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.
567 lines
20 KiB
Kotlin
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
|
|
}
|
|
} |