You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
MagiskModuleManager/app/src/main/java/com/fox2code/mmm/androidacy/AndroidacyWebAPI.kt

567 lines
20 KiB
Kotlin

/*
* 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<String?>(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
}
}