From b7dd20c21f31cca31dfd3380d463977bd5577d42 Mon Sep 17 00:00:00 2001 From: kmmiio99o <164557248+kmmiio99o@users.noreply.github.com> Date: Mon, 10 Nov 2025 16:35:14 +0100 Subject: [PATCH] feat: Add installer selection and Root support This commit adds the ability to select the installation method. It also includes: - Added root support using the `pm` command. - Integrated the Shizuku installer. - Added a new Settings UI for choosing an installer. --- README.md | 2 +- app/build.gradle.kts | 5 + app/src/main/AndroidManifest.xml | 70 ++--- .../aliucord/manager/ManagerApplication.kt | 10 +- .../aliucord/manager/installers/Installer.kt | 2 +- .../manager/installers/InstallerError.kt | 7 + .../manager/installers/pm/PMInstaller.kt | 140 +++------ .../aliucord/manager/installers/pm/PMUtils.kt | 143 +++++++++ .../manager/installers/root/RootInstaller.kt | 75 +++++ .../installers/shizuku/ShizukuInstaller.kt | 278 ++++++++++++++++++ .../shizuku/ShizukuInstallerError.kt | 14 + .../shizuku/ShizukuIntentReceiver.kt | 50 ++++ .../shizuku/ShizukuResultReceiver.kt | 29 ++ .../manager/manager/InstallerManager.kt | 44 ++- .../manager/manager/PreferencesManager.kt | 1 + .../patcher/steps/install/InstallStep.kt | 6 +- .../screens/home/HomeScreenLoadedPreview.kt | 1 + .../manager/ui/screens/home/HomeModel.kt | 44 ++- .../manager/ui/screens/home/HomeScreen.kt | 9 + .../home/components/InstalledItemCard.kt | 8 + .../ui/screens/settings/SettingsModel.kt | 70 +++-- .../ui/screens/settings/SettingsScreen.kt | 119 +++++++- .../settings/components/InstallerDialog.kt | 97 ++++++ .../settings/components/RootDetectedDialog.kt | 63 ++++ .../settings/components/ShizukuInfoDialog.kt | 95 ++++++ .../ui/widgets/updater/UpdaterViewModel.kt | 3 +- app/src/main/res/drawable/ic_android.xml | 12 + ...ord_shiggy.xml => ic_discord_aliucord.xml} | 0 app/src/main/res/values/strings.xml | 9 +- gradle/libs.versions.toml | 16 + settings.gradle.kts | 5 + 31 files changed, 1229 insertions(+), 198 deletions(-) create mode 100644 app/src/main/kotlin/com/aliucord/manager/installers/InstallerError.kt create mode 100644 app/src/main/kotlin/com/aliucord/manager/installers/pm/PMUtils.kt create mode 100644 app/src/main/kotlin/com/aliucord/manager/installers/root/RootInstaller.kt create mode 100644 app/src/main/kotlin/com/aliucord/manager/installers/shizuku/ShizukuInstaller.kt create mode 100644 app/src/main/kotlin/com/aliucord/manager/installers/shizuku/ShizukuInstallerError.kt create mode 100644 app/src/main/kotlin/com/aliucord/manager/installers/shizuku/ShizukuIntentReceiver.kt create mode 100644 app/src/main/kotlin/com/aliucord/manager/installers/shizuku/ShizukuResultReceiver.kt create mode 100644 app/src/main/kotlin/com/aliucord/manager/ui/screens/settings/components/InstallerDialog.kt create mode 100644 app/src/main/kotlin/com/aliucord/manager/ui/screens/settings/components/RootDetectedDialog.kt create mode 100644 app/src/main/kotlin/com/aliucord/manager/ui/screens/settings/components/ShizukuInfoDialog.kt create mode 100644 app/src/main/res/drawable/ic_android.xml rename app/src/main/res/drawable/{ic_discord_shiggy.xml => ic_discord_aliucord.xml} (100%) diff --git a/README.md b/README.md index b1d7672..156101a 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ APK output: `app/build/outputs/apk/debug/app-debug.apk`. You can fully tailor the appearance: - App name: edit `app/src/main/res/values/strings.xml` — ensure the UI uses `@string/app_name`. -- Icons: swap `app/src/main/res/drawable/ic_rounded_shiggy.xml` and `ic_discord_shiggy.xml` with your vector or PNG. +- Icons: swap `app/src/main/res/drawable/ic_rounded_shiggy.xml` and `app/src/main/res/drawable/ic_discord_aliucord.xml` with your vector or PNG. - Colors: use `colors.xml` to apply a new palette. - Glyph sizing: vector drawables support ``. To center an element after scaling, compute translation as: diff --git a/app/build.gradle.kts b/app/build.gradle.kts index decf0a5..e0cc2d4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -9,6 +9,7 @@ plugins { alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.parcelize) alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.hiddenApi.refine) } val isRelease = System.getenv("RELEASE")?.toBoolean() ?: false @@ -186,12 +187,16 @@ dependencies { implementation(libs.binaryResources) implementation(libs.diff) implementation(libs.microg) + implementation(libs.bundles.shizuku) implementation(libs.smali) implementation(libs.baksmali) implementation(libs.compose.pipette) implementation(libs.compose.shimmer) implementation(libs.zip) + compileOnly(libs.hiddenApi.stub) + implementation(libs.hiddenApi.refine) + implementation(libs.hiddenApi.bypass) coreLibraryDesugaring(libs.desugaring) } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b8f8b76..fd5861e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,6 +1,5 @@ - - + + @@ -14,45 +13,48 @@ - - + + - android:label="@string/app_name" - android:largeHeap="true" - android:requestLegacyExternalStorage="true" - android:roundIcon="@mipmap/ic_launcher_round" - android:supportsRtl="true" - android:theme="@style/Theme.AppCompat.NoActionBar"> - - + - + android:name="androidx.core.content.FileProvider" + android:authorities="${applicationId}.provider" + android:exported="false" + android:grantUriPermissions="true" + > + + + + android:name="com.aliucord.manager.MainActivity" + android:exported="true" + android:launchMode="singleTask" + android:screenOrientation="portrait" + android:theme="@style/Theme.AliucordManager.SplashScreen" + tools:ignore="DiscouragedApi,LockedOrientationActivity" + > diff --git a/app/src/main/kotlin/com/aliucord/manager/ManagerApplication.kt b/app/src/main/kotlin/com/aliucord/manager/ManagerApplication.kt index 6c9ffb7..f59d3fc 100644 --- a/app/src/main/kotlin/com/aliucord/manager/ManagerApplication.kt +++ b/app/src/main/kotlin/com/aliucord/manager/ManagerApplication.kt @@ -20,6 +20,8 @@ import com.aliucord.manager.ui.screens.patchopts.PatchOptionsModel import com.aliucord.manager.ui.screens.plugins.PluginsModel import com.aliucord.manager.ui.screens.settings.SettingsModel import com.aliucord.manager.ui.widgets.updater.UpdaterViewModel +import com.aliucord.manager.installers.root.RootInstaller +import com.aliucord.manager.installers.shizuku.ShizukuInstaller import kotlinx.coroutines.Dispatchers import org.koin.android.ext.koin.androidContext import org.koin.core.context.startKoin @@ -54,11 +56,11 @@ class ManagerApplication : Application() { // UI Models modules(module { - factoryOf(::HomeModel) + factory { HomeModel(get(), get(), get(), get(), get(), get(), get()) } factoryOf(::PluginsModel) factoryOf(::AboutModel) factoryOf(::PatchingScreenModel) - factoryOf(::SettingsModel) + factory { SettingsModel(get(), get(), get(), get()) } factoryOf(::PatchOptionsModel) factoryOf(::IconOptionsModel) factoryOf(::LogScreenModel) @@ -80,7 +82,9 @@ class ManagerApplication : Application() { // Installers modules(module { - singleOf(::PMInstaller) + single { PMInstaller(get()) } + single { RootInstaller() } + singleOf(::ShizukuInstaller) }) } diff --git a/app/src/main/kotlin/com/aliucord/manager/installers/Installer.kt b/app/src/main/kotlin/com/aliucord/manager/installers/Installer.kt index 331ebb6..11be3a7 100644 --- a/app/src/main/kotlin/com/aliucord/manager/installers/Installer.kt +++ b/app/src/main/kotlin/com/aliucord/manager/installers/Installer.kt @@ -11,7 +11,7 @@ interface Installer { * @param apks All APKs (including any splits) to merge into a single install. * @param silent If this is an update, then the update will occur without user interaction. */ - fun install(apks: List, silent: Boolean = true) + suspend fun install(apks: List, silent: Boolean = true) /** * Starts an installation and waits for it to finish with a result. A toast will be shown for all result states. diff --git a/app/src/main/kotlin/com/aliucord/manager/installers/InstallerError.kt b/app/src/main/kotlin/com/aliucord/manager/installers/InstallerError.kt new file mode 100644 index 0000000..6d99859 --- /dev/null +++ b/app/src/main/kotlin/com/aliucord/manager/installers/InstallerError.kt @@ -0,0 +1,7 @@ +package com.aliucord.manager.installers + +import android.os.Parcelable + +interface InstallerError : Parcelable { + val message: String +} diff --git a/app/src/main/kotlin/com/aliucord/manager/installers/pm/PMInstaller.kt b/app/src/main/kotlin/com/aliucord/manager/installers/pm/PMInstaller.kt index a6670e5..98a1437 100644 --- a/app/src/main/kotlin/com/aliucord/manager/installers/pm/PMInstaller.kt +++ b/app/src/main/kotlin/com/aliucord/manager/installers/pm/PMInstaller.kt @@ -1,17 +1,11 @@ package com.aliucord.manager.installers.pm import android.app.Application -import android.app.PendingIntent -import android.content.Intent -import android.content.pm.* -import android.content.pm.PackageInstaller.SessionParams -import android.os.Build -import android.os.Process +import android.content.pm.PackageInstaller +import android.content.pm.PackageManager import android.util.Log -import androidx.core.content.ContextCompat -import com.aliucord.manager.installers.Installer -import com.aliucord.manager.util.isMiui import dev.shiggy.manager.BuildConfig +import com.aliucord.manager.installers.Installer import kotlinx.coroutines.suspendCancellableCoroutine import java.io.File import kotlin.coroutines.resume @@ -32,70 +26,64 @@ class PMInstaller( } } - override fun install(apks: List, silent: Boolean) { - startInstall(createInstallSession(silent), apks, false) + override suspend fun install(apks: List, silent: Boolean) { + val sessionId = createInstallSession(silent) + + PMUtils.startInstall( + context = context, + session = _packageInstaller.openSession(sessionId), + sessionId = sessionId, + apks = apks, + relay = false, + ) } override suspend fun waitInstall(apks: List, silent: Boolean) = suspendCancellableCoroutine { continuation -> // Create a new install session val sessionId = createInstallSession(silent) - // This will receive parsed data forwarded by PMIntentReceiver - val relayReceiver = PMResultReceiver( + // Create and register a result receiver + val relayReceiver = PMUtils.registerRelayReceiver( + context = context, sessionId = sessionId, isUninstall = false, - onResult = { continuation.resume(it) }, + onResult = continuation::resume, ) - ContextCompat.registerReceiver( - /* context = */ context, - /* receiver = */ relayReceiver, - /* filter = */ PMResultReceiver.intentFilter, - /* flags = */ ContextCompat.RECEIVER_NOT_EXPORTED, - ) - - // Unregister PMResultReceiver when this coroutine finishes or error occurs - // Additionally, cancel the install session entirely - continuation.invokeOnCancellation { error -> + // Unregister PMResultReceiver when this coroutine finishes or errors + // Explicitly cancel the install session if it did not finish. + continuation.invokeOnCancellation { context.unregisterReceiver(relayReceiver) - context.packageManager.packageInstaller.abandonSession(sessionId) + _packageInstaller.abandonSession(sessionId) } - startInstall(sessionId, apks, relay = true) + PMUtils.startInstall( + context = context, + session = _packageInstaller.openSession(sessionId), + sessionId = sessionId, + apks = apks, + relay = true, + ) } override suspend fun waitUninstall(packageName: String) = suspendCancellableCoroutine { continuation -> - val callbackIntent = Intent(context, PMIntentReceiver::class.java) - .putExtra(PMIntentReceiver.EXTRA_SESSION_ID, -1) - .putExtra(PMIntentReceiver.EXTRA_RELAY_ENABLED, true) - - val pendingIntent = PendingIntent.getBroadcast( - /* context = */ context, - /* requestCode = */ 0, - /* intent = */ callbackIntent, - /* flags = */ PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, - ) - - // This will receive parsed data forwarded by PMIntentReceiver - val relayReceiver = PMResultReceiver( + // Create and register a result receiver + val relayReceiver = PMUtils.registerRelayReceiver( + context = context, sessionId = -1, isUninstall = true, - onResult = { continuation.resume(it) }, + onResult = continuation::resume, ) - ContextCompat.registerReceiver( - /* context = */ context, - /* receiver = */ relayReceiver, - /* filter = */ PMResultReceiver.intentFilter, - /* flags = */ ContextCompat.RECEIVER_NOT_EXPORTED, - ) - - // Unregister PMResultReceiver when this coroutine is cancelled early - continuation.invokeOnCancellation { error -> + // Unregister PMResultReceiver when this coroutine finishes or errors + continuation.invokeOnCancellation { context.unregisterReceiver(relayReceiver) } - _packageInstaller.uninstall(packageName, pendingIntent.intentSender) + _packageInstaller.uninstall( + /* packageName = */ packageName, + /* statusReceiver = */ PMUtils.createUninstallRelayingIntent(context).intentSender, + ) } /** @@ -104,56 +92,6 @@ class PMInstaller( * @return The open install session id. */ private fun createInstallSession(silent: Boolean): Int { - val params = SessionParams(SessionParams.MODE_FULL_INSTALL).apply { - setInstallLocation(PackageInfo.INSTALL_LOCATION_AUTO) - - if (Build.VERSION.SDK_INT >= 24) setOriginatingUid(Process.myUid()) - if (Build.VERSION.SDK_INT >= 26) setInstallReason(PackageManager.INSTALL_REASON_USER) - - if (Build.VERSION.SDK_INT >= 31) { - setInstallScenario(PackageManager.INSTALL_SCENARIO_FAST) - - // Allegedly MIUI is not happy with silent installs - if (silent && !isMiui()) { - setRequireUserAction(SessionParams.USER_ACTION_NOT_REQUIRED) - } - } - - if (Build.VERSION.SDK_INT >= 34) setPackageSource(PackageInstaller.PACKAGE_SOURCE_OTHER) - } - - return context.packageManager.packageInstaller.createSession(params) - } - - /** - * Start a [PackageInstaller] session for installation. - * @param apks The apks to install - * @param relay Whether to use the [PMResultReceiver] flow. - * @return The [PendingIntent] that was registered to the [PackageInstaller.Session] - */ - private fun startInstall(sessionId: Int, apks: List, relay: Boolean) { - val callbackIntent = Intent(context, PMIntentReceiver::class.java) - .putExtra(PMIntentReceiver.EXTRA_SESSION_ID, sessionId) - .putExtra(PMIntentReceiver.EXTRA_RELAY_ENABLED, relay) - - val pendingIntent = PendingIntent.getBroadcast( - /* context = */ context, - /* requestCode = */ 0, - /* intent = */ callbackIntent, - /* flags = */ PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, - ) - - _packageInstaller.openSession(sessionId).use { session -> - val bufferSize = 1 * 1024 * 1024 // 1MiB - - for (apk in apks) { - session.openWrite(apk.name, 0, apk.length()).use { outStream -> - apk.inputStream().use { it.copyTo(outStream, bufferSize) } - session.fsync(outStream) - } - } - - session.commit(pendingIntent.intentSender) - } + return _packageInstaller.createSession(PMUtils.createInstallSessionParams(silent = silent)) } } diff --git a/app/src/main/kotlin/com/aliucord/manager/installers/pm/PMUtils.kt b/app/src/main/kotlin/com/aliucord/manager/installers/pm/PMUtils.kt new file mode 100644 index 0000000..fec069a --- /dev/null +++ b/app/src/main/kotlin/com/aliucord/manager/installers/pm/PMUtils.kt @@ -0,0 +1,143 @@ +package com.aliucord.manager.installers.pm + +import android.annotation.SuppressLint +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageInstaller +import android.content.pm.PackageInstaller.SessionParams +import android.content.pm.PackageManager +import android.os.Build +import android.os.Process +import androidx.core.content.ContextCompat +import com.aliucord.manager.installers.InstallerResult +import com.aliucord.manager.util.isMiui +import java.io.File + +/** + * Shared functionality between all types of installers that directly interface with the [PackageInstaller] API. + */ +object PMUtils { + /** + * Creates install sessions params for [PackageInstaller]. + * @param silent If this is an update, then the update will occur without user interaction. + */ + @Suppress("DEPRECATION") + fun createInstallSessionParams(silent: Boolean): SessionParams { + return SessionParams(SessionParams.MODE_FULL_INSTALL).apply { + if (Build.VERSION.SDK_INT >= 24) setOriginatingUid(Process.myUid()) + if (Build.VERSION.SDK_INT >= 26) setInstallReason(PackageManager.INSTALL_REASON_USER) + if (Build.VERSION.SDK_INT >= 30) setAutoRevokePermissionsMode(false) + + if (Build.VERSION.SDK_INT >= 31) { + setInstallScenario(PackageManager.INSTALL_SCENARIO_FAST) + + // Allegedly MIUI is not happy with silent installs + if (silent && !isMiui()) { + setRequireUserAction(SessionParams.USER_ACTION_NOT_REQUIRED) + } + } + + if (Build.VERSION.SDK_INT >= 34) setPackageSource(PackageInstaller.PACKAGE_SOURCE_OTHER) + } + } + + /** + * Registers a [PMResultReceiver] to receive results relayed by [PMIntentReceiver], to return the state + * back to the application. + * + * @param context Android context. + * @param sessionId The [PackageInstaller] install session ID to filter for. + * @param isUninstall Whether this operation is an uninstallation. + * @param onResult A callback lambda providing the parsed installation result. + * @return The receiver that was registered. This should be unregistered manually, such as + * upon the cancellation of the registering coroutine. + */ + fun registerRelayReceiver( + context: Context, + sessionId: Int, + isUninstall: Boolean, + onResult: (InstallerResult) -> Unit, + ): PMResultReceiver { + // This will receive parsed data forwarded by PMIntentReceiver + val relayReceiver = PMResultReceiver( + sessionId = sessionId, + isUninstall = isUninstall, + onResult = onResult, + ) + + ContextCompat.registerReceiver( + /* context = */ context, + /* receiver = */ relayReceiver, + /* filter = */ PMResultReceiver.intentFilter, + /* flags = */ ContextCompat.RECEIVER_NOT_EXPORTED, + ) + + return relayReceiver + } + + // TODO: Install progress? + // PackageInstaller.Session#setStagingProgress + // PackageInstaller.SessionCallback#onProgressChanged(int, float) + + /** + * Start a [PackageInstaller] session for installation. + * @param context Android context + * @param session A newly opened install session to be written to. Ones binded through Shizuku also work. + * @param sessionId The install session ID of [session]. + * @param apks The apks to install + * @param relay Whether to use the [PMResultReceiver] flow. + */ + fun startInstall( + context: Context, + session: PackageInstaller.Session, + sessionId: Int, + apks: List, + relay: Boolean, + ) { + val callbackIntent = Intent(context, PMIntentReceiver::class.java) + .putExtra(PMIntentReceiver.EXTRA_SESSION_ID, sessionId) + .putExtra(PMIntentReceiver.EXTRA_RELAY_ENABLED, relay) + + val pendingIntent = PendingIntent.getBroadcast( + /* context = */ context, + /* requestCode = */ 0, + /* intent = */ callbackIntent, + /* flags = */ PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + + session.use { session -> + val bufferSize = 1 * 1024 * 1024 // 1MiB + + for (apk in apks) { + session.openWrite(apk.name, 0, apk.length()).use { outStream -> + apk.inputStream().use { it.copyTo(outStream, bufferSize) } + session.fsync(outStream) + } + } + + @SuppressLint("RequestInstallPackagesPolicy") + session.commit(pendingIntent.intentSender) + } + } + + /** + * Creates an uninstallation callback [PendingIntent] that will forward events + * to the relaying [PMIntentReceiver]. These events can be captured by registering [PMResultReceiver] + * through [registerRelayReceiver]. + */ + fun createUninstallRelayingIntent(context: Context): PendingIntent { + // FIXME: Conflicting pending intents when multiple simultaneous uninstalls are happening. + // The extras will end up being merged into one pending intent, with only the newest one working. + val callbackIntent = Intent(context, PMIntentReceiver::class.java) + .putExtra(PMIntentReceiver.EXTRA_SESSION_ID, -1) + .putExtra(PMIntentReceiver.EXTRA_RELAY_ENABLED, true) + + return PendingIntent.getBroadcast( + /* context = */ context, + /* requestCode = */ 0, + /* intent = */ callbackIntent, + /* flags = */ PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + } +} diff --git a/app/src/main/kotlin/com/aliucord/manager/installers/root/RootInstaller.kt b/app/src/main/kotlin/com/aliucord/manager/installers/root/RootInstaller.kt new file mode 100644 index 0000000..300cf00 --- /dev/null +++ b/app/src/main/kotlin/com/aliucord/manager/installers/root/RootInstaller.kt @@ -0,0 +1,75 @@ +package com.aliucord.manager.installers.root + +import com.aliucord.manager.installers.Installer +import com.aliucord.manager.installers.InstallerResult +import com.aliucord.manager.installers.InstallerResult.Error +import java.io.File +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import kotlinx.coroutines.runBlocking + +class RootInstaller : Installer { + override suspend fun install(apks: List, silent: Boolean) { + waitInstall(apks, silent) + } + + override suspend fun waitInstall(apks: List, silent: Boolean): InstallerResult { + // Create install session + val installCommand = StringBuilder("pm install-create") + if (silent) { + installCommand.append(" -r --user 0") + } + val output = execute(installCommand.toString()) + val sessionId = output.substringAfter("Success: created install session [").substringBefore("]").toIntOrNull() + if (sessionId == null) { + println("RootInstaller: install-create failed: $output") + return PMInstallerError("Failed to create install session: $output") + } + + // Write each APK to the session + for (apk in apks) { + val writeCmd = "pm install-write -S ${apk.length()} $sessionId ${apk.name} - < \"${apk.absolutePath}\"" + val writeOutput = execute(writeCmd) + if (!writeOutput.contains("Success")) { + println("RootInstaller: install-write failed: $writeOutput") + execute("pm install-abandon $sessionId") + return PMInstallerError("Failed to write APK: $writeOutput") + } + } + + // Commit the session + val commitOutput = execute("pm install-commit $sessionId") + if (!commitOutput.contains("Success")) { + println("RootInstaller: install-commit failed: $commitOutput") + return PMInstallerError("Failed to commit install session: $commitOutput") + } + + return InstallerResult.Success + } + + override suspend fun waitUninstall(packageName: String): InstallerResult { + val output = execute("pm uninstall $packageName") + if (!output.contains("Success")) { + println("RootInstaller: uninstall failed: $output") + return PMInstallerError("Failed to uninstall: $output") + } + return InstallerResult.Success + } + + private fun execute(command: String): String { + return try { + val process = ProcessBuilder("su", "-c", command).start() + val output = process.inputStream.bufferedReader().readText() + process.waitFor() + output + } catch (e: Exception) { + println("RootInstaller: Exception during command execution: ${e.message}") + e.toString() + } + } +} + +@Parcelize +data class PMInstallerError(private val reason: String) : Error(), Parcelable { + override fun getDebugReason(): String = reason +} diff --git a/app/src/main/kotlin/com/aliucord/manager/installers/shizuku/ShizukuInstaller.kt b/app/src/main/kotlin/com/aliucord/manager/installers/shizuku/ShizukuInstaller.kt new file mode 100644 index 0000000..ffc168a --- /dev/null +++ b/app/src/main/kotlin/com/aliucord/manager/installers/shizuku/ShizukuInstaller.kt @@ -0,0 +1,278 @@ +package com.aliucord.manager.installers.shizuku + +import android.content.Context +import android.content.pm.* +import android.content.pm.PackageInstaller.SessionParams +import android.content.pm.PackageInstallerHidden.SessionParamsHidden +import android.os.Build +import com.aliucord.manager.installers.Installer +import com.aliucord.manager.installers.InstallerResult +import com.aliucord.manager.installers.pm.PMUtils +import com.aliucord.manager.util.showToast +import dev.rikka.tools.refine.Refine +import dev.shiggy.manager.R +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.sync.Mutex +import org.lsposed.hiddenapibypass.HiddenApiBypass +import rikka.shizuku.* +import java.io.File +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.coroutines.resume +import kotlin.random.Random + +// Based on https://github.com/Tobi823/ffupdater/blob/9830452fe1cb3b77b28175833c68118a63d5ca69/ffupdater/src/main/java/de/marmaro/krt/ffupdater/installer/impl/ShizukuInstaller.kt + +class ShizukuInstaller(private val context: Context) : Installer { + private var shizukuPermissionLock = Mutex() + private val shizukuAvailable: StateFlow + field = MutableStateFlow(null) + + // Listeners + private val onBinderReceived = Shizuku.OnBinderReceivedListener { + shizukuAvailable.value = true + } + private val onBinderDead = Shizuku.OnBinderDeadListener { + shizukuAvailable.value = false + + if (shizukuPermissionLock.isLocked) + shizukuPermissionLock.unlock() + } + + init { + Shizuku.addBinderReceivedListenerSticky(onBinderReceived) + Shizuku.addBinderDeadListener(onBinderDead) + } + + fun stop() { + Shizuku.removeBinderReceivedListener(onBinderReceived) + Shizuku.removeBinderDeadListener(onBinderDead) + } + + /** + * Determines whether Shizuku is available and the binder has been retrieved. + */ + fun shizukuAvailable(): Boolean = when (shizukuAvailable.value) { + true -> true + false -> false + null -> { + Shizuku.pingBinder().also { shizukuAvailable.value = it } + } + } + + /** + * Checks whether Shizuku permissions have been granted to this app. + */ + fun checkPermissions(): Boolean { + if (!shizukuAvailable()) return false + + // Old shizuku does not have permission checks + if (Shizuku.isPreV11()) return true + + return Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED + } + + /** + * Requests and waits for Shizuku permissions if they have not already been granted. + */ + suspend fun requestPermissions(): Boolean { + if (!shizukuAvailable()) return false + + // Lock and check if the previous holder already obtained permissions + shizukuPermissionLock.lock() + try { + if (checkPermissions()) { + shizukuPermissionLock.unlock() + return true + } + } catch (_: Exception) { + shizukuPermissionLock.unlock() + } + + return suspendCancellableCoroutine { continuation -> + val currentRequestCode = Random.nextInt() + val onPermissionRequestResult = Shizuku.OnRequestPermissionResultListener { requestCode, grantResult -> + if (requestCode != currentRequestCode) + return@OnRequestPermissionResultListener + + if (grantResult == PackageManager.PERMISSION_DENIED) + context.showToast(R.string.permissions_shizuku_denied) + + continuation.resume(grantResult == PackageManager.PERMISSION_GRANTED) + } + + continuation.invokeOnCancellation { + Shizuku.removeRequestPermissionResultListener(onPermissionRequestResult) + shizukuPermissionLock.unlock() + } + + Shizuku.addRequestPermissionResultListener(onPermissionRequestResult) + Shizuku.requestPermission(currentRequestCode) + } + } + + /** + * Gets the Shizuku binder for [IPackageInstaller]. + */ + private fun getPackageInstallerBinder(): IPackageInstaller { + val iPackageManager = IPackageManager.Stub.asInterface( + ShizukuBinderWrapper(SystemServiceHelper.getSystemService("package")) + ) + val iPackageInstaller = IPackageInstaller.Stub.asInterface( + ShizukuBinderWrapper(iPackageManager.packageInstaller.asBinder()) + ) + + return iPackageInstaller + } + + /** + * Gets a binded [PackageInstaller] service wrapper through Shizuku. + */ + private fun getPackageInstaller(): PackageInstaller { + val iPackageInstaller = getPackageInstallerBinder() + + val hiddenPackageInstaller = if (Build.VERSION.SDK_INT >= 31) { + PackageInstallerHidden( + /* installer = */ iPackageInstaller, + /* installerPackageName = */ PLAY_PACKAGE_NAME, + /* installerAttributionTag = */ null, + /* userId = */ 0, + ) + } else if (Build.VERSION.SDK_INT >= 26) { + PackageInstallerHidden( + /* installer = */ iPackageInstaller, + /* installerPackageName = */ PLAY_PACKAGE_NAME, + /* userId = */ 0, + ) + } else { + PackageInstallerHidden( + /* context = */ context, + /* pm = */ context.packageManager, + /* installer = */ iPackageInstaller, + /* installerPackageName = */ PLAY_PACKAGE_NAME, + /* userId = */ 0, + ) + } + return Refine.unsafeCast(hiddenPackageInstaller) + } + + /** + * Opens and binds a [PackageInstaller.Session] wrapper through Shizuku. + */ + private fun openSession(sessionId: Int): PackageInstaller.Session { + val iPackageInstaller = getPackageInstallerBinder() + val iSession = IPackageInstallerSession.Stub.asInterface( + ShizukuBinderWrapper(iPackageInstaller.openSession(sessionId).asBinder()) + ) + return Refine.unsafeCast(PackageInstallerHidden.SessionHidden(iSession)) + } + + /** + * Creates the [PackageInstaller] session params to be used with Shizuku installs. + */ + private fun createSessionParams(): SessionParams { + // Required to change hidden SessionParams flags + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && !hiddenApiDisabled.getAndSet(true)) + HiddenApiBypass.addHiddenApiExemptions("Landroid/content", "Landroid/os") + + val params = PMUtils.createInstallSessionParams(silent = true) + + // Add INSTALL_REPLACE_EXISTING flag to session params + val paramsHidden = Refine.unsafeCast(params) + paramsHidden.installFlags = paramsHidden.installFlags or PackageManagerHidden.INSTALL_REPLACE_EXISTING + + return params + } + + override suspend fun install(apks: List, silent: Boolean) { + if (!requestPermissions()) + throw IllegalStateException("Shizuku is not available!") + + // Construct install session and create it + val params = createSessionParams() + val packageInstaller = getPackageInstaller() + val sessionId = packageInstaller.createSession(params) + + PMUtils.startInstall( + context = context, + session = openSession(sessionId), + sessionId = sessionId, + apks = apks, + relay = false, + ) + } + + override suspend fun waitInstall(apks: List, silent: Boolean): InstallerResult { + if (!requestPermissions()) + throw IllegalStateException("Shizuku is not available!") + + return suspendCancellableCoroutine { continuation -> + // Construct install session and create it + val params = createSessionParams() + val packageInstaller = getPackageInstaller() + val sessionId = packageInstaller.createSession(params) + + // Create and register a result receiver + val relayReceiver = PMUtils.registerRelayReceiver( + context = context, + sessionId = sessionId, + isUninstall = false, + onResult = continuation::resume, + ) + + // Unregister PMResultReceiver when this coroutine finishes or errors + // Explicitly cancel the install session if it did not finish. + continuation.invokeOnCancellation { + context.unregisterReceiver(relayReceiver) + packageInstaller.abandonSession(sessionId) + } + + PMUtils.startInstall( + context = context, + session = openSession(sessionId), + sessionId = sessionId, + apks = apks, + relay = true, + ) + } + } + + override suspend fun waitUninstall(packageName: String): InstallerResult { + if (!requestPermissions()) + throw IllegalStateException("Shizuku is not available!") + + return suspendCancellableCoroutine { continuation -> + val packageInstaller = getPackageInstaller() + + // Create and register a result receiver + val relayReceiver = PMUtils.registerRelayReceiver( + context = context, + sessionId = -1, + isUninstall = true, + onResult = continuation::resume, + ) + + // Unregister PMResultReceiver when this coroutine finishes or errors + continuation.invokeOnCancellation { + context.unregisterReceiver(relayReceiver) + } + + packageInstaller.uninstall( + /* packageName = */ packageName, + /* statusReceiver = */ PMUtils.createUninstallRelayingIntent(context).intentSender, + ) + } + } + + private companion object { + // We spoof Google Play Store to prevent unnecessary checks + const val PLAY_PACKAGE_NAME = "com.android.vending" + + /** + * Whether the hidden API exemptions have already been added for + * use in modifying hidden install session flags. + */ + var hiddenApiDisabled = AtomicBoolean(false) + } +} diff --git a/app/src/main/kotlin/com/aliucord/manager/installers/shizuku/ShizukuInstallerError.kt b/app/src/main/kotlin/com/aliucord/manager/installers/shizuku/ShizukuInstallerError.kt new file mode 100644 index 0000000..b728073 --- /dev/null +++ b/app/src/main/kotlin/com/aliucord/manager/installers/shizuku/ShizukuInstallerError.kt @@ -0,0 +1,14 @@ +package com.aliucord.manager.installers.shizuku + +import android.content.Context +import android.os.Parcelable +import com.aliucord.manager.installers.InstallerResult +import kotlinx.parcelize.Parcelize + +@Parcelize +data class ShizukuInstallerError( + private val reason: String +) : InstallerResult.Error(), Parcelable { + override fun getDebugReason(): String = reason + override fun getLocalizedReason(context: Context): String? = null +} diff --git a/app/src/main/kotlin/com/aliucord/manager/installers/shizuku/ShizukuIntentReceiver.kt b/app/src/main/kotlin/com/aliucord/manager/installers/shizuku/ShizukuIntentReceiver.kt new file mode 100644 index 0000000..8702280 --- /dev/null +++ b/app/src/main/kotlin/com/aliucord/manager/installers/shizuku/ShizukuIntentReceiver.kt @@ -0,0 +1,50 @@ +package com.aliucord.manager.installers.shizuku + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.pm.PackageInstaller +import android.os.Bundle +import android.util.Log +import com.aliucord.manager.installers.shizuku.ShizukuInstallerError +import com.aliucord.manager.installers.InstallerResult +import dev.shiggy.manager.BuildConfig + +class ShizukuIntentReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val extras = intent.extras ?: return + val status = extras.getInt(PackageInstaller.EXTRA_STATUS) + val message = extras.getString(PackageInstaller.EXTRA_STATUS_MESSAGE) ?: "Unknown" + val sessionId = extras.getInt(EXTRA_SESSION_ID) + + if (BuildConfig.DEBUG) { + Log.d( + BuildConfig.TAG, + "Received install result for session $sessionId: " + + "status=$status, message=$message" + ) + } + + val result = when (status) { + PackageInstaller.STATUS_SUCCESS -> InstallerResult.Success + else -> ShizukuInstallerError("status=$status, message=$message") + } + + if (extras.getBoolean(EXTRA_RELAY_ENABLED)) { + val resultIntent = Intent(context, ShizukuResultReceiver::class.java).apply { + putExtras(Bundle().apply { + putInt(EXTRA_SESSION_ID, sessionId) + putParcelable(EXTRA_RESULT, result) + }) + } + + context.sendBroadcast(resultIntent) + } + } + + companion object { + const val EXTRA_SESSION_ID = "com.aliucord.manager.intent.extra.SESSION_ID" + const val EXTRA_RELAY_ENABLED = "com.aliucord.manager.intent.extra.RELAY_ENABLED" + const val EXTRA_RESULT = "com.aliucord.manager.intent.extra.RESULT" + } +} diff --git a/app/src/main/kotlin/com/aliucord/manager/installers/shizuku/ShizukuResultReceiver.kt b/app/src/main/kotlin/com/aliucord/manager/installers/shizuku/ShizukuResultReceiver.kt new file mode 100644 index 0000000..53d6933 --- /dev/null +++ b/app/src/main/kotlin/com/aliucord/manager/installers/shizuku/ShizukuResultReceiver.kt @@ -0,0 +1,29 @@ +package com.aliucord.manager.installers.shizuku + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import com.aliucord.manager.installers.InstallerResult + +class ShizukuResultReceiver( + private val sessionId: Int, + private val isUninstall: Boolean, + private val onResult: (InstallerResult) -> Unit +) : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val extras = intent.extras ?: return + val receivedSessionId = extras.getInt(ShizukuIntentReceiver.EXTRA_SESSION_ID) + + if (receivedSessionId == sessionId) { + val result = extras.getParcelable(ShizukuIntentReceiver.EXTRA_RESULT) + if (result != null) { + onResult(result) + } + } + } + + companion object { + val intentFilter = IntentFilter("com.aliucord.manager.INSTALL_RESULT") + } +} diff --git a/app/src/main/kotlin/com/aliucord/manager/manager/InstallerManager.kt b/app/src/main/kotlin/com/aliucord/manager/manager/InstallerManager.kt index c6a4d90..daefb5d 100644 --- a/app/src/main/kotlin/com/aliucord/manager/manager/InstallerManager.kt +++ b/app/src/main/kotlin/com/aliucord/manager/manager/InstallerManager.kt @@ -2,8 +2,11 @@ package com.aliucord.manager.manager import com.aliucord.manager.installers.Installer import com.aliucord.manager.installers.pm.PMInstaller -import org.koin.core.annotation.KoinInternalApi +import com.aliucord.manager.installers.root.RootInstaller +import com.aliucord.manager.installers.shizuku.ShizukuInstaller import org.koin.core.component.KoinComponent +import org.koin.core.component.get +import rikka.shizuku.Shizuku import kotlin.reflect.KClass /** @@ -15,15 +18,44 @@ class InstallerManager( fun getActiveInstaller(): Installer = getInstaller(prefs.installer) - @OptIn(KoinInternalApi::class) - fun getInstaller(type: InstallerSetting): Installer = - getKoin().scopeRegistry.rootScope.get(clazz = type.installerClass) + fun getInstaller(type: InstallerSetting): Installer = when (type) { + InstallerSetting.PM -> get() + InstallerSetting.ROOT -> get() + InstallerSetting.Shizuku -> get() + } + + fun getAvailableInstallers(): List { + val installers = mutableListOf(InstallerSetting.PM) + try { + if (Shizuku.pingBinder()) { + installers.add(InstallerSetting.Shizuku) + } + } catch (e: Exception) { + // Shizuku not available + } + try { + val process = ProcessBuilder("su", "-c", "echo").start() + process.waitFor() + if (process.exitValue() == 0) { + installers.add(InstallerSetting.ROOT) + } + } catch (e: Exception) { + // Root not available + } + return installers + } } enum class InstallerSetting( - // @StringRes - // private val localizedName: Int, val installerClass: KClass, ) { PM(PMInstaller::class), + ROOT(RootInstaller::class), + Shizuku(ShizukuInstaller::class), +} + +fun InstallerSetting.toDisplayName(): String = when (this) { + InstallerSetting.PM -> "Package Manager" + InstallerSetting.ROOT -> "Root" + InstallerSetting.Shizuku -> "Shizuku" } diff --git a/app/src/main/kotlin/com/aliucord/manager/manager/PreferencesManager.kt b/app/src/main/kotlin/com/aliucord/manager/manager/PreferencesManager.kt index 6096c6b..78366a8 100644 --- a/app/src/main/kotlin/com/aliucord/manager/manager/PreferencesManager.kt +++ b/app/src/main/kotlin/com/aliucord/manager/manager/PreferencesManager.kt @@ -14,4 +14,5 @@ class PreferencesManager(preferences: SharedPreferences) : BasePreferenceManager var keepPatchedApks by booleanPreference("keep_patched_apks", false) var showNetworkWarning by booleanPreference("show_network_warning", true) var showPlayProtectWarning by booleanPreference("show_play_protect_warning", true) + var rootPopupShown by booleanPreference("root_popup_shown", false) } diff --git a/app/src/main/kotlin/com/aliucord/manager/patcher/steps/install/InstallStep.kt b/app/src/main/kotlin/com/aliucord/manager/patcher/steps/install/InstallStep.kt index 3d3495b..6af04cc 100644 --- a/app/src/main/kotlin/com/aliucord/manager/patcher/steps/install/InstallStep.kt +++ b/app/src/main/kotlin/com/aliucord/manager/patcher/steps/install/InstallStep.kt @@ -53,6 +53,10 @@ open class InstallStep(private val options: PatchOptions) : Step(), KoinComponen // Wait until app resumed ProcessLifecycleOwner.get().lifecycle.withResumed {} + // Retrieve configured installer + container.log("Retrieving configured installer ${prefs.installer}") + val installer = installers.getActiveInstaller() + // Show [PlayProtectDialog] and wait until it gets dismissed if (prefs.showPlayProtectWarning && !prefs.devMode @@ -67,7 +71,7 @@ open class InstallStep(private val options: PatchOptions) : Step(), KoinComponen } container.log("Installing ${apks.joinToString(", ") { it.name }}, silent: ${!prefs.devMode}") - val result = installers.getActiveInstaller().waitInstall( + val result = installer.waitInstall( apks = apks, silent = !prefs.devMode, ) diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/previews/screens/home/HomeScreenLoadedPreview.kt b/app/src/main/kotlin/com/aliucord/manager/ui/previews/screens/home/HomeScreenLoadedPreview.kt index 75da5dd..c0bf477 100644 --- a/app/src/main/kotlin/com/aliucord/manager/ui/previews/screens/home/HomeScreenLoadedPreview.kt +++ b/app/src/main/kotlin/com/aliucord/manager/ui/previews/screens/home/HomeScreenLoadedPreview.kt @@ -35,6 +35,7 @@ private fun HomeScreenLoadedPreview( onOpenApp = {}, onOpenAppInfo = {}, onOpenPlugins = {}, + onUninstall = {}, ) } } diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/screens/home/HomeModel.kt b/app/src/main/kotlin/com/aliucord/manager/ui/screens/home/HomeModel.kt index ccad646..7224216 100644 --- a/app/src/main/kotlin/com/aliucord/manager/ui/screens/home/HomeModel.kt +++ b/app/src/main/kotlin/com/aliucord/manager/ui/screens/home/HomeModel.kt @@ -39,11 +39,15 @@ class HomeModel( private val maven: AliucordMavenService, private val wtGithub: ShiggyGithubService, private val rnaTracker: RNATrackerService, - private val json: Json, + private val json: kotlinx.serialization.json.Json, + private val preferences: com.aliucord.manager.manager.PreferencesManager, ) : ScreenModel { var installsState by mutableStateOf(InstallsState.Fetching) private set + var showRootDetectedDialog by mutableStateOf(false) + var rootDetected by mutableStateOf(false) + private val refreshingLock = Mutex() private var remoteDataJson: BuildInfo? = null private var trackerIndexJson: RNATrackerIndex? = null @@ -52,6 +56,7 @@ class HomeModel( init { refresh() + checkAndSetRootInstaller() } fun refresh(delay: Boolean = false) = screenModelScope.launchIO { @@ -82,6 +87,14 @@ class HomeModel( } } + fun uninstallApp(packageName: String) { + val uninstallIntent = Intent(Intent.ACTION_DELETE).apply { + data = "package:$packageName".toUri() + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + application.startActivity(uninstallIntent) + } + fun openApp(packageName: String) { val launchIntent = application.packageManager .getLaunchIntentForPackage(packageName) @@ -216,13 +229,40 @@ class HomeModel( ) }, - ).joinAll() + ).joinAll() if (trackerIndexJson == null /* remoteDataJson == null || latestAliuhookVersion == null */ || latestShiggyXposedVersion == null) { mainThread { application.showToast(R.string.home_network_fail) } } } + private fun isRootAvailable(): Boolean { + return try { + val process = ProcessBuilder("su", "-c", "echo rooted").start() + val output = process.inputStream.bufferedReader().readText().trim() + process.waitFor() + output == "rooted" + } catch (e: Exception) { + false + } + } + + private fun checkAndSetRootInstaller() { + if (isRootAvailable() && !rootDetected) { + rootDetected = true + // Only show dialog and auto-switch if the popup hasn't been shown before + if (!preferences.rootPopupShown) { + showRootDetectedDialog = true + preferences.rootPopupShown = true + // Switch installer to ROOT only the first time + if (preferences.installer != com.aliucord.manager.manager.InstallerSetting.ROOT) { + preferences.installer = com.aliucord.manager.manager.InstallerSetting.ROOT + } + } + + } + } + /** * Obtains all installed packages on the device that are an Aliucord installation. */ diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/screens/home/HomeScreen.kt b/app/src/main/kotlin/com/aliucord/manager/ui/screens/home/HomeScreen.kt index f3f548e..925a6b7 100644 --- a/app/src/main/kotlin/com/aliucord/manager/ui/screens/home/HomeScreen.kt +++ b/app/src/main/kotlin/com/aliucord/manager/ui/screens/home/HomeScreen.kt @@ -49,6 +49,12 @@ class HomeScreen : Screen, Parcelable { val scope = rememberCoroutineScope() val model = koinScreenModel() + if (model.showRootDetectedDialog) { + com.aliucord.manager.ui.screens.settings.components.RootDetectedDialog( + onDismissRequest = { model.showRootDetectedDialog = false } + ) + } + // Refresh installations list when the screen changes or activity resumes LifecycleResumeEffect(Unit) { model.refresh(delay = true) @@ -73,6 +79,7 @@ class HomeScreen : Screen, Parcelable { onOpenApp = model::openApp, onOpenAppInfo = model::openAppInfo, onOpenPlugins = { navigator.push(PluginsScreen()) }, // TODO: install-specific plugins + onUninstall = model::uninstallApp, ) InstallsState.Fetching -> HomeScreenLoadingContent(padding = padding) @@ -123,6 +130,7 @@ fun HomeScreenLoadedContent( onOpenApp: (packageName: String) -> Unit, onOpenAppInfo: (packageName: String) -> Unit, onOpenPlugins: (packageName: String) -> Unit, + onUninstall: (packageName: String) -> Unit, ) { LazyColumn( verticalArrangement = Arrangement.spacedBy(10.dp), @@ -156,6 +164,7 @@ fun HomeScreenLoadedContent( onOpenApp = { onOpenApp(item.packageName) }, onOpenInfo = { onOpenAppInfo(item.packageName) }, onOpenPlugins = { onOpenPlugins(item.packageName) }, + onUninstall = { onUninstall(item.packageName) }, modifier = Modifier.fillMaxWidth() ) } diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/screens/home/components/InstalledItemCard.kt b/app/src/main/kotlin/com/aliucord/manager/ui/screens/home/components/InstalledItemCard.kt index b4e9ab3..68dd9fb 100644 --- a/app/src/main/kotlin/com/aliucord/manager/ui/screens/home/components/InstalledItemCard.kt +++ b/app/src/main/kotlin/com/aliucord/manager/ui/screens/home/components/InstalledItemCard.kt @@ -27,6 +27,7 @@ fun InstalledItemCard( onOpenApp: () -> Unit, onOpenInfo: () -> Unit, onOpenPlugins: () -> Unit, + onUninstall: () -> Unit, modifier: Modifier = Modifier, ) { ElevatedCard( @@ -123,6 +124,13 @@ fun InstalledItemCard( text = stringResource(R.string.action_launch), onClick = onOpenApp, ) + SegmentedButton( + icon = painterResource(R.drawable.ic_delete_forever), + text = stringResource(R.string.action_uninstall), // Assuming R.string.action_uninstall exists or will be added + iconColor = MaterialTheme.colorScheme.error, + textColor = MaterialTheme.colorScheme.error, + onClick = onUninstall, + ) } } } diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/screens/settings/SettingsModel.kt b/app/src/main/kotlin/com/aliucord/manager/ui/screens/settings/SettingsModel.kt index 8e2a18b..cdf0775 100644 --- a/app/src/main/kotlin/com/aliucord/manager/ui/screens/settings/SettingsModel.kt +++ b/app/src/main/kotlin/com/aliucord/manager/ui/screens/settings/SettingsModel.kt @@ -1,11 +1,13 @@ package com.aliucord.manager.ui.screens.settings import android.app.Application +import android.net.Uri import androidx.compose.runtime.* import cafe.adriel.voyager.core.model.ScreenModel import cafe.adriel.voyager.core.model.screenModelScope import com.aliucord.manager.manager.PathManager import com.aliucord.manager.manager.PreferencesManager +import com.aliucord.manager.manager.InstallerManager import com.aliucord.manager.ui.theme.Theme import com.aliucord.manager.util.* import dev.shiggy.manager.BuildConfig @@ -15,15 +17,19 @@ class SettingsModel( private val application: Application, private val paths: PathManager, val preferences: PreferencesManager, + private val installerManager: InstallerManager, ) : ScreenModel { val installInfo = InstallInfo + var patchedApkExists by mutableStateOf(paths.patchedApk().exists()) private set var showThemeDialog by mutableStateOf(false) private set + val availableInstallers = installerManager.getAvailableInstallers() + fun showThemeDialog() { showThemeDialog = true } @@ -36,6 +42,10 @@ class SettingsModel( preferences.theme = theme } + fun setInstaller(installer: com.aliucord.manager.manager.InstallerSetting) { + preferences.installer = installer + } + fun setKeepPatchedApks(value: Boolean) { preferences.keepPatchedApks = value } @@ -54,35 +64,37 @@ class SettingsModel( application.showToast(R.string.action_copied) } - fun shareApk() { - // TODO(Shiggy): Implement this - application.showToast(R.string.settings_export_apk_not_implemented) - // val file = paths.patchingWorkingDir().resolve("patched.apk") - // val fileUri = FileProvider.getUriForFile( - // /* context = */ application, - // /* authority = */ "${BuildConfig.APPLICATION_ID}.provider", - // /* file = */ file, - // /* displayName = */ "Aliucord.apk", - // ) - // - // val intent = Intent(Intent.ACTION_SEND) - // .setType("application/vnd.android.package-archive") - // .putExtra(Intent.EXTRA_STREAM, fileUri) - // .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - // .let { - // Intent.createChooser( - // /* target = */ it, - // /* title = */ application.getString(R.string.log_action_export_apk), - // ) - // } - // .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - // - // try { - // application.startActivity(intent) - // } catch (t: Throwable) { - // Log.w(BuildConfig.TAG, "Failed to share APK", t) - // application.showToast(R.string.status_failed) - // } + fun shareApk(uriToShare: android.net.Uri? = null) { + val fileUri = uriToShare ?: run { + val file = paths.patchedApk() + if (!file.exists()) { + application.showToast(R.string.settings_export_apk_not_implemented) + return + } + try { + androidx.core.content.FileProvider.getUriForFile( + application, + "${BuildConfig.APPLICATION_ID}.provider", + file + ) + } catch (t: Throwable) { + application.showToast(R.string.status_failed) + return + } + } + + val intent = android.content.Intent(android.content.Intent.ACTION_SEND) + .setType("application/vnd.android.package-archive") + .putExtra(android.content.Intent.EXTRA_STREAM, fileUri) + .addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION) + .addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK) + + try { + application.startActivity(intent) + } catch (t: Throwable) { + android.util.Log.w(BuildConfig.TAG, "Failed to share APK", t) + application.showToast(R.string.status_failed) + } } companion object { diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/screens/settings/SettingsScreen.kt b/app/src/main/kotlin/com/aliucord/manager/ui/screens/settings/SettingsScreen.kt index 9bfc273..2da3d8f 100644 --- a/app/src/main/kotlin/com/aliucord/manager/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/aliucord/manager/ui/screens/settings/SettingsScreen.kt @@ -20,13 +20,22 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.koin.koinScreenModel +import com.aliucord.manager.manager.InstallerManager import com.aliucord.manager.ui.components.BackButton import com.aliucord.manager.ui.components.MainActionButton import com.aliucord.manager.ui.components.settings.* import com.aliucord.manager.ui.screens.settings.components.ThemeDialog +import com.aliucord.manager.ui.screens.settings.components.InstallerDialog +import com.aliucord.manager.ui.screens.settings.components.ShizukuInfoDialog +import com.aliucord.manager.manager.InstallerSetting +import com.aliucord.manager.manager.toDisplayName import dev.shiggy.manager.R import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize +import android.content.pm.PackageManager +import rikka.shizuku.Shizuku +import androidx.compose.ui.platform.LocalContext + @Parcelize class SettingsScreen : Screen, Parcelable { @@ -38,6 +47,7 @@ class SettingsScreen : Screen, Parcelable { val model = koinScreenModel() var clearedCache by rememberSaveable { mutableStateOf(false) } + Scaffold( topBar = { TopAppBar( @@ -70,10 +80,24 @@ class SettingsScreen : Screen, Parcelable { secondaryText = { Text(stringResource(R.string.setting_theme_desc)) } ) { FilledTonalButton(onClick = model::showThemeDialog) { + Icon( + painter = painterResource( + when (preferences.theme) { + com.aliucord.manager.ui.theme.Theme.Dark -> R.drawable.ic_night + com.aliucord.manager.ui.theme.Theme.Light -> R.drawable.ic_light + com.aliucord.manager.ui.theme.Theme.System -> R.drawable.ic_sync + } + ), + contentDescription = null, + modifier = Modifier + .padding(end = 4.dp) + .size(18.dp), + ) Text(preferences.theme.toDisplayName()) } } + // Material You theming on Android 12+ if (Build.VERSION.SDK_INT >= 31) { SettingsSwitch( @@ -87,6 +111,85 @@ class SettingsScreen : Screen, Parcelable { SettingsHeader(stringResource(R.string.settings_header_advanced)) + var showInstallerDialog by remember { mutableStateOf(false) } + var showShizukuInfoDialog by remember { mutableStateOf(false) } + var shouldRequestShizuku by remember { mutableStateOf(false) } + val context = LocalContext.current + + if (shouldRequestShizuku) { + LaunchedEffect(shouldRequestShizuku) { + val SHIZUKU_REQUEST_CODE = 1001 + try { + rikka.shizuku.Shizuku.requestPermission(SHIZUKU_REQUEST_CODE) + kotlinx.coroutines.delay(800) + if (rikka.shizuku.Shizuku.checkSelfPermission() != 0) { + showShizukuInfoDialog = true + } + } catch (_: Exception) { + showShizukuInfoDialog = true + } + shouldRequestShizuku = false + } + } + + if (showInstallerDialog) { + InstallerDialog( + currentInstaller = preferences.installer, + onDismissRequest = { showInstallerDialog = false }, + onConfirm = { installer -> + model.setInstaller(installer) + if (installer == InstallerSetting.Shizuku) { + shouldRequestShizuku = true + } + } + ) + } + if (showShizukuInfoDialog) { + ShizukuInfoDialog(onDismissRequest = { showShizukuInfoDialog = false }) + } + + SettingsItem( + modifier = Modifier.clickable { showInstallerDialog = true }, + icon = { Icon(painterResource(R.drawable.ic_tools), null) }, + text = { Text(stringResource(R.string.setting_installer)) }, + secondaryText = { Text(stringResource(R.string.setting_installer_desc)) } + ) { + FilledTonalButton(onClick = { showInstallerDialog = true }) { + Icon( + painter = painterResource( + when (preferences.installer) { + InstallerSetting.PM -> R.drawable.ic_app_shortcut + InstallerSetting.ROOT -> R.drawable.ic_code + InstallerSetting.Shizuku -> R.drawable.ic_tools + } + ), + contentDescription = null, + modifier = Modifier + .padding(end = 4.dp) + .size(18.dp), + ) + Text(preferences.installer.toDisplayName()) + } + } + + MainActionButton( + text = stringResource(R.string.settings_clear_cache), + icon = painterResource(R.drawable.ic_delete_forever), + enabled = !clearedCache, + colors = IconButtonDefaults.filledTonalIconButtonColors( + containerColor = MaterialTheme.colorScheme.error, + ), + onClick = { + clearedCache = true + model.clearCache() + }, + modifier = Modifier + .padding(horizontal = 18.dp, vertical = 10.dp) + .fillMaxWidth() + ) + + SettingsHeader("Development") + SettingsSwitch( label = stringResource(R.string.setting_developer_options), secondaryLabel = stringResource(R.string.setting_developer_options_desc), @@ -116,22 +219,6 @@ class SettingsScreen : Screen, Parcelable { ) } - MainActionButton( - text = stringResource(R.string.settings_clear_cache), - icon = painterResource(R.drawable.ic_delete_forever), - enabled = !clearedCache, - colors = IconButtonDefaults.filledTonalIconButtonColors( - containerColor = MaterialTheme.colorScheme.error, - ), - onClick = { - clearedCache = true - model.clearCache() - }, - modifier = Modifier - .padding(horizontal = 18.dp, vertical = 10.dp) - .fillMaxWidth() - ) - SettingsHeader(stringResource(R.string.settings_header_info)) Row( diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/screens/settings/components/InstallerDialog.kt b/app/src/main/kotlin/com/aliucord/manager/ui/screens/settings/components/InstallerDialog.kt new file mode 100644 index 0000000..8c13701 --- /dev/null +++ b/app/src/main/kotlin/com/aliucord/manager/ui/screens/settings/components/InstallerDialog.kt @@ -0,0 +1,97 @@ +package com.aliucord.manager.ui.screens.settings.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.aliucord.manager.manager.InstallerSetting +import com.aliucord.manager.manager.toDisplayName +import dev.shiggy.manager.R + +@Composable +fun InstallerDialog( + currentInstaller: InstallerSetting, + onDismissRequest: () -> Unit, + onConfirm: (InstallerSetting) -> Unit, +) { + var selectedInstaller by rememberSaveable { mutableStateOf(currentInstaller) } + + AlertDialog( + onDismissRequest = onDismissRequest, + icon = { + Icon( + painter = painterResource(R.drawable.ic_tools), + contentDescription = stringResource(R.string.setting_installer), + modifier = Modifier.size(32.dp), + ) + }, + title = { Text(stringResource(R.string.setting_installer)) }, + text = { + Column { + for (installer in InstallerSetting.values()) key(installer) { + val interactionSource = remember(::MutableInteractionSource) + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clickable( + indication = null, + interactionSource = interactionSource, + onClick = { selectedInstaller = installer }, + ) + .clip(MaterialTheme.shapes.medium) + .padding(horizontal = 6.dp, vertical = 8.dp), + ) { + // Optionally, you can add a unique icon for each installer type + Icon( + painter = painterResource( + when (installer) { + InstallerSetting.PM -> R.drawable.ic_android + InstallerSetting.ROOT -> R.drawable.ic_code + InstallerSetting.Shizuku -> R.drawable.ic_tools + } + ), + contentDescription = null, + modifier = Modifier + .padding(end = 14.dp) + .size(26.dp), + ) + + Text( + text = installer.toDisplayName(), + style = MaterialTheme.typography.labelLarge + .copy(fontSize = 14.sp) + ) + + Spacer(Modifier.weight(1f, true)) + + RadioButton( + selected = installer == selectedInstaller, + onClick = { selectedInstaller = installer }, + interactionSource = interactionSource, + ) + } + } + } + }, + confirmButton = { + Button( + onClick = { + onConfirm(selectedInstaller) + onDismissRequest() + }, + ) { + Text(stringResource(R.string.action_apply)) + } + }, + ) +} diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/screens/settings/components/RootDetectedDialog.kt b/app/src/main/kotlin/com/aliucord/manager/ui/screens/settings/components/RootDetectedDialog.kt new file mode 100644 index 0000000..d53babb --- /dev/null +++ b/app/src/main/kotlin/com/aliucord/manager/ui/screens/settings/components/RootDetectedDialog.kt @@ -0,0 +1,63 @@ +package com.aliucord.manager.ui.screens.settings.components + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import dev.shiggy.manager.R + +@Composable +fun RootDetectedDialog( + onDismissRequest: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismissRequest, + icon = { + Icon( + painter = painterResource(R.drawable.ic_code), + contentDescription = "Root", + modifier = Modifier + .size(48.dp) + .padding(bottom = 8.dp), + tint = MaterialTheme.colorScheme.primary + ) + }, + title = { + Text( + "Root Access Detected", + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(bottom = 4.dp) + ) + }, + text = { + Column( + modifier = Modifier.padding(top = 4.dp, bottom = 4.dp) + ) { + Text( + "Root permissions were found on your device.", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(bottom = 12.dp) + ) + Text( + "The installer has been automatically switched to Root for the best compatibility and performance.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f) + ) + } + }, + confirmButton = { + Button( + onClick = onDismissRequest, + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + ) { + Text("Okay", style = MaterialTheme.typography.titleMedium) + } + }, + dismissButton = {} + ) +} diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/screens/settings/components/ShizukuInfoDialog.kt b/app/src/main/kotlin/com/aliucord/manager/ui/screens/settings/components/ShizukuInfoDialog.kt new file mode 100644 index 0000000..8340db4 --- /dev/null +++ b/app/src/main/kotlin/com/aliucord/manager/ui/screens/settings/components/ShizukuInfoDialog.kt @@ -0,0 +1,95 @@ +package com.aliucord.manager.ui.screens.settings.components + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import dev.shiggy.manager.R + +@Composable +fun ShizukuInfoDialog( + onDismissRequest: () -> Unit +) { + val context = androidx.compose.ui.platform.LocalContext.current + AlertDialog( + onDismissRequest = onDismissRequest, + icon = { + Icon( + painter = painterResource(R.drawable.ic_tools), + contentDescription = "Shizuku", + modifier = Modifier + .size(48.dp) + .padding(bottom = 8.dp), + tint = MaterialTheme.colorScheme.primary + ) + }, + title = { + Text( + "Shizuku Permission Required", + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(bottom = 4.dp) + ) + }, + text = { + Column( + modifier = Modifier.padding(top = 4.dp, bottom = 4.dp) + ) { + Text( + "To use Shizuku, you must manually grant permission:", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(bottom = 12.dp) + ) + Text( + "1. Open the Shizuku app\n" + + "2. Grant permissions in the Shizuku app\n" + + "3. Relaunch Shiggy Manager to apply changes\n\n", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f) + ) + } + }, + confirmButton = { + Column { + Button( + onClick = { + val intent = android.content.Intent().apply { + setClassName( + "moe.shizuku.manager.authorization.RequestPermissionActivity", + "moe.shizuku.privileged.api" + ) + addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK) + } + try { + context.startActivity(intent) + } catch (_: Exception) { + val fallback = context.packageManager.getLaunchIntentForPackage("moe.shizuku.privileged.api") + if (fallback != null) { + context.startActivity(fallback) + } + } + onDismissRequest() + }, + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + ) { + Text("Open Shizuku App") + } + Spacer(modifier = Modifier.height(8.dp)) + Button( + onClick = onDismissRequest, + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + ) { + Text("Okay") + } + } + }, + dismissButton = {} + ) +} diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/widgets/updater/UpdaterViewModel.kt b/app/src/main/kotlin/com/aliucord/manager/ui/widgets/updater/UpdaterViewModel.kt index 838e363..2a89e28 100644 --- a/app/src/main/kotlin/com/aliucord/manager/ui/widgets/updater/UpdaterViewModel.kt +++ b/app/src/main/kotlin/com/aliucord/manager/ui/widgets/updater/UpdaterViewModel.kt @@ -9,7 +9,6 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.aliucord.manager.installers.InstallerResult import com.aliucord.manager.manager.InstallerManager -import com.aliucord.manager.manager.InstallerSetting import com.aliucord.manager.manager.download.IDownloadManager import com.aliucord.manager.manager.download.KtorDownloadManager import com.aliucord.manager.network.services.AliucordGithubService @@ -91,7 +90,7 @@ class UpdaterViewModel( downloadProgress.value = null - val installer = installers.getInstaller(InstallerSetting.PM) + val installer = installers.getActiveInstaller() val installResult = installer.waitInstall( apks = listOf(apkFile), silent = true, diff --git a/app/src/main/res/drawable/ic_android.xml b/app/src/main/res/drawable/ic_android.xml new file mode 100644 index 0000000..1617bae --- /dev/null +++ b/app/src/main/res/drawable/ic_android.xml @@ -0,0 +1,12 @@ + + + diff --git a/app/src/main/res/drawable/ic_discord_shiggy.xml b/app/src/main/res/drawable/ic_discord_aliucord.xml similarity index 100% rename from app/src/main/res/drawable/ic_discord_shiggy.xml rename to app/src/main/res/drawable/ic_discord_aliucord.xml diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d893495..df2db3c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2,7 +2,7 @@ Shiggy Manager A Discord mod built with simplicity and a seamless experience in mind. - ShiggyCord + Aliucord Discord GitHub Support Server @@ -53,6 +53,8 @@ The theme to apply to this app Dynamic color Enables Material You theming on Android 12+ + Installer + The method to use for installing Shiggy Developer options Don\'t enable this unless you know what it does! (yes, really) Keep patched APKs @@ -255,7 +257,8 @@ Error Stacktrace Installation Log Export log - Export APK + Export Patched APK + Share Custom APK Share log Logs @@ -283,6 +286,8 @@ Custom Component A custom local version for a patching component (%s) has been found. Would you like to use it? + Failed to obtain Shizuku permissions + Fun Fact: %s ShiggyCord on first release was meant to be custom Kettu build! The refreshed UI inside the settings was made from developer boredom diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 138f252..036027b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,4 +1,5 @@ [versions] +shizuku = "13.1.5" accompanist = "0.37.3" agp = "8.11.0" androidx-activity = "1.10.1" @@ -15,6 +16,9 @@ compose-material3 = "1.3.2" compose-pipette = "1.0.1" desugaring = "2.1.5" diff = "4.16" +hiddenApi-bypass = "6.1" +hiddenApi-refine = "4.4.0" +hiddenApi-stub = "4.4.0" koin = "4.1.0" kotlin = "2.2.0" kotlinx-immutable = "0.4.0" @@ -27,6 +31,13 @@ voyager = "1.1.0-beta03" zip = "2.3.0" [libraries] +# Shizuku + Utils +shizuku-api = { module = "dev.rikka.shizuku:api", version.ref = "shizuku" } +shizuku-provider = { module = "dev.rikka.shizuku:provider", version.ref = "shizuku" } +hiddenApi-bypass = { module = "org.lsposed.hiddenapibypass:hiddenapibypass", version.ref = "hiddenApi-bypass" } +hiddenApi-refine = { module = "dev.rikka.tools.refine:runtime", version.ref = "hiddenApi-refine" } +hiddenApi-stub = { module = "dev.rikka.hidden:stub", version.ref = "hiddenApi-stub" } + # Accompanist accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist" } accompanist-systemUiController = { module = "com.google.accompanist:accompanist-systemuicontroller", version = "0.36.0" } @@ -131,6 +142,10 @@ voyager = [ "voyager-navigator", "voyager-transitions", ] +shizuku = [ + "shizuku-api", + "shizuku-provider", +] [plugins] android-application = { id = "com.android.application", version.ref = "agp" } @@ -138,3 +153,4 @@ compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = " kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +hiddenApi-refine = { id = "dev.rikka.tools.refine", version = "4.3.0" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 3c84f8a..78b82f9 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -6,6 +6,7 @@ pluginManagement { repositories { google() mavenCentral() + maven { url = uri("https://maven.rikka.app/releases") } } } @@ -17,6 +18,10 @@ dependencyResolutionManagement { name = "aliucord" url = uri("https://maven.aliucord.com/snapshots") } + maven { + name = "jitpack" + url = uri("https://jitpack.io") + } } }