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.
This commit is contained in:
kmmiio99o 2025-11-10 16:35:14 +01:00
parent ad877dd434
commit b7dd20c21f
31 changed files with 1229 additions and 198 deletions

View file

@ -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 `<group android:scaleX="" android:scaleY="" android:translateX="" android:translateY="">`. To center an element after scaling, compute translation as:

View file

@ -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)
}

View file

@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<?xml version="1.0" encoding="utf-8" ?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
@ -14,45 +13,48 @@
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission
android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" />
<uses-permission
android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" tools:ignore="QueryAllPackagesPermission" />
<application
android:name="com.aliucord.manager.ManagerApplication"
android:icon="@mipmap/ic_launcher"
android:name="com.aliucord.manager.ManagerApplication"
android:icon="@mipmap/ic_launcher"
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: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">
<receiver
android:name="com.aliucord.manager.installers.pm.PMIntentReceiver"
android:exported="false" />
<receiver android:name="com.aliucord.manager.installers.pm.PMIntentReceiver" android:exported="false" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/provider_paths" />
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true"
>
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/provider_paths" />
</provider>
<provider
android:name="rikka.shizuku.ShizukuProvider"
android:authorities="${applicationId}.shizuku"
android:enabled="true"
android:exported="true"
android:multiprocess="false"
android:permission="android.permission.INTERACT_ACROSS_USERS_FULL"
/>
<activity
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">
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"
>
<intent-filter>
<action android:name="android.intent.action.MAIN" />

View file

@ -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<Application>(), get<AliucordGithubService>(), get<AliucordMavenService>(), get<ShiggyGithubService>(), get<RNATrackerService>(), get<kotlinx.serialization.json.Json>(), get<com.aliucord.manager.manager.PreferencesManager>()) }
factoryOf(::PluginsModel)
factoryOf(::AboutModel)
factoryOf(::PatchingScreenModel)
factoryOf(::SettingsModel)
factory { SettingsModel(get<Application>(), get<PathManager>(), get<PreferencesManager>(), get<InstallerManager>()) }
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)
})
}

View file

@ -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<File>, silent: Boolean = true)
suspend fun install(apks: List<File>, silent: Boolean = true)
/**
* Starts an installation and waits for it to finish with a result. A toast will be shown for all result states.

View file

@ -0,0 +1,7 @@
package com.aliucord.manager.installers
import android.os.Parcelable
interface InstallerError : Parcelable {
val message: String
}

View file

@ -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<File>, silent: Boolean) {
startInstall(createInstallSession(silent), apks, false)
override suspend fun install(apks: List<File>, 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<File>, 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<File>, 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))
}
}

View file

@ -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<File>,
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,
)
}
}

View file

@ -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<File>, silent: Boolean) {
waitInstall(apks, silent)
}
override suspend fun waitInstall(apks: List<File>, 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
}

View file

@ -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<Boolean?>
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<SessionParamsHidden>(params)
paramsHidden.installFlags = paramsHidden.installFlags or PackageManagerHidden.INSTALL_REPLACE_EXISTING
return params
}
override suspend fun install(apks: List<File>, 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<File>, 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)
}
}

View file

@ -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
}

View file

@ -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"
}
}

View file

@ -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<InstallerResult>(ShizukuIntentReceiver.EXTRA_RESULT)
if (result != null) {
onResult(result)
}
}
}
companion object {
val intentFilter = IntentFilter("com.aliucord.manager.INSTALL_RESULT")
}
}

View file

@ -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<PMInstaller>()
InstallerSetting.ROOT -> get<RootInstaller>()
InstallerSetting.Shizuku -> get<ShizukuInstaller>()
}
fun getAvailableInstallers(): List<InstallerSetting> {
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<out Installer>,
) {
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"
}

View file

@ -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)
}

View file

@ -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,
)

View file

@ -35,6 +35,7 @@ private fun HomeScreenLoadedPreview(
onOpenApp = {},
onOpenAppInfo = {},
onOpenPlugins = {},
onUninstall = {},
)
}
}

View file

@ -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>(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.
*/

View file

@ -49,6 +49,12 @@ class HomeScreen : Screen, Parcelable {
val scope = rememberCoroutineScope()
val model = koinScreenModel<HomeModel>()
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()
)
}

View file

@ -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,
)
}
}
}

View file

@ -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 {

View file

@ -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<SettingsModel>()
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(

View file

@ -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))
}
},
)
}

View file

@ -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 = {}
)
}

View file

@ -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 = {}
)
}

View file

@ -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,

View file

@ -0,0 +1,12 @@
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
>
<path
android:fillColor="#FF3DDB85"
android:pathData="M40,720q9,-107 65.5,-197T256,380l-74,-128q-6,-9 -3,-19t13,-15q8,-5 18,-2t16,12l74,128q86,-36 180,-36t180,36l74,-128q6,-9 16,-12t18,2q10,5 13,15t-3,19l-74,128q94,53 150.5,143T920,720L40,720ZM280,610q21,0 35.5,-14.5T330,560q0,-21 -14.5,-35.5T280,510q-21,0 -35.5,14.5T230,560q0,21 14.5,35.5T280,610ZM680,610q21,0 35.5,-14.5T730,560q0,-21 -14.5,-35.5T680,510q-21,0 -35.5,14.5T630,560q0,21 14.5,35.5T680,610Z"
/>
</vector>

View file

@ -2,7 +2,7 @@
<string name="app_name">Shiggy Manager</string>
<string name="app_description">A Discord mod built with simplicity and a seamless experience in mind.</string>
<string name="aliucord" translatable="false">ShiggyCord</string>
<string name="aliucord" translatable="false">Aliucord</string>
<string name="discord" translatable="false">Discord</string>
<string name="github" translatable="false">GitHub</string>
<string name="support_server">Support Server</string>
@ -53,6 +53,8 @@
<string name="setting_theme_desc">The theme to apply to this app</string>
<string name="setting_dynamic_color">Dynamic color</string>
<string name="setting_dynamic_color_desc">Enables Material You theming on Android 12+</string>
<string name="setting_installer">Installer</string>
<string name="setting_installer_desc">The method to use for installing Shiggy</string>
<string name="setting_developer_options">Developer options</string>
<string name="setting_developer_options_desc">Don\'t enable this unless you know what it does! (yes, really)</string>
<string name="setting_keep_patched_apks">Keep patched APKs</string>
@ -255,7 +257,8 @@
<string name="log_section_error">Error Stacktrace</string>
<string name="log_section_log">Installation Log</string>
<string name="log_action_export">Export log</string>
<string name="log_action_export_apk">Export APK</string>
<string name="log_action_export_apk">Export Patched APK</string>
<string name="settings_share_custom_apk">Share Custom APK</string>
<string name="log_action_share">Share log</string>
<string name="logs_title">Logs</string>
@ -283,6 +286,8 @@
<string name="custom_component_picker_title">Custom Component</string>
<string name="custom_component_picker_desc">A custom local version for a patching component (%s) has been found. Would you like to use it?</string>
<string name="permissions_shizuku_denied">Failed to obtain Shizuku permissions</string>
<string name="fun_fact_prefix">Fun Fact: %s</string>
<string name="fun_fact_1">ShiggyCord on first release was meant to be custom Kettu build!</string>
<string name="fun_fact_2">The refreshed UI inside the settings was made from developer boredom</string>

View file

@ -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" }

View file

@ -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")
}
}
}