/* * Copyright (c) 2023 to present Androidacy and contributors. Names, logos, icons, and the Androidacy name are all trademarks of Androidacy and may not be used without license. See LICENSE for more information. */ @file:Suppress("NAME_SHADOWING", "MemberVisibilityCanBePrivate") package com.fox2code.mmm.androidacy import android.annotation.SuppressLint import android.content.DialogInterface import android.graphics.Color import android.net.Uri import android.os.Build import android.util.TypedValue import android.webkit.JavascriptInterface import android.widget.Toast import androidx.annotation.Keep import androidx.core.content.ContextCompat import com.fox2code.foxcompat.view.FoxDisplay import com.fox2code.mmm.BuildConfig import com.fox2code.mmm.MainApplication import com.fox2code.mmm.R import com.fox2code.mmm.androidacy.AndroidacyUtil.Companion.getModuleId import com.fox2code.mmm.androidacy.AndroidacyUtil.Companion.getModuleTitle import com.fox2code.mmm.androidacy.AndroidacyUtil.Companion.hideToken import com.fox2code.mmm.androidacy.AndroidacyUtil.Companion.isAndroidacyFileUrl import com.fox2code.mmm.androidacy.AndroidacyUtil.Companion.isAndroidacyLink import com.fox2code.mmm.installer.InstallerInitializer.Companion.peekMagiskPath import com.fox2code.mmm.installer.InstallerInitializer.Companion.peekMagiskVersion import com.fox2code.mmm.manager.ModuleInfo import com.fox2code.mmm.manager.ModuleManager.Companion.instance import com.fox2code.mmm.utils.ExternalHelper import com.fox2code.mmm.utils.IntentHelper.Companion.openCustomTab import com.fox2code.mmm.utils.IntentHelper.Companion.openInstaller import com.fox2code.mmm.utils.IntentHelper.Companion.openUrl import com.fox2code.mmm.utils.io.Files.Companion.readSU import com.fox2code.mmm.utils.io.Files.Companion.writeSU import com.fox2code.mmm.utils.io.Hashes.Companion.checkSumFormat import com.fox2code.mmm.utils.io.Hashes.Companion.checkSumName import com.fox2code.mmm.utils.io.Hashes.Companion.checkSumValid import com.google.android.material.dialog.MaterialAlertDialogBuilder import timber.log.Timber import java.io.File import java.io.IOException import java.nio.charset.StandardCharsets import java.util.Objects @Keep class AndroidacyWebAPI( private val activity: AndroidacyActivity, private val allowInstall: Boolean ) { var consumedAction = false var downloadMode = false /** * Allow Androidacy backend to notify compat mode * return current effective compat mode */ @get:JavascriptInterface var effectiveCompatMode = 0 var notifiedCompatMode = 0 fun forceQuitRaw(error: String?) { Toast.makeText(activity, error, Toast.LENGTH_LONG).show() activity.runOnUiThread { activity.forceBackPressed() } activity.backOnResume = true // Set backOnResume just in case downloadMode = false } fun openNativeModuleDialogRaw( moduleUrl: String?, moduleId: String?, installTitle: String?, checksum: String?, canInstall: Boolean ) { if (BuildConfig.DEBUG) Timber.d( "ModuleDialog, downloadUrl: " + hideToken( moduleUrl!! ) + ", moduleId: " + moduleId + ", installTitle: " + installTitle + ", checksum: " + checksum + ", canInstall: " + canInstall ) // moduleUrl should be a valid URL, i.e. in the androidacy.com domain // if it is not, do not proceed if (!isAndroidacyFileUrl(moduleUrl)) { Timber.e("ModuleDialog, invalid URL: %s", moduleUrl) return } downloadMode = false val repoModule = AndroidacyRepoData.getInstance().moduleHashMap[installTitle] val title: String? var description: String? var mmtReborn = false if (repoModule != null) { title = repoModule.moduleInfo.name description = repoModule.moduleInfo.description mmtReborn = repoModule.moduleInfo.mmtReborn if (description.isNullOrEmpty()) { description = activity.getString(R.string.no_desc_found) } } else { // URL Decode installTitle title = installTitle val checkSumType = checkSumName(checksum) description = if (checkSumType == null) { "Checksum: ${if (checksum.isNullOrEmpty()) "null" else checksum}" } else { "$checkSumType: $checksum" } } val builder = MaterialAlertDialogBuilder(activity) builder.setTitle(title).setMessage(description).setCancelable(true) .setIcon(R.drawable.ic_baseline_extension_24) builder.setNegativeButton(R.string.download_module) { _: DialogInterface?, _: Int -> downloadMode = true openCustomTab(activity, moduleUrl) } if (canInstall) { var hasUpdate = false var config: String? = null if (repoModule != null) { config = repoModule.moduleInfo.config val localModuleInfo = instance?.modules?.get(repoModule.id) hasUpdate = localModuleInfo != null && repoModule.moduleInfo.versionCode > localModuleInfo.versionCode } val fConfig = config val fMMTReborn = mmtReborn builder.setPositiveButton(if (hasUpdate) R.string.update_module else R.string.install_module) { _: DialogInterface?, _: Int -> openInstaller( activity, moduleUrl, title, fConfig, checksum, fMMTReborn ) // close activity activity.runOnUiThread { activity.finishAndRemoveTask() } } } builder.setOnCancelListener { _: DialogInterface? -> if (!activity.backOnResume) consumedAction = false } ExternalHelper.INSTANCE.injectButton(builder, { downloadMode = true try { return@injectButton activity.downloadFileAsync(moduleUrl) } catch (e: IOException) { Timber.e(e, "Failed to download module") activity.runOnUiThread { Toast.makeText( activity, R.string.failed_download, Toast.LENGTH_SHORT ).show() } return@injectButton null } }, "androidacy_repo") val dim5dp = FoxDisplay.dpToPixel(5f) builder.setBackgroundInsetStart(dim5dp).setBackgroundInsetEnd(dim5dp) activity.runOnUiThread { val alertDialog = builder.show() for (i in -3..-1) { val alertButton = alertDialog.getButton(i) if (alertButton != null && alertButton.paddingStart > dim5dp) { alertButton.setPadding(dim5dp, dim5dp, dim5dp, dim5dp) } } } } @Suppress("NAME_SHADOWING") fun notifyCompatModeRaw(value: Int) { var value = value if (consumedAction) return if (BuildConfig.DEBUG) Timber.d("Androidacy Compat mode: %s", value) notifiedCompatMode = value if (value < 0) { value = 0 } else if (value > MAX_COMPAT_MODE) { value = MAX_COMPAT_MODE } effectiveCompatMode = value } @JavascriptInterface fun forceQuit(error: String?) { // Allow forceQuit and cancel in downloadMode if (consumedAction && !downloadMode) return consumedAction = true forceQuitRaw(error) } @JavascriptInterface fun cancel() { // Allow forceQuit and cancel in downloadMode if (consumedAction && !downloadMode) return consumedAction = true activity.runOnUiThread { activity.forceBackPressed() } } /** * Open an url always in an external page or browser. */ @JavascriptInterface fun openUrl(url: String?) { if (consumedAction) return consumedAction = true downloadMode = false if (BuildConfig.DEBUG) Timber.d("Received openUrl request: %s", url) if (Uri.parse(url).scheme == "https") { openUrl(activity, url) } } /** * Open an url in a custom tab if possible. */ @JavascriptInterface fun openCustomTab(url: String?) { if (consumedAction) return consumedAction = true downloadMode = false if (BuildConfig.DEBUG) Timber.d("Received openCustomTab request: %s", url) if (Uri.parse(url).scheme == "https") { openCustomTab(activity, url) } } /** * Return if current theme is a light theme. */ @get:JavascriptInterface val isLightTheme: Boolean get() = MainApplication.INSTANCE!!.isLightTheme /** * Check if the manager has received root access * (Note: hasRoot only return true on Magisk rooted phones) */ @JavascriptInterface fun hasRoot(): Boolean { return peekMagiskPath() != null } /** * Check if the install API can be used */ @JavascriptInterface fun canInstall(): Boolean { // With lockdown mode enabled or lack of root, install should not have any effect return allowInstall && hasRoot() && !MainApplication.isShowcaseMode } /** * install a module via url, with the file checked with the md5 checksum value. */ @JavascriptInterface fun install(moduleUrl: String, installTitle: String?, checksum: String?) { // If compat mode is 0, this means Androidacy didn't implemented a download mode yet var installTitle = installTitle var checksum = checksum if (consumedAction || effectiveCompatMode >= 1 && !canInstall()) { return } consumedAction = true downloadMode = false if (BuildConfig.DEBUG) Timber.d("Received install request: $moduleUrl $installTitle $checksum") if (!isAndroidacyLink(moduleUrl)) { forceQuitRaw("Non Androidacy module link used on Androidacy") return } checksum = checkSumFormat(checksum) if (checksum.isNullOrEmpty()) { Timber.w("Androidacy didn't provided a checksum!") } else if (!checkSumValid(checksum)) { forceQuitRaw("Androidacy didn't provided a valid checksum") return } // moduleId is the module parameter in the url val moduleId = getModuleId(moduleUrl) // Let's handle download mode ourself if not implemented if (effectiveCompatMode < 1) { if (!canInstall()) { downloadMode = true activity.runOnUiThread { if (activity.webView != null) { activity.webView!!.loadUrl(moduleUrl) } } } else { openNativeModuleDialogRaw(moduleUrl, moduleId, installTitle, checksum, true) } } else { val repoModule = AndroidacyRepoData.getInstance().moduleHashMap[installTitle] var config: String? = null var mmtReborn = false if (repoModule != null && Objects.requireNonNull(repoModule.moduleInfo.name).length >= 3) { installTitle = repoModule.moduleInfo.name // Set title to module name config = repoModule.moduleInfo.config mmtReborn = repoModule.moduleInfo.mmtReborn } activity.backOnResume = true openInstaller(activity, moduleUrl, installTitle, config, checksum, mmtReborn) } } /** * install a module via url, with the file checked with the md5 checksum value. */ @JavascriptInterface fun openNativeModuleDialog(moduleUrl: String?, moduleId: String?, checksum: String?) { var checksum = checksum if (consumedAction) return consumedAction = true downloadMode = false if (!isAndroidacyLink(moduleUrl)) { forceQuitRaw("Non Androidacy module link used on Androidacy") return } checksum = checkSumFormat(checksum) if (checksum.isNullOrEmpty()) { Timber.w("Androidacy WebView didn't provided a checksum!") } else if (!checkSumValid(checksum)) { forceQuitRaw("Androidacy didn't provided a valid checksum") return } // Get moduleTitle from url val moduleTitle = getModuleTitle(moduleUrl!!) openNativeModuleDialogRaw(moduleUrl, moduleId, moduleTitle, checksum, canInstall()) } /** * Tell if the moduleId is installed on the device */ @JavascriptInterface fun isModuleInstalled(moduleId: String?): Boolean { return instance?.modules?.get(moduleId) != null } /** * Tell if the moduleId is updating and waiting a reboot to update */ @JavascriptInterface fun isModuleUpdating(moduleId: String?): Boolean { val localModuleInfo = instance?.modules?.get(moduleId) return localModuleInfo != null && localModuleInfo.hasFlag(ModuleInfo.FLAG_MODULE_UPDATING) } /** * Return the module version name or null if not installed. */ @JavascriptInterface fun getModuleVersion(moduleId: String?): String? { val localModuleInfo = instance?.modules?.get(moduleId) return localModuleInfo?.version } /** * Return the module version code or -1 if not installed. */ @JavascriptInterface fun getModuleVersionCode(moduleId: String?): Long { val localModuleInfo = instance?.modules?.get(moduleId) return localModuleInfo?.versionCode ?: -1L } /** * Hide action bar if visible, the action bar is only visible by default on notes. */ @JavascriptInterface fun hideActionBar() { if (consumedAction) return consumedAction = true activity.runOnUiThread { activity.hideActionBar() consumedAction = false } } /** * Show action bar if not visible, the action bar is only visible by default on notes. * Optional title param to set action bar title. */ @JavascriptInterface fun showActionBar(title: String?) { if (consumedAction) return consumedAction = true activity.runOnUiThread { activity.showActionBar() if (!title.isNullOrEmpty()) { activity.title = title } consumedAction = false } } /** * Return true if the module is an Androidacy module. */ @JavascriptInterface fun isAndroidacyModule(moduleId: String?): Boolean { val localModuleInfo = instance?.modules?.get(moduleId) return localModuleInfo != null && ("Androidacy" == localModuleInfo.author || isAndroidacyLink( localModuleInfo.config )) } /** * get a module file, return an empty string if not * an Androidacy module or if file doesn't exists. */ @JavascriptInterface fun getAndroidacyModuleFile(moduleId: String, moduleFile: String?): String { var moduleId = moduleId var moduleFile = moduleFile moduleId = moduleId.replace("\\.".toRegex(), "").replace("/".toRegex(), "") if (moduleFile == null || consumedAction || !isAndroidacyModule(moduleId)) return "" moduleFile = moduleFile.replace("\\.".toRegex(), "").replace("/".toRegex(), "") val moduleFolder = File("/data/adb/modules/$moduleId") val absModuleFile = File(moduleFolder, moduleFile).absoluteFile return if (!absModuleFile.path.startsWith(moduleFolder.path)) "" else try { String(readSU(absModuleFile.absoluteFile), StandardCharsets.UTF_8) } catch (e: IOException) { "" } } /** * Create an ".androidacy" file with {@param content} as content * Return true if action succeeded */ @JavascriptInterface fun setAndroidacyModuleMeta(moduleId: String, content: String?): Boolean { var moduleId = moduleId moduleId = moduleId.replace("\\.".toRegex(), "").replace("/".toRegex(), "") if (content == null || consumedAction || !isAndroidacyModule(moduleId)) return false val androidacyMetaFile = File("/data/adb/modules/$moduleId/.androidacy") return try { writeSU(androidacyMetaFile, content.toByteArray(StandardCharsets.UTF_8)) true } catch (e: IOException) { false } } /** * Return current app version code */ @get:JavascriptInterface val appVersionCode: Int get() = BuildConfig.VERSION_CODE /** * Return current app version name */ @get:JavascriptInterface val appVersionName: String get() = BuildConfig.VERSION_NAME /** * Return current magisk version code or 0 if not applicable */ @get:JavascriptInterface val magiskVersionCode: Int get() = if (peekMagiskPath() == null) 0 else peekMagiskVersion() /** * Return current android sdk-int version code, see: * [right here](https://source.android.com/setup/start/build-numbers) */ @get:JavascriptInterface val androidVersionCode: Int get() = Build.VERSION.SDK_INT /** * Return current navigation bar height or 0 if not visible */ @get:JavascriptInterface val navigationBarHeight: Int get() = activity.navigationBarHeight /** * Return current theme accent color */ @get:JavascriptInterface val accentColor: Int get() { val theme = activity.theme val typedValue = TypedValue() theme.resolveAttribute(androidx.appcompat.R.attr.colorPrimary, typedValue, true) if (typedValue.type >= TypedValue.TYPE_FIRST_COLOR_INT && typedValue.type <= TypedValue.TYPE_LAST_COLOR_INT) { return typedValue.data } theme.resolveAttribute(android.R.attr.colorAccent, typedValue, true) return typedValue.data } /** * Return current theme foreground color */ @get:JavascriptInterface val foregroundColor: Int get() = if (activity.isLightTheme) Color.BLACK else Color.WHITE /** * Return current theme background color */ @get:JavascriptInterface val backgroundColor: Int get() { val theme = activity.theme val typedValue = TypedValue() theme.resolveAttribute( com.google.android.material.R.attr.backgroundColor, typedValue, true ) if (typedValue.type >= TypedValue.TYPE_FIRST_COLOR_INT && typedValue.type <= TypedValue.TYPE_LAST_COLOR_INT) { return typedValue.data } theme.resolveAttribute(android.R.attr.background, typedValue, true) return typedValue.data } /** * Return current hex string of monet theme */ @JavascriptInterface fun getMonetColor(id: String): String { @SuppressLint("DiscouragedApi") val nameResourceID = activity.resources.getIdentifier( "@android:color/$id", "color", activity.applicationInfo.packageName ) return if (nameResourceID == 0) { throw IllegalArgumentException("No resource string found with name $id") } else { val color = ContextCompat.getColor(activity, nameResourceID) val red = Color.red(color) val blue = Color.blue(color) val green = Color.green(color) String.format("#%02x%02x%02x", red, green, blue) } } @JavascriptInterface fun setAndroidacyToken(token: String?) { AndroidacyRepoData.getInstance().setToken(token) } // Androidacy feature level declaration method @JavascriptInterface fun notifyCompatUnsupported() { notifyCompatModeRaw(COMPAT_UNSUPPORTED) } @JavascriptInterface fun notifyCompatDownloadButton() { notifyCompatModeRaw(COMPAT_DOWNLOAD) } companion object { const val COMPAT_UNSUPPORTED = 0 const val COMPAT_DOWNLOAD = 1 private const val MAX_COMPAT_MODE = 1 } }