From 56787539444ee6659fcce7776f1fa7821249721d Mon Sep 17 00:00:00 2001 From: androidacy-user Date: Sat, 29 Mar 2025 19:27:09 -0400 Subject: [PATCH] (chore) AMM v2.3.8 - last supported version before v3 This update includes: - CronetLoader: Added utility for loading Cronet engine builder via reflection without direct dependency on Google Play Services. - Cronet Provider: Attempted to install GMS Cronet provider using reflection. - Notification: Added a notification for the last supported version before v3. - Strings: Added strings related to the last supported version message. - Code Cleanup: Refactored code for readability and maintainability. - Library Updates: Updated dependencies and libraries. - UI Improvements: Minor UI adjustments and improvements. Signed-off-by: androidacy-user --- app/build.gradle.kts | 62 ++--- .../kotlin/com/fox2code/mmm/CrashHandler.kt | 14 +- .../kotlin/com/fox2code/mmm/MainActivity.kt | 26 ++- .../com/fox2code/mmm/MainApplication.kt | 11 +- .../com/fox2code/mmm/NotificationType.kt | 14 +- .../kotlin/com/fox2code/mmm/SetupActivity.kt | 46 ++-- .../kotlin/com/fox2code/mmm/UpdateActivity.kt | 2 + .../mmm/androidacy/AndroidacyActivity.kt | 11 + .../fox2code/mmm/androidacy/AndroidacyUtil.kt | 23 +- .../mmm/installer/InstallerActivity.kt | 54 +++-- .../fox2code/mmm/settings/SettingsActivity.kt | 10 + .../com/fox2code/mmm/utils/ExternalHelper.kt | 3 +- .../com/fox2code/mmm/utils/RuntimeUtils.kt | 24 +- .../com/fox2code/mmm/utils/io/CronetLoader.kt | 220 ++++++++++++++++++ .../kotlin/com/fox2code/mmm/utils/io/Files.kt | 2 +- .../mmm/utils/io/GMSProviderInstaller.kt | 8 + .../com/fox2code/mmm/utils/io/net/Http.kt | 25 +- app/src/main/res/values/strings.xml | 2 + 18 files changed, 440 insertions(+), 117 deletions(-) create mode 100644 app/src/main/kotlin/com/fox2code/mmm/utils/io/CronetLoader.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e82a7e9..3eb35a4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -45,6 +45,8 @@ android { defaultConfig { applicationId = "com.fox2code.mmm" minSdk = 26 + // app is in maintenance mode, we do not intend to update it to target newer SDKs + //noinspection OldTargetApi targetSdk = 34 versionCode = 93 versionName = "2.3.8" @@ -52,33 +54,34 @@ android { useSupportLibrary = true } multiDexEnabled = true - resourceConfigurations.addAll( - listOf( - "ar", - "bs", - "cs", - "de", - "es-rMX", - "es", - "el", - "fr", - "hu", - "id", - "it", - "ja", - "nl", - "pl", - "pt", - "pt-rBR", - "ru", - "tr", - "uk", - "vi", - "zh", - "zh-rTW", - "en" + androidResources { + localeFilters.addAll( + listOf( + "ar", + "bs", + "cs", + "de", + "es-rMX", + "es", + "el", + "fr", + "hu", + "id", + "it", + "ja", + "nl", + "pl", + "pt", + "pt-rBR", + "ru", + "tr", + "uk", + "vi", + "zh", + "zh-rTW" + ) ) - ) + } ksp { arg("room.schemaLocation", "$projectDir/roomSchemas") } @@ -161,8 +164,7 @@ android { propertiesA.load(project.rootProject.file("androidacy.properties").reader()) propertiesA.setProperty( "client_id", propertiesA.getProperty( - "client_id", - default + "client_id", default ) ) } else { @@ -214,8 +216,7 @@ android { propertiesA.load(project.rootProject.file("androidacy.properties").reader()) propertiesA.setProperty( "client_id", propertiesA.getProperty( - "client_id", - default + "client_id", default ) ) } else { @@ -370,6 +371,7 @@ dependencies { // logging interceptor debugImplementation("com.squareup.okhttp3:logging-interceptor:5.0.0-alpha.14") // Chromium cronet from androidacy + // implementation("com.google.android.gms:play-services-cronet:18.1.0") implementation("org.chromium.net:cronet-embedded:119.6045.31") val libsuVersion = "6.0.0" diff --git a/app/src/main/kotlin/com/fox2code/mmm/CrashHandler.kt b/app/src/main/kotlin/com/fox2code/mmm/CrashHandler.kt index b0c7b56..6169ebe 100644 --- a/app/src/main/kotlin/com/fox2code/mmm/CrashHandler.kt +++ b/app/src/main/kotlin/com/fox2code/mmm/CrashHandler.kt @@ -12,7 +12,10 @@ import android.content.Intent import android.os.Bundle import android.view.View import android.widget.Toast +import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat import cat.ereza.customactivityoncrash.CustomActivityOnCrash import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.textview.MaterialTextView @@ -22,13 +25,20 @@ class CrashHandler : AppCompatActivity() { @SuppressLint("RestrictedApi") override fun onCreate(savedInstanceState: Bundle?) { if (MainApplication.forceDebugLogging) Timber.i( - "CrashHandler.onCreate(%s)", - savedInstanceState + "CrashHandler.onCreate(%s)", savedInstanceState ) // log intent with extras if (MainApplication.forceDebugLogging) Timber.d("CrashHandler.onCreate: intent=%s", intent) + enableEdgeToEdge() super.onCreate(savedInstanceState) setContentView(R.layout.activity_crash_handler) + val view = findViewById(android.R.id.content) + + ViewCompat.setOnApplyWindowInsetsListener(view) { view, windowInsets -> + val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + view.setPadding(0, insets.top, 0, insets.bottom) + WindowInsetsCompat.CONSUMED + } // set crash_details MaterialTextView to the exception passed in the intent or unknown if null // convert stacktrace from array to string, and pretty print it (first line is the exception, the rest is the stacktrace, with each line indented by 4 spaces) val crashDetails = findViewById(R.id.crash_details) diff --git a/app/src/main/kotlin/com/fox2code/mmm/MainActivity.kt b/app/src/main/kotlin/com/fox2code/mmm/MainActivity.kt index cf5807f..b99a4ea 100644 --- a/app/src/main/kotlin/com/fox2code/mmm/MainActivity.kt +++ b/app/src/main/kotlin/com/fox2code/mmm/MainActivity.kt @@ -29,11 +29,14 @@ import android.view.inputmethod.EditorInfo import android.view.inputmethod.InputMethodManager import android.widget.EditText import android.widget.Toast +import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsAnimationCompat import androidx.core.view.WindowInsetsCompat +import androidx.core.view.get +import androidx.core.view.isVisible import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.room.Room @@ -66,7 +69,6 @@ import com.fox2code.mmm.utils.io.net.Http.Companion.hasWebView import com.fox2code.mmm.utils.room.ReposListDatabase import com.google.android.material.bottomnavigation.BottomNavigationView import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.elevation.SurfaceColors import com.google.android.material.floatingactionbutton.FloatingActionButton import com.google.android.material.progressindicator.LinearProgressIndicator import com.google.android.material.textfield.TextInputEditText @@ -79,7 +81,6 @@ import java.io.FileOutputStream import java.io.InputStream import java.io.OutputStream import kotlin.math.roundToInt -import androidx.core.view.isVisible class MainActivity : AppCompatActivity(), OnRefreshListener, OverScrollHelper { @@ -208,6 +209,8 @@ class MainActivity : AppCompatActivity(), OnRefreshListener, OverScrollHelper { doSetupRestarting = false } onMainActivityCreate(this) + enableEdgeToEdge() + // make sure we don't draw behind the status bar super.onCreate(savedInstanceState) INSTANCE = this // check for pref_crashed and if so start crash handler @@ -254,9 +257,16 @@ class MainActivity : AppCompatActivity(), OnRefreshListener, OverScrollHelper { }.start() MainApplication.getInstance().check(this) setContentView(R.layout.activity_main) + val view = findViewById(android.R.id.content) + ViewCompat.setOnApplyWindowInsetsListener(view) { view, windowInsets -> + val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + overScrollInsetTop = insets.top + overScrollInsetBottom = insets.bottom + view.setPadding(0, insets.top, 0, insets.bottom) + WindowInsetsCompat.CONSUMED + } this.setTitle(R.string.app_name_v2) // set navigation bar color based on surfacecolors - window.navigationBarColor = SurfaceColors.SURFACE_2.getColor(this) progressIndicator = findViewById(R.id.progress_bar) progressIndicator?.max = PRECISION progressIndicator?.min = 0 @@ -270,12 +280,10 @@ class MainActivity : AppCompatActivity(), OnRefreshListener, OverScrollHelper { moduleListOnline = findViewById(R.id.module_list_online) searchTextInputEditText = findViewById(R.id.search_input) val textInputEditText = searchTextInputEditText!! - val view = findViewById(R.id.root_container) var startBottom = 0f var endBottom = 0f ViewCompat.setWindowInsetsAnimationCallback( - view, - object : WindowInsetsAnimationCompat.Callback(DISPATCH_MODE_STOP) { + view, object : WindowInsetsAnimationCompat.Callback(DISPATCH_MODE_STOP) { // Override methods… override fun onProgress( insets: WindowInsetsCompat, @@ -567,6 +575,8 @@ class MainActivity : AppCompatActivity(), OnRefreshListener, OverScrollHelper { } // reset update module and update module count in main application MainApplication.getInstance().resetUpdateModule() + moduleViewListBuilder.addNotification(NotificationType.LAST_VER) + moduleViewListBuilderOnline.addNotification(NotificationType.LAST_VER) tryGetMagiskPathAsync(object : InstallerInitializer.Callback { override fun onPathReceived(path: String?) { if (MainApplication.forceDebugLogging) Timber.i("Got magisk path: %s", path) @@ -630,7 +640,7 @@ class MainActivity : AppCompatActivity(), OnRefreshListener, OverScrollHelper { moduleViewListBuilder.addNotification(NotificationType.NO_WEB_VIEW) // disable online tab runOnUiThread { - bottomNavigationView.menu.getItem(1).isEnabled = false + bottomNavigationView.menu[1].isEnabled = false bottomNavigationView.selectedItemId = R.id.installed_menu_item } } @@ -825,7 +835,7 @@ class MainActivity : AppCompatActivity(), OnRefreshListener, OverScrollHelper { } override fun onRefresh() { - if (swipeRefreshBlocker > System.currentTimeMillis() || initMode || progressIndicator == null || progressIndicator!!.visibility == View.VISIBLE || doSetupNowRunning) { + if (swipeRefreshBlocker > System.currentTimeMillis() || initMode || progressIndicator == null || progressIndicator!!.isVisible || doSetupNowRunning) { swipeRefreshLayout!!.isRefreshing = false return // Do not double scan } diff --git a/app/src/main/kotlin/com/fox2code/mmm/MainApplication.kt b/app/src/main/kotlin/com/fox2code/mmm/MainApplication.kt index 3d9a2ae..0ef83d0 100644 --- a/app/src/main/kotlin/com/fox2code/mmm/MainApplication.kt +++ b/app/src/main/kotlin/com/fox2code/mmm/MainApplication.kt @@ -554,12 +554,10 @@ class MainApplication : Application(), Configuration.Provider, ActivityLifecycle private var shellBuilder: Shell.Builder? = null // Is application wrapped, and therefore must reduce it's feature set. - @SuppressLint("RestrictedApi") // Use FoxProcess wrapper helper. const val IS_WRAPPED = false init { - - assert(!IS_WRAPPED) { "This application is not wrapped!" } + assert(!IS_WRAPPED) { "This application is wrapped!" } } private val callers = ArrayList() @@ -737,9 +735,8 @@ class MainApplication : Application(), Configuration.Provider, ActivityLifecycle if (updateCheckBg != null) { return java.lang.Boolean.parseBoolean(updateCheckBg) } - val wrapped = IS_WRAPPED - val updateCheckBgTemp = !wrapped && getPreferences("mmm")!!.getBoolean( - "pref_background_update_check", true + @Suppress("KotlinConstantConditions") val updateCheckBgTemp = getPreferences("mmm")!!.getBoolean( + "pref_background_update_check", BuildConfig.ENABLE_AUTO_UPDATER ) updateCheckBg = updateCheckBgTemp.toString() return java.lang.Boolean.parseBoolean(updateCheckBg) @@ -753,6 +750,7 @@ class MainApplication : Application(), Configuration.Provider, ActivityLifecycle getPreferences("mmm")!!.edit { putBoolean("has_root_access", bool) } } + @Suppress("KotlinConstantConditions") val isCrashReportingEnabled: Boolean get() = analyticsAllowed() && getPreferences("mmm")!!.getBoolean( "pref_crash_reporting", BuildConfig.DEFAULT_ENABLE_CRASH_REPORTING @@ -768,6 +766,7 @@ class MainApplication : Application(), Configuration.Provider, ActivityLifecycle val isNotificationPermissionGranted: Boolean get() = NotificationManagerCompat.from((INSTANCE)!!).areNotificationsEnabled() + @Suppress("KotlinConstantConditions") fun analyticsAllowed(): Boolean { return getPreferences("mmm")!!.getBoolean( "pref_analytics_enabled", BuildConfig.DEFAULT_ENABLE_ANALYTICS diff --git a/app/src/main/kotlin/com/fox2code/mmm/NotificationType.kt b/app/src/main/kotlin/com/fox2code/mmm/NotificationType.kt index 66170a1..487de81 100644 --- a/app/src/main/kotlin/com/fox2code/mmm/NotificationType.kt +++ b/app/src/main/kotlin/com/fox2code/mmm/NotificationType.kt @@ -88,7 +88,6 @@ enum class NotificationType( } }, - MAGISK_OUTDATED( R.string.magisk_outdated, R.drawable.ic_baseline_update_24, @@ -225,6 +224,19 @@ enum class NotificationType( return !BuildConfig.DEBUG && (MainApplication.isShowcaseMode || InstallerInitializer.peekMagiskPath() == null) } }, + LAST_VER( + R.string.last_ver, + R.drawable.ic_baseline_info_24, + com.google.android.material.R.attr.colorSurfaceBright, + com.google.android.material.R.attr.colorOnSurface, + View.OnClickListener { v: View -> + MaterialAlertDialogBuilder(v.context) + .setTitle(R.string.last_ver) + .setMessage(R.string.last_ver_message) + .setPositiveButton(android.R.string.ok, null) + .show() + } + ), KSU_EXPERIMENTAL( R.string.ksu_experimental, R.drawable.ic_baseline_warning_24, diff --git a/app/src/main/kotlin/com/fox2code/mmm/SetupActivity.kt b/app/src/main/kotlin/com/fox2code/mmm/SetupActivity.kt index 69412ad..e565c6c 100644 --- a/app/src/main/kotlin/com/fox2code/mmm/SetupActivity.kt +++ b/app/src/main/kotlin/com/fox2code/mmm/SetupActivity.kt @@ -11,14 +11,18 @@ import android.content.DialogInterface import android.content.Intent import android.content.pm.PackageManager import android.content.res.Resources.Theme -import android.net.Uri import android.os.Bundle import android.os.Process import android.view.View import android.webkit.CookieManager import android.widget.CompoundButton import android.widget.Toast +import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.edit +import androidx.core.net.toUri +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat import androidx.fragment.app.FragmentActivity import androidx.room.Room import com.fox2code.mmm.databinding.ActivitySetupBinding @@ -49,9 +53,9 @@ class SetupActivity : AppCompatActivity(), LanguageActivity { @SuppressLint("ApplySharedPref", "RestrictedApi") @Suppress("KotlinConstantConditions", "NAME_SHADOWING") override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() super.onCreate(savedInstanceState) this.setTitle(R.string.setup_title) - this.window.navigationBarColor = this.getColor(R.color.black_transparent) createFiles() disableUpdateActivityForFdroidFlavor() if (BuildConfig.DEBUG) { @@ -68,6 +72,12 @@ class SetupActivity : AppCompatActivity(), LanguageActivity { } val binding = ActivitySetupBinding.inflate(layoutInflater) setContentView(binding.root) + + ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view, windowInsets -> + val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + view.setPadding(0, insets.top, 0, insets.bottom) + WindowInsetsCompat.CONSUMED + } MainApplication.getInstance().check(this) val view: View = binding.root // if our application id is "com.androidacy.mmm" or begins with it, check if com.fox2code.mmm is installed and offer to uninstall it. if we're com.fox2code.mmm, check if com.fox2code.mmm.fdroid or com.fox2code.mmm.debug is installed and offer to uninstall it @@ -93,14 +103,13 @@ class SetupActivity : AppCompatActivity(), LanguageActivity { materialAlertDialogBuilder.setTitle(R.string.setup_uninstall_title) materialAlertDialogBuilder.setMessage( getString( - R.string.setup_uninstall_message, - packageName + R.string.setup_uninstall_message, packageName ) ) materialAlertDialogBuilder.setPositiveButton(R.string.uninstall) { _: DialogInterface?, _: Int -> // start uninstall intent val intent = Intent(Intent.ACTION_DELETE) - intent.data = Uri.parse("package:$packageName") + intent.data = "package:$packageName".toUri() startActivity(intent) } materialAlertDialogBuilder.setNegativeButton(R.string.cancel) { _: DialogInterface?, _: Int -> } @@ -113,14 +122,13 @@ class SetupActivity : AppCompatActivity(), LanguageActivity { materialAlertDialogBuilder.setTitle(R.string.setup_uninstall_title) materialAlertDialogBuilder.setMessage( getString( - R.string.setup_uninstall_message, - packageName + R.string.setup_uninstall_message, packageName ) ) materialAlertDialogBuilder.setPositiveButton(R.string.uninstall) { _: DialogInterface?, _: Int -> // start uninstall intent val intent = Intent(Intent.ACTION_DELETE) - intent.data = Uri.parse("package:$packageName") + intent.data = "package:$packageName".toUri() startActivity(intent) } materialAlertDialogBuilder.setNegativeButton(R.string.cancel) { _: DialogInterface?, _: Int -> } @@ -133,14 +141,13 @@ class SetupActivity : AppCompatActivity(), LanguageActivity { materialAlertDialogBuilder.setTitle(R.string.setup_uninstall_title) materialAlertDialogBuilder.setMessage( getString( - R.string.setup_uninstall_message, - packageName + R.string.setup_uninstall_message, packageName ) ) materialAlertDialogBuilder.setPositiveButton(R.string.uninstall) { _: DialogInterface?, _: Int -> // start uninstall intent val intent = Intent(Intent.ACTION_DELETE) - intent.data = Uri.parse("package:$packageName") + intent.data = "package:$packageName".toUri() startActivity(intent) } materialAlertDialogBuilder.setNegativeButton(R.string.cancel) { _: DialogInterface?, _: Int -> } @@ -250,7 +257,7 @@ class SetupActivity : AppCompatActivity(), LanguageActivity { themeNames, checkedItem ) { dialog: DialogInterface, which: Int -> // Set the theme - prefs.edit().putString("pref_theme", themeValues[which]).commit() + prefs.edit(commit = true) { putString("pref_theme", themeValues[which]) } // Dismiss the dialog dialog.dismiss() // Set the theme @@ -356,21 +363,17 @@ class SetupActivity : AppCompatActivity(), LanguageActivity { editor.commit() // Log the changes if (MainApplication.forceDebugLogging) Timber.d( - "Setup finished. Preferences: %s", - prefs.all + "Setup finished. Preferences: %s", prefs.all ) if (MainApplication.forceDebugLogging) Timber.d( - "Androidacy repo: %s", - androidacyRepoRoom + "Androidacy repo: %s", androidacyRepoRoom ) if (MainApplication.forceDebugLogging) Timber.d( - "Magisk Alt repo: %s", - magiskAltRepoRoom + "Magisk Alt repo: %s", magiskAltRepoRoom ) // log last shown setup if (MainApplication.forceDebugLogging) Timber.d( - "Last shown setup: %s", - prefs.getString("last_shown_setup", "v0") + "Last shown setup: %s", prefs.getString("last_shown_setup", "v0") ) // Restart the activity MainActivity.doSetupRestarting = true @@ -544,8 +547,7 @@ class SetupActivity : AppCompatActivity(), LanguageActivity { db.close() db2.close() if (MainApplication.forceDebugLogging) Timber.d( - "Databases created in %s ms", - System.currentTimeMillis() - startTime + "Databases created in %s ms", System.currentTimeMillis() - startTime ) } thread.start() diff --git a/app/src/main/kotlin/com/fox2code/mmm/UpdateActivity.kt b/app/src/main/kotlin/com/fox2code/mmm/UpdateActivity.kt index 9830b42..051957c 100644 --- a/app/src/main/kotlin/com/fox2code/mmm/UpdateActivity.kt +++ b/app/src/main/kotlin/com/fox2code/mmm/UpdateActivity.kt @@ -12,6 +12,7 @@ import android.view.View import android.webkit.CookieManager import android.webkit.WebSettings import android.webkit.WebView +import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.content.res.AppCompatResources import androidx.core.content.FileProvider @@ -37,6 +38,7 @@ class UpdateActivity : AppCompatActivity() { @SuppressLint("RestrictedApi", "SetJavaScriptEnabled") override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() super.onCreate(savedInstanceState) setContentView(R.layout.activity_update) MainApplication.getInstance().check(this) diff --git a/app/src/main/kotlin/com/fox2code/mmm/androidacy/AndroidacyActivity.kt b/app/src/main/kotlin/com/fox2code/mmm/androidacy/AndroidacyActivity.kt index 3ce4176..383ac48 100644 --- a/app/src/main/kotlin/com/fox2code/mmm/androidacy/AndroidacyActivity.kt +++ b/app/src/main/kotlin/com/fox2code/mmm/androidacy/AndroidacyActivity.kt @@ -26,8 +26,11 @@ import android.webkit.WebSettings import android.webkit.WebView import android.widget.TextView import android.widget.Toast +import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AppCompatActivity import androidx.core.content.FileProvider +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import androidx.webkit.WebResourceErrorCompat import androidx.webkit.WebSettingsCompat @@ -71,6 +74,7 @@ class AndroidacyActivity : AppCompatActivity() { ) override fun onCreate(savedInstanceState: Bundle?) { moduleFile = File(this.cacheDir, "module.zip") + enableEdgeToEdge() super.onCreate(savedInstanceState) val intent = this.intent @@ -127,6 +131,13 @@ class AndroidacyActivity : AppCompatActivity() { val config = intent.getStringExtra(Constants.EXTRA_ANDROIDACY_ACTIONBAR_CONFIG) val compatLevel = intent.getIntExtra(Constants.EXTRA_ANDROIDACY_COMPAT_LEVEL, 0) this.setContentView(R.layout.webview) + + val view = findViewById(android.R.id.content) + ViewCompat.setOnApplyWindowInsetsListener(view) { view, windowInsets -> + val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + view.setPadding(0, insets.top, 0, 0) + WindowInsetsCompat.CONSUMED + } if (title.isNullOrEmpty()) { title = "Androidacy" } diff --git a/app/src/main/kotlin/com/fox2code/mmm/androidacy/AndroidacyUtil.kt b/app/src/main/kotlin/com/fox2code/mmm/androidacy/AndroidacyUtil.kt index 2db45aa..d30650e 100644 --- a/app/src/main/kotlin/com/fox2code/mmm/androidacy/AndroidacyUtil.kt +++ b/app/src/main/kotlin/com/fox2code/mmm/androidacy/AndroidacyUtil.kt @@ -8,6 +8,7 @@ package com.fox2code.mmm.androidacy import android.net.Uri import com.fox2code.mmm.BuildConfig +import androidx.core.net.toUri @Suppress("MemberVisibilityCanBePrivate", "MemberVisibilityCanBePrivate") enum class AndroidacyUtil { @@ -79,8 +80,17 @@ enum class AndroidacyUtil { // Strip non alphanumeric moduleId = moduleId.replace("[^a-zA-Z\\d]".toRegex(), "") return moduleId + }// fallback to last sergment minus .zip + // Get the last segment + val lastSegment = moduleUrl.toUri().lastPathSegment + // Check if it ends with .zip + if (lastSegment != null && lastSegment.endsWith(".zip")) { + // Strip the .zip + moduleId = lastSegment.substring(0, lastSegment.length - 4) + // Strip non alphanumeric + moduleId = moduleId.replace("[^a-zA-Z\\d]".toRegex(), "") + return moduleId } - require(!BuildConfig.DEBUG) { "Invalid module url: $moduleUrl" } return null } @@ -96,6 +106,17 @@ enum class AndroidacyUtil { Uri.decode(moduleUrl.substring(i + 13, j)) } } + // fallback to last sergment minus .zip + // Get the last segment + val lastSegment = moduleUrl.toUri().lastPathSegment + // Check if it ends with .zip + if (lastSegment != null && lastSegment.endsWith(".zip")) { + // Strip the .zip + val moduleId = lastSegment.substring(0, lastSegment.length - 4) + // Strip non alphanumeric + val moduleTitle = moduleId.replace("[^a-zA-Z\\d]".toRegex(), "") + return moduleTitle + } return null } diff --git a/app/src/main/kotlin/com/fox2code/mmm/installer/InstallerActivity.kt b/app/src/main/kotlin/com/fox2code/mmm/installer/InstallerActivity.kt index 7b288dc..98dd988 100644 --- a/app/src/main/kotlin/com/fox2code/mmm/installer/InstallerActivity.kt +++ b/app/src/main/kotlin/com/fox2code/mmm/installer/InstallerActivity.kt @@ -12,7 +12,6 @@ import android.content.DialogInterface import android.content.Intent import android.content.pm.PackageManager import android.graphics.Color -import android.graphics.drawable.ColorDrawable import android.os.Build import android.os.Bundle import android.os.PowerManager @@ -21,8 +20,13 @@ import android.view.KeyEvent import android.view.View import android.view.WindowManager import android.widget.Toast +import androidx.activity.enableEdgeToEdge import androidx.annotation.Keep import androidx.appcompat.app.AppCompatActivity +import androidx.core.graphics.drawable.toDrawable +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.isVisible import androidx.recyclerview.widget.RecyclerView import com.fox2code.androidansi.AnsiConstants import com.fox2code.androidansi.AnsiParser @@ -65,16 +69,16 @@ import java.io.InputStreamReader import java.util.Enumeration import java.util.concurrent.Executor import java.util.zip.ZipEntry -import androidx.core.graphics.drawable.toDrawable -import androidx.core.view.isVisible class InstallerActivity : AppCompatActivity() { private var canGoBack: Boolean = false private val isLightTheme: Boolean get() = MainApplication.getInstance().isLightTheme private var progressIndicator: LinearProgressIndicator? = null + @SuppressLint("RestrictedApi") private var rebootFloatingButton: BottomNavigationItemView? = null + @SuppressLint("RestrictedApi") private var cancelFloatingButton: BottomNavigationItemView? = null private var installerTerminal: InstallerTerminal? = null @@ -87,10 +91,11 @@ class InstallerActivity : AppCompatActivity() { @SuppressLint("RestrictedApi") override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + super.onCreate(savedInstanceState) warnReboot = false moduleCache = File(this.cacheDir, "installer") if (!moduleCache!!.exists() && !moduleCache!!.mkdirs()) Timber.e("Failed to mkdir module cache dir!") - super.onCreate(savedInstanceState) val intent = this.intent val target: String @@ -142,6 +147,12 @@ class InstallerActivity : AppCompatActivity() { title = name textWrap = MainApplication.isTextWrapEnabled setContentView(if (textWrap) R.layout.installer_wrap else R.layout.installer) + val view = findViewById(android.R.id.content) + ViewCompat.setOnApplyWindowInsetsListener(view) { view, windowInsets -> + val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + view.setPadding(0, insets.top, 0, insets.bottom) + WindowInsetsCompat.CONSUMED + } val background: Int val foreground: Int if (MainApplication.getInstance().isLightTheme && !MainApplication.isForceDarkTerminal) { @@ -181,14 +192,16 @@ class InstallerActivity : AppCompatActivity() { if (urlMode) installerTerminal!!.addLine("- Downloading $name") // track install if (MainApplication.analyticsAllowed()) { - Countly.sharedInstance().events().recordEvent("install", mapOf( - "name" to name, - "url" to target, - "checksum" to checksum, - "noExtensions" to noExtensions, - "rootless" to rootless, - "mmtReborn" to mmtReborn - )) + Countly.sharedInstance().events().recordEvent( + "install", mapOf( + "name" to name, + "url" to target, + "checksum" to checksum, + "noExtensions" to noExtensions, + "rootless" to rootless, + "mmtReborn" to mmtReborn + ) + ) } Thread(Runnable { // ensure module cache is is in our cache dir @@ -229,7 +242,10 @@ class InstallerActivity : AppCompatActivity() { } if (canceled) return@Runnable if (!checksum.isNullOrEmpty()) { - if (MainApplication.forceDebugLogging) Timber.i("Checking for checksum: %s", checksum.toString()) + if (MainApplication.forceDebugLogging) Timber.i( + "Checking for checksum: %s", + checksum.toString() + ) runOnUiThread { installerTerminal!!.addLine("- Checking file integrity") } if (!checkSumMatch(rawModule, checksum)) { setInstallStateFinished(false, "! File integrity check failed", "") @@ -342,12 +358,12 @@ class InstallerActivity : AppCompatActivity() { ) val installerMonitor: InstallerMonitor val installJob: Shell.Job - val mgskPath = InstallerInitializer.peekMagiskPath() - val ashExec = if (!InstallerInitializer.isKsu) { - "$mgskPath/magisk/busybox ash" - } else { - "$mgskPath/ksu/busybox ash" - } + val mgskPath = InstallerInitializer.peekMagiskPath() + val ashExec = if (!InstallerInitializer.isKsu) { + "$mgskPath/magisk/busybox ash" + } else { + "$mgskPath/ksu/busybox ash" + } if (rootless) { // rootless is only used for debugging val installScript = extractInstallScript("module_installer_test.sh") diff --git a/app/src/main/kotlin/com/fox2code/mmm/settings/SettingsActivity.kt b/app/src/main/kotlin/com/fox2code/mmm/settings/SettingsActivity.kt index 389b70f..54adc14 100644 --- a/app/src/main/kotlin/com/fox2code/mmm/settings/SettingsActivity.kt +++ b/app/src/main/kotlin/com/fox2code/mmm/settings/SettingsActivity.kt @@ -16,9 +16,12 @@ import android.os.Bundle import android.view.MenuItem import android.view.View import android.widget.Toast +import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AppCompatActivity import androidx.core.content.edit import androidx.core.net.toUri +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat import androidx.fragment.app.FragmentTransaction import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat @@ -76,6 +79,7 @@ class SettingsActivity : AppCompatActivity(), LanguageActivity, @SuppressLint("RestrictedApi", "CommitTransaction") override fun onCreate(savedInstanceState: Bundle?) { devModeStep = 0 + enableEdgeToEdge() super.onCreate(savedInstanceState) // check for pref_crashed and if so start crash handler @@ -101,6 +105,12 @@ class SettingsActivity : AppCompatActivity(), LanguageActivity, } setContentView(R.layout.settings_activity) + val view = findViewById(android.R.id.content) + ViewCompat.setOnApplyWindowInsetsListener(view) { view, windowInsets -> + val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + view.setPadding(0, insets.top, 0, insets.bottom) + WindowInsetsCompat.CONSUMED + } setTitle(R.string.app_name_v2) MainApplication.getInstance().check(this) //hideActionBar(); diff --git a/app/src/main/kotlin/com/fox2code/mmm/utils/ExternalHelper.kt b/app/src/main/kotlin/com/fox2code/mmm/utils/ExternalHelper.kt index 95d2af8..a4a1f49 100644 --- a/app/src/main/kotlin/com/fox2code/mmm/utils/ExternalHelper.kt +++ b/app/src/main/kotlin/com/fox2code/mmm/utils/ExternalHelper.kt @@ -20,13 +20,14 @@ import com.fox2code.mmm.Constants import com.fox2code.mmm.MainApplication import com.topjohnwu.superuser.internal.UiThreadHandler import timber.log.Timber +import androidx.core.net.toUri class ExternalHelper private constructor() { private var fallback: ComponentName? = null private var label: CharSequence? = null private var multi = false fun refreshHelper(context: Context) { - val intent = Intent(FOX_MMM_OPEN_EXTERNAL, Uri.parse("https://fox2code.com/module.zip")) + val intent = Intent(FOX_MMM_OPEN_EXTERNAL, "https://fox2code.com/module.zip".toUri()) val resolveInfos = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { context.packageManager.queryIntentActivities( diff --git a/app/src/main/kotlin/com/fox2code/mmm/utils/RuntimeUtils.kt b/app/src/main/kotlin/com/fox2code/mmm/utils/RuntimeUtils.kt index f9c1305..81e4014 100644 --- a/app/src/main/kotlin/com/fox2code/mmm/utils/RuntimeUtils.kt +++ b/app/src/main/kotlin/com/fox2code/mmm/utils/RuntimeUtils.kt @@ -27,6 +27,8 @@ import com.fox2code.mmm.SetupActivity import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.topjohnwu.superuser.Shell import timber.log.Timber +import androidx.core.content.edit +import androidx.core.net.toUri @Suppress("UNUSED_PARAMETER") class RuntimeUtils { @@ -64,9 +66,11 @@ class RuntimeUtils { checkBox.setOnCheckedChangeListener { _: CompoundButton?, isChecked: Boolean -> PreferenceManager.getDefaultSharedPreferences( context - ).edit().putBoolean( - "pref_dont_ask_again_notification_permission", isChecked - ).apply() + ).edit { + putBoolean( + "pref_dont_ask_again_notification_permission", isChecked + ) + } } builder.setView(view) builder.setPositiveButton(R.string.permission_notification_grant) { _, _ -> @@ -79,7 +83,7 @@ class RuntimeUtils { builder.setNegativeButton(R.string.cancel) { dialog, _ -> // Set pref_background_update_check to false and dismiss dialog val prefs = PreferenceManager.getDefaultSharedPreferences(context) - prefs.edit().putBoolean("pref_background_update_check", false).apply() + prefs.edit { putBoolean("pref_background_update_check", false) } dialog.dismiss() MainActivity.doSetupNowRunning = false } @@ -118,9 +122,9 @@ class RuntimeUtils { checkBox.setOnCheckedChangeListener { _: CompoundButton?, isChecked: Boolean -> PreferenceManager.getDefaultSharedPreferences( context - ).edit() - .putBoolean("pref_dont_ask_again_notification_permission", isChecked) - .apply() + ).edit { + putBoolean("pref_dont_ask_again_notification_permission", isChecked) + } } builder.setView(view) builder.setPositiveButton(R.string.permission_notification_grant) { _, _ -> @@ -135,7 +139,7 @@ class RuntimeUtils { builder.setNegativeButton(R.string.cancel) { dialog, _ -> // Set pref_background_update_check to false and dismiss dialog val prefs = MainApplication.getPreferences("mmm")!! - prefs.edit().putBoolean("pref_background_update_check", false).apply() + prefs.edit { putBoolean("pref_background_update_check", false) } dialog.dismiss() MainActivity.doSetupNowRunning = false } @@ -223,12 +227,12 @@ class RuntimeUtils { builder.setPositiveButton(R.string.upgrade_now) { dialog, _ -> val intent = Intent(Intent.ACTION_VIEW) intent.data = - Uri.parse("https://androidacy.com/membership-join/#utm_source=AMMM&utm_medium=app&utm_campaign=upgrade_snackbar") + "https://androidacy.com/membership-join/#utm_source=AMMM&utm_medium=app&utm_campaign=upgrade_snackbar".toUri() activity.startActivity(intent) dialog.dismiss() } // do not show for another 7 days - prefs.edit().putLong("ugsns4", System.currentTimeMillis()).apply() + prefs.edit { putLong("ugsns4", System.currentTimeMillis()) } if (MainApplication.forceDebugLogging) Timber.i("showUpgradeSnackbar done") } diff --git a/app/src/main/kotlin/com/fox2code/mmm/utils/io/CronetLoader.kt b/app/src/main/kotlin/com/fox2code/mmm/utils/io/CronetLoader.kt new file mode 100644 index 0000000..f8a093c --- /dev/null +++ b/app/src/main/kotlin/com/fox2code/mmm/utils/io/CronetLoader.kt @@ -0,0 +1,220 @@ +package com.fox2code.mmm.utils.io + +import android.content.Context +import android.content.pm.PackageManager +import timber.log.Timber + +/** + * Utility for loading Cronet engine builder through reflection + * without direct dependency on Google Play Services + * + * Of course, actually getting the provider does rely on GMS but we have a fallback packaged in the app. + */ +class CronetLoader { + companion object { + private const val GMS_PROVIDER_NAME = "Google-Play-Services-Cronet-Provider" + private const val GMS_PACKAGE = "com.google.android.gms" + private const val DYNAMITE_MODULE_CLASS = "com.google.android.gms.dynamite.DynamiteModule" + private const val DYNAMITE_MODULE_ID = "com.google.android.gms.cronet_dynamite" + + /** + * Get a Cronet engine builder via reflection, attempting to install GMS provider first + * + * @param context Application context + * @return CronetEngine.Builder instance or null if unavailable + */ + fun getCronetEngineBuilder(context: Context): Any? { + // Check if GMS is installed + val gmsInstalled = isGmsInstalled(context) + + if (gmsInstalled) { + Timber.d("GMS is installed, attempting to load Cronet provider") + // Try to initialize GMS provider + try { + installGmsProviderViaReflection(context) + } catch (e: Exception) { + Timber.w("Failed to install GMS Cronet provider: ${e.message}") + false + } + } else { + Timber.d("GMS is not installed, skipping GMS provider initialization") + } + + // Whether GMS was initialized or not, try to get available providers + return getProviderBuilder(context) + } + + /** + * Check if Google Play Services is installed on the device + */ + private fun isGmsInstalled(context: Context): Boolean { + return try { + // Try to create a package context for GMS + context.createPackageContext( + GMS_PACKAGE, Context.CONTEXT_INCLUDE_CODE or Context.CONTEXT_IGNORE_SECURITY + ) + true + } catch (e: PackageManager.NameNotFoundException) { + Timber.d("Google Play Services not installed") + false + } catch (e: Exception) { + Timber.d("Error checking for Google Play Services: ${e.message}") + false + } + } + + /** + * Attempt to install GMS Cronet provider using reflection + */ + private fun installGmsProviderViaReflection(context: Context): Boolean { + try { + // Try to load Dynamite module class + val dynamiteClass = try { + Class.forName(DYNAMITE_MODULE_CLASS) + } catch (e: ClassNotFoundException) { + Timber.d("DynamiteModule class not found") + return false + } + + // Find the load method + val loadMethod = try { + // We need to find the right version of the load method + dynamiteClass.getDeclaredMethods().firstOrNull { method -> + method.name == "load" && method.parameterTypes.size == 3 && method.parameterTypes[0] == Context::class.java && method.parameterTypes[2] == String::class.java + } + } catch (e: Exception) { + Timber.d("Could not find appropriate load method: ${e.message}") + return false + } + + if (loadMethod == null) { + Timber.d("Could not find appropriate load method") + return false + } + + // Get the PREFER_REMOTE constant + val preferRemoteObj = try { + val fields = dynamiteClass.declaredFields + val preferRemoteField = fields.firstOrNull { field -> + field.name == "PREFER_REMOTE" + } + + if (preferRemoteField == null) { + Timber.d("PREFER_REMOTE field not found") + return false + } + + preferRemoteField.get(null) + } catch (e: Exception) { + Timber.d("Error getting PREFER_REMOTE: ${e.message}") + return false + } + + try { + // Attempt to load the Cronet dynamite module + loadMethod.invoke(null, context, preferRemoteObj, DYNAMITE_MODULE_ID) + Timber.d("Successfully loaded GMS Cronet dynamite module") + return true + } catch (e: Exception) { + Timber.d("Failed to load Cronet dynamite module: ${e.message}") + return false + } + + } catch (e: Exception) { + Timber.d("Error during GMS provider installation: ${e.message}") + return false + } + } + + /** + * Get a builder from available providers, prioritizing GMS + */ + private fun getProviderBuilder(context: Context): Any? { + try { + // Try to access the CronetProvider class + val providerClass = try { + Class.forName("org.chromium.net.CronetProvider") + } catch (e: ClassNotFoundException) { + Timber.d("Cronet API not available") + return null + } + + // Get all available providers + val getAllProvidersMethod = + providerClass.getMethod("getAllProviders", Context::class.java) + val providers = + getAllProvidersMethod.invoke(null, context) as? List<*> ?: emptyList() + + if (providers.isEmpty()) { + Timber.d("No Cronet providers found") + return null + } + + // Get method references we'll need + val isEnabledMethod = providerClass.getMethod("isEnabled") + val getNameMethod = providerClass.getMethod("getName") + val createBuilderMethod = providerClass.getMethod("createBuilder") + + // Get fallback name constant + val fallbackName = try { + val field = providerClass.getField("PROVIDER_NAME_FALLBACK") + field.get(null) as String + } catch (e: Exception) { + "fallback" + } + + // Get native name constant + val nativeName = try { + val field = providerClass.getField("PROVIDER_NAME_APP_PACKAGED") + field.get(null) as String + } catch (e: Exception) { + "APP_PACKAGED" + } + + // Filter and categorize providers + val gmsProviders = mutableListOf() + val nativeProviders = mutableListOf() + val otherProviders = mutableListOf() + + for (provider in providers) { + try { + val isEnabled = isEnabledMethod.invoke(provider) as Boolean + val name = getNameMethod.invoke(provider) as String + + if (isEnabled && name != fallbackName && provider != null) { + when (name) { + GMS_PROVIDER_NAME -> gmsProviders.add(provider) + nativeName -> nativeProviders.add(provider) + else -> otherProviders.add(provider) + } + Timber.d("Found enabled provider: $name") + } + } catch (e: Exception) { + Timber.d("Error checking provider: ${e.message}") + } + } + + // Try providers in order of preference + val orderedProviders = gmsProviders + nativeProviders + otherProviders + + for (provider in orderedProviders) { + try { + val name = getNameMethod.invoke(provider) as String + val builder = createBuilderMethod.invoke(provider) + Timber.d("Successfully created Cronet builder using: $name") + return builder + } catch (e: Exception) { + Timber.w("Failed to create builder from provider: ${e.message}") + } + } + + Timber.d("Could not create Cronet builder from any provider") + + } catch (e: Exception) { + Timber.e(e, "Error getting Cronet providers") + } + + return null + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/fox2code/mmm/utils/io/Files.kt b/app/src/main/kotlin/com/fox2code/mmm/utils/io/Files.kt index 2e0b577..a6b7653 100644 --- a/app/src/main/kotlin/com/fox2code/mmm/utils/io/Files.kt +++ b/app/src/main/kotlin/com/fox2code/mmm/utils/io/Files.kt @@ -223,7 +223,7 @@ enum class Files { // unzip if (MainApplication.forceDebugLogging) Timber.d("Unzipping module to %s", tempUnzipDir.absolutePath) try { - ZipFile(tempFile).use { zipFile -> + ZipFile.builder().setFile(tempFile).get().use { zipFile -> var files = zipFile.entries // check if there is only one folder in the top level var folderCount = 0 diff --git a/app/src/main/kotlin/com/fox2code/mmm/utils/io/GMSProviderInstaller.kt b/app/src/main/kotlin/com/fox2code/mmm/utils/io/GMSProviderInstaller.kt index e6400c2..598c37e 100644 --- a/app/src/main/kotlin/com/fox2code/mmm/utils/io/GMSProviderInstaller.kt +++ b/app/src/main/kotlin/com/fox2code/mmm/utils/io/GMSProviderInstaller.kt @@ -36,6 +36,14 @@ enum class GMSProviderInstaller { remote.classLoader.loadClass("com.google.android.gms.common.security.ProviderInstallerImpl") cl.getDeclaredMethod("insertProvider", Context::class.java).invoke(null, remote) if (MainApplication.forceDebugLogging) Timber.i("Installed GMS security providers!") + // next, try cronet provider + val cronet = context.createPackageContext( + "com.google.android.gms", + Context.CONTEXT_INCLUDE_CODE or Context.CONTEXT_IGNORE_SECURITY + ) + val cronetCl = + cronet.classLoader.loadClass("com.google.android.gms.common.security.ProviderInstallerImpl") + cronetCl.getDeclaredMethod("insertProvider", Context::class.java).invoke(null, cronet) } catch (e: PackageManager.NameNotFoundException) { Timber.w("No GMS Implementation are installed on this device") } catch (e: Exception) { diff --git a/app/src/main/kotlin/com/fox2code/mmm/utils/io/net/Http.kt b/app/src/main/kotlin/com/fox2code/mmm/utils/io/net/Http.kt index 2a6b33a..c70099d 100644 --- a/app/src/main/kotlin/com/fox2code/mmm/utils/io/net/Http.kt +++ b/app/src/main/kotlin/com/fox2code/mmm/utils/io/net/Http.kt @@ -28,6 +28,7 @@ import com.fox2code.mmm.R import com.fox2code.mmm.androidacy.AndroidacyUtil import com.fox2code.mmm.installer.InstallerInitializer.Companion.peekMagiskPath import com.fox2code.mmm.installer.InstallerInitializer.Companion.peekMagiskVersion +import com.fox2code.mmm.utils.io.CronetLoader import com.fox2code.mmm.utils.io.Files.Companion.makeBuffer import com.google.net.cronet.okhttptransport.CronetInterceptor import ly.count.android.sdk.Countly @@ -190,19 +191,6 @@ enum class Http {; init { val mainApplication = MainApplication.getInstance() - if (mainApplication == null) { - val error = Error("Initialized Http too soon!") - error.fillInStackTrace() - Timber.e(error, "Initialized Http too soon!") - System.out.flush() - System.err.flush() - try { - Os.kill(Os.getpid(), 9) - } catch (e: ErrnoException) { - exitProcess(9) - } - throw error - } var cookieManager: CookieManager? = null try { cookieManager = CookieManager.getInstance() @@ -318,9 +306,14 @@ enum class Http {; // Add cronet interceptor // init cronet try { - // Load the cronet library - val builder: CronetEngine.Builder = - CronetEngine.Builder(mainApplication.applicationContext) + val cronetEngine = try { + // Load the cronet library + CronetLoader.getCronetEngineBuilder(MainApplication.getInstance().applicationContext) as CronetEngine.Builder + } catch (e: Exception) { + Timber.e(e, "Failed to load cronet library from GMS") + null + } + val builder = cronetEngine ?: CronetEngine.Builder(MainApplication.getInstance().applicationContext) builder.enableBrotli(true) builder.enableHttp2(true) builder.enableQuic(true) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7cbc2cc..a8ef754 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -430,4 +430,6 @@ No update URL found Could not determine a suitable URL to reinstall or update this module from. Please check with the source you got this module from. Our other apps + AMM V3 coming soon! + V3 of AMM is launching soon! This version (2.3.8) is the last supported version of any version before v3, and will be deprecated shortly after launching v3.