diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 30b2faa8a6..8aa925c685 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -23,6 +23,7 @@ plugins {
alias(libs.plugins.kotlin.ksp)
alias(libs.plugins.compose.compiler)
alias(libs.plugins.kotlin.serialization)
+ alias(libs.plugins.rikka.tools.refine)
}
android {
@@ -282,6 +283,19 @@ dependencies {
implementation(libs.protobuf.javalite)
implementation(libs.protobuf.kotlin.lite)
+ // Shizuku for privileged installation
+ compileOnly(libs.rikka.hidden.stub)
+ implementation(libs.rikka.tools.refine.runtime)
+ implementation(libs.shizuku.api)
+ implementation(libs.shizuku.provider)
+ implementation(libs.lsposed.hiddenapibypass)
+
+ // libsu for root access
+ implementation(libs.libsu.core)
+
+ // Dhizuku for device owner installation
+ implementation(libs.dhizuku.api)
+
coreLibraryDesugaring(libs.desugaring)
implementation(libs.timber)
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
index 07b0689226..4fc5b111f2 100644
--- a/app/proguard-rules.pro
+++ b/app/proguard-rules.pro
@@ -209,3 +209,25 @@
-keepclasseswithmembers class com.metrolist.shazamkit.models.** {
kotlinx.serialization.KSerializer serializer(...);
}
+
+## Shizuku & Hidden API Rules
+-keep class rikka.shizuku.** { *; }
+-keep class moe.shizuku.** { *; }
+-keep class dev.rikka.tools.refine.** { *; }
+
+# Hidden Android APIs accessed via Shizuku
+-keep class android.content.pm.IPackageManager { *; }
+-keep class android.content.pm.IPackageManager$Stub { *; }
+-keep class android.content.pm.IPackageInstaller { *; }
+-keep class android.content.pm.IPackageInstaller$Stub { *; }
+-keep class android.content.pm.IPackageInstallerSession { *; }
+-keep class android.content.pm.IPackageInstallerSession$Stub { *; }
+-keep class android.content.pm.PackageInstallerHidden { *; }
+-keep class android.content.pm.PackageInstallerHidden$* { *; }
+-keep class android.content.pm.PackageManagerHidden { *; }
+
+# libsu for root access
+-keep class com.topjohnwu.superuser.** { *; }
+
+# Dhizuku for device owner installation
+-keep class com.rosan.dhizuku.** { *; }
diff --git a/app/src/izzy/AndroidManifest.xml b/app/src/izzy/AndroidManifest.xml
index 81853c368f..c6d207feab 100644
--- a/app/src/izzy/AndroidManifest.xml
+++ b/app/src/izzy/AndroidManifest.xml
@@ -1,11 +1,27 @@
+
+ xmlns:tools="http://schemas.android.com/tools"
+ tools:remove="android:testOnly">
+
+
+
+
+
+
+
+
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 783b3b93ed..e8032d6ab0 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1,10 +1,14 @@
+ xmlns:tools="http://schemas.android.com/tools"
+ android:testOnly="true">
+
+
+
@@ -315,6 +319,24 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/metrolist/music/App.kt b/app/src/main/kotlin/com/metrolist/music/App.kt
index 959e347503..9bb62dc1a1 100644
--- a/app/src/main/kotlin/com/metrolist/music/App.kt
+++ b/app/src/main/kotlin/com/metrolist/music/App.kt
@@ -47,6 +47,7 @@ import okhttp3.Credentials
import timber.log.Timber
import java.net.Authenticator
import java.net.PasswordAuthentication
+import org.lsposed.hiddenapibypass.HiddenApiBypass
import java.net.Proxy
import java.util.Locale
import javax.inject.Inject
@@ -65,6 +66,15 @@ class App :
// Install crash handler first
CrashHandler.install(this)
+ // Bypass hidden API restrictions for Shizuku installer (Android 9+)
+ if (BuildConfig.UPDATER_AVAILABLE && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ runCatching {
+ HiddenApiBypass.addHiddenApiExemptions("I", "L")
+ }.onFailure {
+ Timber.w(it, "Hidden API bypass unavailable; privileged installers will be disabled")
+ }
+ }
+
// Initialize cipher deobfuscator for WEB_REMIX streaming
CipherDeobfuscator.initialize(this)
diff --git a/app/src/main/kotlin/com/metrolist/music/constants/PreferenceKeys.kt b/app/src/main/kotlin/com/metrolist/music/constants/PreferenceKeys.kt
index ba0d03dd85..4e9c936417 100644
--- a/app/src/main/kotlin/com/metrolist/music/constants/PreferenceKeys.kt
+++ b/app/src/main/kotlin/com/metrolist/music/constants/PreferenceKeys.kt
@@ -84,6 +84,7 @@ val SelectedYtmPlaylistsKey = stringPreferencesKey("selectedYtmPlaylists")
val CheckForUpdatesKey = booleanPreferencesKey("checkForUpdates")
val UpdateNotificationsEnabledKey = booleanPreferencesKey("updateNotifications")
val LastUpdateCheckTimeKey = longPreferencesKey("lastUpdateCheckTime")
+val InstallerTypeKey = intPreferencesKey("installerType")
val AudioQualityKey = stringPreferencesKey("audioQuality")
diff --git a/app/src/main/kotlin/com/metrolist/music/ui/component/ReleaseNotesCard.kt b/app/src/main/kotlin/com/metrolist/music/ui/component/ReleaseNotesCard.kt
index fb104df154..47905ac94b 100644
--- a/app/src/main/kotlin/com/metrolist/music/ui/component/ReleaseNotesCard.kt
+++ b/app/src/main/kotlin/com/metrolist/music/ui/component/ReleaseNotesCard.kt
@@ -20,6 +20,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.metrolist.music.R
+import com.metrolist.music.ui.screens.settings.MarkdownText
import com.metrolist.music.utils.Updater
@Composable
@@ -43,11 +44,7 @@ fun ReleaseNotesCard() {
style = MaterialTheme.typography.titleLarge
)
Spacer(modifier = Modifier.height(8.dp))
- Text(
- text = releaseInfo.description,
- style = MaterialTheme.typography.bodyMedium,
- modifier = Modifier.padding(vertical = 2.dp)
- )
+ MarkdownText(releaseInfo.description)
}
}
Spacer(modifier = Modifier.height(16.dp))
diff --git a/app/src/main/kotlin/com/metrolist/music/ui/screens/settings/AccountSettings.kt b/app/src/main/kotlin/com/metrolist/music/ui/screens/settings/AccountSettings.kt
index 59a395e15c..2b09f74b03 100644
--- a/app/src/main/kotlin/com/metrolist/music/ui/screens/settings/AccountSettings.kt
+++ b/app/src/main/kotlin/com/metrolist/music/ui/screens/settings/AccountSettings.kt
@@ -400,25 +400,21 @@ fun AccountSettings(
Spacer(Modifier.height(4.dp))
if (BuildConfig.UPDATER_AVAILABLE && latestVersionName != BuildConfig.VERSION_NAME) {
- val releaseInfo = Updater.getCachedLatestRelease()
- val downloadUrl = releaseInfo?.let { Updater.getDownloadUrlForCurrentVariant(it) }
-
- if (downloadUrl != null) {
- PreferenceEntry(
- title = {
- Text(text = stringResource(R.string.new_version_available))
- },
- description = latestVersionName,
- icon = {
- BadgedBox(badge = { Badge() }) {
- Icon(painterResource(R.drawable.update), null)
- }
- },
- onClick = {
- uriHandler.openUri(downloadUrl)
+ PreferenceEntry(
+ title = {
+ Text(text = stringResource(R.string.new_version_available))
+ },
+ description = latestVersionName,
+ icon = {
+ BadgedBox(badge = { Badge() }) {
+ Icon(painterResource(R.drawable.update), null)
}
- )
- }
+ },
+ onClick = {
+ onClose()
+ navController.navigate("settings/updater")
+ }
+ )
}
}
}
diff --git a/app/src/main/kotlin/com/metrolist/music/ui/screens/settings/ChangelogScreen.kt b/app/src/main/kotlin/com/metrolist/music/ui/screens/settings/ChangelogScreen.kt
index 2e6b54628d..80aff3ab0c 100644
--- a/app/src/main/kotlin/com/metrolist/music/ui/screens/settings/ChangelogScreen.kt
+++ b/app/src/main/kotlin/com/metrolist/music/ui/screens/settings/ChangelogScreen.kt
@@ -24,6 +24,7 @@ import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
@@ -36,6 +37,8 @@ import com.metrolist.music.utils.ReleaseInfo
import com.metrolist.music.utils.Updater
private val markdownLinkRegex = Regex("(@[a-zA-Z0-9_-]+)|(https?://[\\w-]+(\\.[\\w-]+)+[\\w.,@?^=%&:/~+#-]*[\\w@?^=%&/~+#-])")
+private val boldRegex = Regex("\\*\\*(.+?)\\*\\*")
+private val admonitionRegex = Regex("^>\\s*\\[!(WARNING|NOTE|TIP|IMPORTANT|CAUTION)]\\s*$", RegexOption.IGNORE_CASE)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
@@ -197,10 +200,42 @@ fun MarkdownText(text: String) {
val lines = text.split("\n")
val uriHandler = LocalUriHandler.current
+ var currentAdmonition: String? = null
+ val admonitionContent = mutableListOf()
+
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
- lines.filter { it.isNotBlank() }.forEach { line ->
+ var i = 0
+ while (i < lines.size) {
+ val line = lines[i]
val trimmedLine = line.trim()
+ // Check for GitHub admonition start
+ val admonitionMatch = admonitionRegex.find(trimmedLine)
+ if (admonitionMatch != null) {
+ currentAdmonition = admonitionMatch.groupValues[1].uppercase()
+ admonitionContent.clear()
+ i++
+ // Collect admonition content (lines starting with >)
+ while (i < lines.size && lines[i].trim().startsWith(">")) {
+ val contentLine = lines[i].trim().removePrefix(">").trim()
+ if (contentLine.isNotBlank()) {
+ admonitionContent.add(contentLine)
+ }
+ i++
+ }
+ // Render admonition
+ AdmonitionBlock(type = currentAdmonition, content = admonitionContent.joinToString("\n"))
+ currentAdmonition = null
+ continue
+ }
+
+ // Skip empty lines
+ if (trimmedLine.isBlank()) {
+ i++
+ continue
+ }
+
+ // Headers
if (trimmedLine.startsWith("#")) {
val level = trimmedLine.takeWhile { it == '#' }.length
val headerText = trimmedLine.substring(level).trim()
@@ -224,27 +259,7 @@ fun MarkdownText(text: String) {
trimmedLine
}
- val annotatedString = buildAnnotatedString {
- var lastIndex = 0
- markdownLinkRegex.findAll(contentText).forEach { result ->
- append(contentText.substring(lastIndex, result.range.first))
-
- val match = result.value
- val link = if (match.startsWith("@")) "https://github.com/${match.substring(1)}" else match
-
- pushStringAnnotation(tag = "URL", annotation = link)
- withStyle(style = SpanStyle(
- color = MaterialTheme.colorScheme.primary,
- fontWeight = if (match.startsWith("@")) FontWeight.Bold else FontWeight.Normal,
- textDecoration = if (match.startsWith("@")) TextDecoration.None else TextDecoration.Underline
- )) {
- append(match)
- }
- pop()
- lastIndex = result.range.last + 1
- }
- append(contentText.substring(lastIndex))
- }
+ val annotatedString = buildStyledText(contentText)
Column(modifier = Modifier.fillMaxWidth()) {
Row(modifier = Modifier.fillMaxWidth()) {
@@ -266,7 +281,7 @@ fun MarkdownText(text: String) {
}
)
}
-
+
if (isListItem) {
Spacer(modifier = Modifier.height(4.dp))
HorizontalDivider(
@@ -276,6 +291,120 @@ fun MarkdownText(text: String) {
}
}
}
+ i++
+ }
+ }
+}
+
+@Composable
+private fun buildStyledText(text: String): AnnotatedString {
+ return buildAnnotatedString {
+ var remaining = text
+ var currentIndex = 0
+
+ while (remaining.isNotEmpty()) {
+ // Find the next match (bold or link)
+ val boldMatch = boldRegex.find(remaining)
+ val linkMatch = markdownLinkRegex.find(remaining)
+
+ val nextMatch = listOfNotNull(boldMatch, linkMatch)
+ .minByOrNull { it.range.first }
+
+ if (nextMatch == null) {
+ append(remaining)
+ break
+ }
+
+ // Append text before match
+ append(remaining.substring(0, nextMatch.range.first))
+
+ when (nextMatch) {
+ boldMatch -> {
+ withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
+ append(boldMatch.groupValues[1])
+ }
+ }
+ linkMatch -> {
+ val match = linkMatch.value
+ val link = if (match.startsWith("@")) "https://github.com/${match.substring(1)}" else match
+
+ pushStringAnnotation(tag = "URL", annotation = link)
+ withStyle(SpanStyle(
+ color = MaterialTheme.colorScheme.primary,
+ fontWeight = if (match.startsWith("@")) FontWeight.Bold else FontWeight.Normal,
+ textDecoration = if (match.startsWith("@")) TextDecoration.None else TextDecoration.Underline
+ )) {
+ append(match)
+ }
+ pop()
+ }
+ }
+
+ remaining = remaining.substring(nextMatch.range.last + 1)
+ }
+ }
+}
+
+@Composable
+private fun AdmonitionBlock(type: String, content: String) {
+ val (containerColor, contentColor, icon) = when (type) {
+ "WARNING", "CAUTION" -> Triple(
+ MaterialTheme.colorScheme.errorContainer,
+ MaterialTheme.colorScheme.onErrorContainer,
+ R.drawable.error
+ )
+ "NOTE" -> Triple(
+ MaterialTheme.colorScheme.primaryContainer,
+ MaterialTheme.colorScheme.onPrimaryContainer,
+ R.drawable.info
+ )
+ "TIP" -> Triple(
+ MaterialTheme.colorScheme.tertiaryContainer,
+ MaterialTheme.colorScheme.onTertiaryContainer,
+ R.drawable.info
+ )
+ "IMPORTANT" -> Triple(
+ MaterialTheme.colorScheme.secondaryContainer,
+ MaterialTheme.colorScheme.onSecondaryContainer,
+ R.drawable.info
+ )
+ else -> Triple(
+ MaterialTheme.colorScheme.surfaceVariant,
+ MaterialTheme.colorScheme.onSurfaceVariant,
+ R.drawable.info
+ )
+ }
+
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(containerColor = containerColor),
+ shape = RoundedCornerShape(12.dp)
+ ) {
+ Row(
+ modifier = Modifier.padding(12.dp),
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ verticalAlignment = Alignment.Top
+ ) {
+ Icon(
+ painter = painterResource(icon),
+ contentDescription = type,
+ tint = contentColor,
+ modifier = Modifier.size(20.dp)
+ )
+ Column {
+ Text(
+ text = type,
+ style = MaterialTheme.typography.labelLarge,
+ fontWeight = FontWeight.Bold,
+ color = contentColor
+ )
+ Spacer(Modifier.height(4.dp))
+ Text(
+ text = content,
+ style = MaterialTheme.typography.bodyMedium,
+ color = contentColor
+ )
+ }
}
}
}
diff --git a/app/src/main/kotlin/com/metrolist/music/ui/screens/settings/SettingsScreen.kt b/app/src/main/kotlin/com/metrolist/music/ui/screens/settings/SettingsScreen.kt
index 83a1e08d6f..f9ae3663a4 100644
--- a/app/src/main/kotlin/com/metrolist/music/ui/screens/settings/SettingsScreen.kt
+++ b/app/src/main/kotlin/com/metrolist/music/ui/screens/settings/SettingsScreen.kt
@@ -207,30 +207,25 @@ fun SettingsScreen(
)
)
if (BuildConfig.UPDATER_AVAILABLE && latestVersionName != BuildConfig.VERSION_NAME) {
- val releaseInfo = Updater.getCachedLatestRelease()
- val downloadUrl = releaseInfo?.let { Updater.getDownloadUrlForCurrentVariant(it) }
-
- if (downloadUrl != null) {
- add(
- Material3SettingsItem(
- icon = painterResource(R.drawable.update),
- title = {
- Text(
- text = stringResource(R.string.new_version_available),
- )
- },
- description = {
- Text(
- text = latestVersionName,
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- },
- showBadge = true,
- onClick = { uriHandler.openUri(downloadUrl) }
- )
+ add(
+ Material3SettingsItem(
+ icon = painterResource(R.drawable.update),
+ title = {
+ Text(
+ text = stringResource(R.string.new_version_available),
+ )
+ },
+ description = {
+ Text(
+ text = latestVersionName,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ },
+ showBadge = true,
+ onClick = { navController.navigate("settings/updater") }
)
- }
+ )
}
}
)
diff --git a/app/src/main/kotlin/com/metrolist/music/ui/screens/settings/UpdaterSettings.kt b/app/src/main/kotlin/com/metrolist/music/ui/screens/settings/UpdaterSettings.kt
index 2c5cb686ab..eb2318ff59 100644
--- a/app/src/main/kotlin/com/metrolist/music/ui/screens/settings/UpdaterSettings.kt
+++ b/app/src/main/kotlin/com/metrolist/music/ui/screens/settings/UpdaterSettings.kt
@@ -5,51 +5,104 @@
package com.metrolist.music.ui.screens.settings
+import android.app.Activity
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.shrinkVertically
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.selection.selectable
+import androidx.compose.foundation.selection.selectableGroup
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FilledTonalButton
+import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
+import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.ProgressIndicatorDefaults
+import androidx.compose.material3.RadioButton
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarScrollBehavior
+import android.content.pm.PackageManager
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.semantics.Role
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.metrolist.music.BuildConfig
import com.metrolist.music.LocalPlayerAwareWindowInsets
import com.metrolist.music.R
import com.metrolist.music.constants.CheckForUpdatesKey
+import com.metrolist.music.constants.InstallerTypeKey
import com.metrolist.music.constants.UpdateNotificationsEnabledKey
+import com.metrolist.music.ui.component.DefaultDialog
import com.metrolist.music.ui.component.IconButton
import com.metrolist.music.ui.component.Material3SettingsGroup
import com.metrolist.music.ui.component.Material3SettingsItem
import com.metrolist.music.ui.utils.backToMain
+import com.metrolist.music.utils.ApkDownloader
+import com.metrolist.music.utils.DownloadState
+import com.metrolist.music.utils.ReleaseInfo
import com.metrolist.music.utils.Updater
import com.metrolist.music.utils.rememberPreference
+import com.metrolist.music.utils.updater.AppInstaller
+import com.metrolist.music.utils.updater.InstallResult
+import com.metrolist.music.utils.updater.InstallerType
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
+import com.rosan.dhizuku.api.Dhizuku
+import com.rosan.dhizuku.api.DhizukuRequestPermissionListener
+import rikka.shizuku.Shizuku
+import java.io.File
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -58,39 +111,161 @@ fun UpdaterScreen(
) {
val (checkForUpdates, onCheckForUpdatesChange) = rememberPreference(CheckForUpdatesKey, true)
val (updateNotifications, onUpdateNotificationsChange) = rememberPreference(UpdateNotificationsEnabledKey, true)
+ val (installerTypeInt, onInstallerTypeChange) = rememberPreference(InstallerTypeKey, 0)
+ val installerType = InstallerType.entries.getOrElse(installerTypeInt) { InstallerType.NATIVE }
val context = LocalContext.current
+ val availableInstallers = remember { AppInstaller.getAvailableInstallers(context) }
var isChecking by remember { mutableStateOf(false) }
var updateAvailable by remember { mutableStateOf(false) }
var latestVersion by remember { mutableStateOf(null) }
+ var releaseInfo by remember { mutableStateOf(null) }
var showChangelog by remember { mutableStateOf(false) }
var changelogContent by remember { mutableStateOf(null) }
var checkError by remember { mutableStateOf(null) }
- val failedToCheckUpdatesTemplate = stringResource(R.string.failed_to_check_updates)
+
+ // Download state
+ var downloadState by remember { mutableStateOf(DownloadState.Idle) }
+ var downloadedApkFile by remember { mutableStateOf(null) }
+ var downloadProgress by remember { mutableFloatStateOf(0f) }
+ var downloadedBytes by remember { mutableStateOf(0L) }
+ var totalBytes by remember { mutableStateOf(0L) }
+ var isInstalling by remember { mutableStateOf(false) }
+ var installError by remember { mutableStateOf(null) }
+ var showInstallerDialog by remember { mutableStateOf(false) }
+
+ // Permission launcher for install packages
+ val installPermissionLauncher = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.StartActivityForResult()
+ ) { result ->
+ if (ApkDownloader.canInstallPackages(context)) {
+ downloadedApkFile?.let { file ->
+ ApkDownloader.installApk(context, file)
+ }
+ }
+ }
val coroutineScope = rememberCoroutineScope()
+ // Check for existing downloaded APK and auto-check if cached update exists
+ LaunchedEffect(Unit) {
+ ApkDownloader.getDownloadedApk(context)?.let { file ->
+ downloadedApkFile = file
+ downloadState = DownloadState.Completed(file)
+ }
+
+ // Auto-populate from cached release if update is available
+ Updater.getCachedLatestRelease()?.let { cached ->
+ if (Updater.isUpdateAvailable(BuildConfig.VERSION_NAME, cached.versionName)) {
+ latestVersion = cached.versionName
+ updateAvailable = true
+ changelogContent = cached.description
+ releaseInfo = cached
+ }
+ }
+ }
+
+ // Shizuku permission listener
+ DisposableEffect(Unit) {
+ val listener = Shizuku.OnRequestPermissionResultListener { requestCode, grantResult ->
+ if (grantResult == PackageManager.PERMISSION_GRANTED) {
+ onInstallerTypeChange(InstallerType.SHIZUKU.ordinal)
+ } else {
+ installError = context.getString(R.string.shizuku_permission_required)
+ }
+ }
+ try {
+ Shizuku.addRequestPermissionResultListener(listener)
+ } catch (e: Exception) {
+ // Shizuku not available
+ }
+ onDispose {
+ try {
+ Shizuku.removeRequestPermissionResultListener(listener)
+ } catch (e: Exception) {
+ // Shizuku not available
+ }
+ }
+ }
+
fun performManualCheck() {
coroutineScope.launch {
isChecking = true
checkError = null
- withContext(Dispatchers.IO) {
- Updater
- .checkForUpdate(forceRefresh = true)
- .onSuccess { (releaseInfo, hasUpdate) ->
- if (releaseInfo != null) {
- latestVersion = releaseInfo.versionName
- updateAvailable = hasUpdate
- changelogContent = releaseInfo.description
- }
- }.onFailure {
- checkError = String.format(failedToCheckUpdatesTemplate, it.message ?: "Unknown error")
- }
+ val result = withContext(Dispatchers.IO) {
+ Updater.checkForUpdate(forceRefresh = true)
+ }
+ result.onSuccess { (info, hasUpdate) ->
+ if (info != null) {
+ latestVersion = info.versionName
+ updateAvailable = hasUpdate
+ changelogContent = info.description
+ releaseInfo = info
+ }
+ }.onFailure {
+ checkError = context.getString(R.string.failed_to_check_updates, it.message ?: "Unknown error")
}
isChecking = false
}
}
+ fun startDownload() {
+ val downloadUrl = releaseInfo?.let { Updater.getDownloadUrlForCurrentVariant(it) }
+ if (downloadUrl == null) {
+ downloadState = DownloadState.Error(context.getString(R.string.download_url_not_found))
+ return
+ }
+
+ coroutineScope.launch {
+ ApkDownloader.downloadApk(context, downloadUrl).collect { state ->
+ downloadState = state
+ when (state) {
+ is DownloadState.Downloading -> {
+ downloadProgress = state.progress
+ downloadedBytes = state.downloadedBytes
+ totalBytes = state.totalBytes
+ }
+ is DownloadState.Completed -> {
+ downloadedApkFile = state.file
+ }
+ else -> {}
+ }
+ }
+ }
+ }
+
+ fun installUpdate() {
+ downloadedApkFile?.let { file ->
+ // For NATIVE installer, check permission first
+ if (installerType == InstallerType.NATIVE && !ApkDownloader.canInstallPackages(context)) {
+ installPermissionLauncher.launch(ApkDownloader.getInstallPermissionIntent(context))
+ return
+ }
+
+ coroutineScope.launch {
+ isInstalling = true
+ installError = null
+ val result = AppInstaller.install(context, file, installerType)
+ isInstalling = false
+ when (result) {
+ is InstallResult.Success -> {
+ // Installation completed successfully (for ROOT/SHIZUKU)
+ downloadState = DownloadState.Idle
+ downloadedApkFile = null
+ ApkDownloader.clearDownloadedApk(context)
+ }
+ is InstallResult.RequiresUserAction -> {
+ // User needs to confirm installation (for NATIVE)
+ // The system installer UI will be shown
+ }
+ is InstallResult.Error -> {
+ installError = result.message
+ }
+ }
+ }
+ }
+ }
+
Column(
modifier =
Modifier
@@ -170,38 +345,182 @@ fun UpdaterScreen(
Spacer(Modifier.height(16.dp))
+ // Installer selection - single item that opens dialog
+ val currentInstallerInfo = availableInstallers.find { it.type == installerType }
Material3SettingsGroup(
- title = stringResource(R.string.check_for_updates_title),
- items =
- listOf(
- Material3SettingsItem(
- icon = painterResource(R.drawable.refresh),
- title = {
- if (isChecking) {
- Text(stringResource(R.string.checking_for_updates))
- } else if (latestVersion != null) {
- Text(stringResource(R.string.latest_version_format, latestVersion!!))
- } else {
- Text(stringResource(R.string.check_for_updates_button))
+ title = stringResource(R.string.installer_method),
+ items = listOf(
+ Material3SettingsItem(
+ title = { Text(stringResource(currentInstallerInfo?.title ?: R.string.installer_native_title)) },
+ description = { Text(stringResource(currentInstallerInfo?.description ?: R.string.installer_native_desc)) },
+ onClick = { showInstallerDialog = true }
+ )
+ )
+ )
+
+ // Installer selection dialog
+ if (showInstallerDialog) {
+ DefaultDialog(
+ onDismiss = { showInstallerDialog = false },
+ icon = {
+ Icon(
+ painter = painterResource(R.drawable.download),
+ contentDescription = null
+ )
+ },
+ title = { Text(stringResource(R.string.installer_method)) },
+ buttons = {
+ TextButton(onClick = { showInstallerDialog = false }) {
+ Text(stringResource(R.string.close))
+ }
+ }
+ ) {
+ HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Column(modifier = Modifier.selectableGroup()) {
+ InstallerType.entries.forEach { type ->
+ val info = availableInstallers.find { it.type == type }!!
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(8.dp))
+ .selectable(
+ selected = installerType == type,
+ role = Role.RadioButton
+ ) {
+ when (type) {
+ InstallerType.ROOT -> {
+ coroutineScope.launch {
+ val hasRoot = withContext(Dispatchers.IO) {
+ AppInstaller.hasRootAccess()
+ }
+ if (hasRoot) {
+ onInstallerTypeChange(type.ordinal)
+ showInstallerDialog = false
+ } else {
+ installError = context.getString(R.string.installer_root_unavailable)
+ }
+ }
+ }
+ InstallerType.SHIZUKU -> {
+ if (!AppInstaller.hasShizukuOrSui(context)) {
+ installError = context.getString(R.string.installer_not_available)
+ } else if (!AppInstaller.isShizukuAlive()) {
+ installError = context.getString(R.string.shizuku_not_running)
+ } else if (AppInstaller.hasShizukuPermission()) {
+ onInstallerTypeChange(type.ordinal)
+ showInstallerDialog = false
+ } else {
+ try {
+ Shizuku.requestPermission(0)
+ } catch (e: Exception) {
+ installError = context.getString(R.string.shizuku_permission_required)
+ }
+ }
+ }
+ InstallerType.DHIZUKU -> {
+ if (!AppInstaller.hasDhizuku(context)) {
+ installError = context.getString(R.string.installer_not_available)
+ } else if (AppInstaller.hasDhizukuPermission(context)) {
+ onInstallerTypeChange(type.ordinal)
+ showInstallerDialog = false
+ } else {
+ // Request Dhizuku permission
+ try {
+ Dhizuku.init(context)
+ Dhizuku.requestPermission(object : DhizukuRequestPermissionListener() {
+ override fun onRequestPermission(grantResult: Int) {
+ if (grantResult == PackageManager.PERMISSION_GRANTED) {
+ onInstallerTypeChange(type.ordinal)
+ showInstallerDialog = false
+ } else {
+ installError = context.getString(R.string.installer_dhizuku_unavailable)
+ }
+ }
+ })
+ } catch (e: Exception) {
+ installError = context.getString(R.string.installer_dhizuku_unavailable)
+ }
+ }
+ }
+ else -> {
+ onInstallerTypeChange(type.ordinal)
+ showInstallerDialog = false
+ }
+ }
}
- },
- trailingContent = {
- if (isChecking) {
- CircularProgressIndicator(
- modifier = Modifier.padding(end = 16.dp),
- strokeWidth = 2.dp,
- )
- } else if (updateAvailable) {
+ .padding(vertical = 8.dp, horizontal = 4.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ RadioButton(
+ selected = installerType == type,
+ onClick = null
+ )
+ Spacer(Modifier.width(8.dp))
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = stringResource(info.title),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ Text(
+ text = stringResource(info.description),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+ HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
+ }
+ }
+
+ Spacer(Modifier.height(16.dp))
+
+ Material3SettingsGroup(
+ title = stringResource(R.string.check_for_updates_title),
+ items = listOf(
+ Material3SettingsItem(
+ icon = painterResource(R.drawable.refresh),
+ title = {
+ if (isChecking) {
+ Text(stringResource(R.string.checking_for_updates))
+ } else if (latestVersion != null) {
+ Text(stringResource(R.string.latest_version_format, latestVersion!!))
+ } else {
+ Text(stringResource(R.string.check_for_updates_button))
+ }
+ },
+ trailingContent = {
+ if (isChecking) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(24.dp),
+ strokeWidth = 2.dp
+ )
+ } else if (updateAvailable) {
+ Box(
+ modifier = Modifier
+ .size(24.dp)
+ .clip(CircleShape)
+ .background(MaterialTheme.colorScheme.primary),
+ contentAlignment = Alignment.Center
+ ) {
Icon(
- painter = painterResource(R.drawable.download),
- contentDescription = stringResource(R.string.update_available_title),
- tint = MaterialTheme.colorScheme.primary,
+ painter = painterResource(R.drawable.arrow_upward),
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onPrimary,
+ modifier = Modifier.size(16.dp)
)
}
- },
- onClick = { if (!isChecking) performManualCheck() },
- ),
- ),
+ }
+ },
+ onClick = { if (!isChecking) performManualCheck() }
+ )
+ )
)
checkError?.let {
@@ -214,28 +533,89 @@ fun UpdaterScreen(
)
}
- if (updateAvailable && latestVersion != null) {
- Spacer(Modifier.height(16.dp))
- Button(
- onClick = { showChangelog = !showChangelog },
- modifier =
- Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp),
- ) {
- Text(if (showChangelog) stringResource(R.string.hide_changelog) else stringResource(R.string.view_changelog))
- }
+ installError?.let {
+ Spacer(Modifier.height(12.dp))
+ Text(
+ text = stringResource(R.string.install_failed, it),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.error,
+ modifier = Modifier.padding(horizontal = 16.dp)
+ )
+ }
- if (showChangelog && changelogContent != null) {
- Spacer(Modifier.height(12.dp))
- Text(
- text = changelogContent!!,
- style = MaterialTheme.typography.bodySmall,
- modifier =
- Modifier
- .fillMaxWidth()
- .padding(16.dp),
+ // Update available card with download functionality
+ AnimatedVisibility(
+ visible = updateAvailable && latestVersion != null,
+ enter = expandVertically() + fadeIn(),
+ exit = shrinkVertically() + fadeOut()
+ ) {
+ Column {
+ Spacer(Modifier.height(16.dp))
+
+ UpdateDownloadCard(
+ latestVersion = latestVersion ?: "",
+ downloadState = downloadState,
+ downloadProgress = downloadProgress,
+ downloadedBytes = downloadedBytes,
+ totalBytes = totalBytes,
+ isInstalling = isInstalling,
+ onDownloadClick = { startDownload() },
+ onInstallClick = { installUpdate() },
+ onRetryClick = {
+ downloadState = DownloadState.Idle
+ installError = null
+ startDownload()
+ },
+ onCancelClick = {
+ ApkDownloader.clearDownloadedApk(context)
+ downloadState = DownloadState.Idle
+ downloadedApkFile = null
+ installError = null
+ }
)
+
+ Spacer(Modifier.height(16.dp))
+
+ // Changelog section
+ FilledTonalButton(
+ onClick = { showChangelog = !showChangelog },
+ modifier = Modifier.fillMaxWidth(),
+ colors = ButtonDefaults.filledTonalButtonColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
+ contentColor = MaterialTheme.colorScheme.onSurface
+ )
+ ) {
+ Icon(
+ painter = painterResource(
+ if (showChangelog) R.drawable.expand_less else R.drawable.expand_more
+ ),
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(18.dp)
+ )
+ Spacer(Modifier.width(8.dp))
+ Text(if (showChangelog) stringResource(R.string.hide_changelog) else stringResource(R.string.view_changelog))
+ }
+
+ AnimatedVisibility(
+ visible = showChangelog && changelogContent != null,
+ enter = expandVertically() + fadeIn(),
+ exit = shrinkVertically() + fadeOut()
+ ) {
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 12.dp),
+ shape = RoundedCornerShape(16.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
+ )
+ ) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ MarkdownText(changelogContent ?: "")
+ }
+ }
+ }
}
}
@@ -257,3 +637,273 @@ fun UpdaterScreen(
},
)
}
+
+@Composable
+private fun UpdateDownloadCard(
+ latestVersion: String,
+ downloadState: DownloadState,
+ downloadProgress: Float,
+ downloadedBytes: Long,
+ totalBytes: Long,
+ isInstalling: Boolean,
+ onDownloadClick: () -> Unit,
+ onInstallClick: () -> Unit,
+ onRetryClick: () -> Unit,
+ onCancelClick: () -> Unit
+) {
+ val animatedProgress by animateFloatAsState(
+ targetValue = downloadProgress,
+ animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec,
+ label = "download_progress"
+ )
+
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(24.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
+ )
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(20.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ // Header with icon
+ Box(
+ modifier = Modifier
+ .size(56.dp)
+ .clip(CircleShape)
+ .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)),
+ contentAlignment = Alignment.Center
+ ) {
+ AnimatedContent(
+ targetState = downloadState,
+ contentKey = { state ->
+ // Use class name as key so Downloading doesn't re-animate on progress updates
+ state::class.simpleName
+ },
+ label = "icon_animation"
+ ) { state ->
+ when (state) {
+ is DownloadState.Idle -> {
+ Icon(
+ painter = painterResource(R.drawable.download),
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(28.dp)
+ )
+ }
+ is DownloadState.Downloading -> {
+ CircularProgressIndicator(
+ modifier = Modifier.size(32.dp),
+ color = MaterialTheme.colorScheme.primary,
+ trackColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f),
+ strokeWidth = 3.dp
+ )
+ }
+ is DownloadState.Completed -> {
+ Icon(
+ painter = painterResource(R.drawable.done),
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(28.dp)
+ )
+ }
+ is DownloadState.Error -> {
+ Icon(
+ painter = painterResource(R.drawable.error),
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.error,
+ modifier = Modifier.size(28.dp)
+ )
+ }
+ }
+ }
+ }
+
+ Spacer(Modifier.height(16.dp))
+
+ // Title
+ Text(
+ text = when (downloadState) {
+ is DownloadState.Idle -> stringResource(R.string.update_available_title)
+ is DownloadState.Downloading -> stringResource(R.string.downloading_update)
+ is DownloadState.Completed -> stringResource(R.string.download_complete)
+ is DownloadState.Error -> stringResource(R.string.download_failed)
+ },
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+
+ Spacer(Modifier.height(4.dp))
+
+ // Version info
+ Text(
+ text = stringResource(R.string.version_update_info, BuildConfig.VERSION_NAME, latestVersion),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ // Progress section for downloading state
+ AnimatedVisibility(
+ visible = downloadState is DownloadState.Downloading,
+ enter = expandVertically() + fadeIn(),
+ exit = shrinkVertically() + fadeOut()
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ LinearProgressIndicator(
+ progress = { animatedProgress },
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(8.dp)
+ .clip(RoundedCornerShape(4.dp)),
+ color = MaterialTheme.colorScheme.primary,
+ trackColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.2f)
+ )
+
+ Spacer(Modifier.height(8.dp))
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Text(
+ text = "${ApkDownloader.formatBytes(downloadedBytes)} / ${ApkDownloader.formatBytes(totalBytes)}",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text(
+ text = "${(downloadProgress * 100).toInt()}%",
+ style = MaterialTheme.typography.labelSmall,
+ fontWeight = FontWeight.Medium,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ }
+ }
+ }
+
+ // Error message
+ if (downloadState is DownloadState.Error) {
+ Spacer(Modifier.height(8.dp))
+ Text(
+ text = downloadState.message,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.error,
+ textAlign = TextAlign.Center
+ )
+ }
+
+ Spacer(Modifier.height(16.dp))
+
+ // Action buttons
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ when (downloadState) {
+ is DownloadState.Idle -> {
+ Button(
+ onClick = onDownloadClick,
+ modifier = Modifier.fillMaxWidth(),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.primary
+ )
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.download),
+ contentDescription = null,
+ modifier = Modifier.size(18.dp)
+ )
+ Spacer(Modifier.width(8.dp))
+ Text(
+ text = stringResource(R.string.download_update),
+ maxLines = 1
+ )
+ }
+ }
+ is DownloadState.Downloading -> {
+ OutlinedButton(
+ onClick = onCancelClick,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.close),
+ contentDescription = null,
+ modifier = Modifier.size(18.dp)
+ )
+ Spacer(Modifier.width(8.dp))
+ Text(stringResource(R.string.cancel))
+ }
+ }
+ is DownloadState.Completed -> {
+ Button(
+ onClick = onInstallClick,
+ modifier = Modifier.weight(1f),
+ enabled = !isInstalling,
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.primary
+ )
+ ) {
+ if (isInstalling) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(18.dp),
+ color = MaterialTheme.colorScheme.onPrimary,
+ strokeWidth = 2.dp
+ )
+ } else {
+ Icon(
+ painter = painterResource(R.drawable.update),
+ contentDescription = null,
+ modifier = Modifier.size(18.dp)
+ )
+ }
+ Spacer(Modifier.width(8.dp))
+ Text(
+ text = if (isInstalling) stringResource(R.string.installing) else stringResource(R.string.install),
+ maxLines = 1
+ )
+ }
+ OutlinedButton(
+ onClick = onCancelClick,
+ modifier = Modifier.weight(1f),
+ enabled = !isInstalling
+ ) {
+ Text(stringResource(R.string.cancel))
+ }
+ }
+ is DownloadState.Error -> {
+ Button(
+ onClick = onRetryClick,
+ modifier = Modifier.weight(1f),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.primary
+ )
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.refresh),
+ contentDescription = null,
+ modifier = Modifier.size(18.dp)
+ )
+ Spacer(Modifier.width(8.dp))
+ Text(stringResource(R.string.retry_button))
+ }
+ OutlinedButton(
+ onClick = onCancelClick,
+ modifier = Modifier.weight(1f)
+ ) {
+ Text(stringResource(R.string.cancel))
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/kotlin/com/metrolist/music/utils/ApkDownloader.kt b/app/src/main/kotlin/com/metrolist/music/utils/ApkDownloader.kt
new file mode 100644
index 0000000000..7fcf3989b6
--- /dev/null
+++ b/app/src/main/kotlin/com/metrolist/music/utils/ApkDownloader.kt
@@ -0,0 +1,149 @@
+/**
+ * Metrolist Project (C) 2026
+ * Licensed under GPL-3.0 | See git history for contributors
+ */
+
+package com.metrolist.music.utils
+
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.net.Uri
+import android.os.Build
+import android.provider.Settings
+import androidx.core.content.FileProvider
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.flowOn
+import java.io.File
+import java.io.FileOutputStream
+import java.net.HttpURLConnection
+import java.net.URL
+
+sealed class DownloadState {
+ data object Idle : DownloadState()
+ data class Downloading(val progress: Float, val downloadedBytes: Long, val totalBytes: Long) : DownloadState()
+ data class Completed(val file: File) : DownloadState()
+ data class Error(val message: String) : DownloadState()
+}
+
+object ApkDownloader {
+ private const val APK_FILE_NAME = "metrolist_update.apk"
+ private const val BUFFER_SIZE = 8192
+
+ fun canInstallPackages(context: Context): Boolean {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ context.packageManager.canRequestPackageInstalls()
+ } else {
+ true
+ }
+ }
+
+ fun getInstallPermissionIntent(context: Context): Intent {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
+ data = Uri.parse("package:${context.packageName}")
+ }
+ } else {
+ Intent(Settings.ACTION_SECURITY_SETTINGS)
+ }
+ }
+
+ fun downloadApk(
+ context: Context,
+ downloadUrl: String,
+ onProgress: ((DownloadState) -> Unit)? = null
+ ): Flow = flow {
+ emit(DownloadState.Downloading(0f, 0, 0))
+
+ try {
+ val cacheDir = context.externalCacheDir ?: context.cacheDir
+ val apkFile = File(cacheDir, APK_FILE_NAME)
+
+ // Delete existing file if any
+ if (apkFile.exists()) {
+ apkFile.delete()
+ }
+
+ val url = URL(downloadUrl)
+ val connection = url.openConnection() as HttpURLConnection
+ connection.requestMethod = "GET"
+ connection.connectTimeout = 15000
+ connection.readTimeout = 15000
+ connection.setRequestProperty("Accept", "application/vnd.android.package-archive")
+ connection.connect()
+
+ if (connection.responseCode != HttpURLConnection.HTTP_OK) {
+ emit(DownloadState.Error("Server returned HTTP ${connection.responseCode}"))
+ return@flow
+ }
+
+ val totalBytes = connection.contentLengthLong
+ var downloadedBytes = 0L
+
+ connection.inputStream.use { input ->
+ FileOutputStream(apkFile).use { output ->
+ val buffer = ByteArray(BUFFER_SIZE)
+ var bytesRead: Int
+
+ while (input.read(buffer).also { bytesRead = it } != -1) {
+ output.write(buffer, 0, bytesRead)
+ downloadedBytes += bytesRead
+
+ val progress = if (totalBytes > 0) {
+ downloadedBytes.toFloat() / totalBytes.toFloat()
+ } else {
+ 0f
+ }
+
+ emit(DownloadState.Downloading(progress, downloadedBytes, totalBytes))
+ onProgress?.invoke(DownloadState.Downloading(progress, downloadedBytes, totalBytes))
+ }
+ }
+ }
+
+ emit(DownloadState.Completed(apkFile))
+ } catch (e: Exception) {
+ emit(DownloadState.Error(e.message ?: "Unknown error occurred"))
+ }
+ }.flowOn(Dispatchers.IO)
+
+ fun installApk(context: Context, apkFile: File) {
+ val apkUri: Uri = FileProvider.getUriForFile(
+ context,
+ "${context.packageName}.FileProvider",
+ apkFile
+ )
+
+ val installIntent = Intent(Intent.ACTION_VIEW).apply {
+ setDataAndType(apkUri, "application/vnd.android.package-archive")
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION
+ }
+
+ context.startActivity(installIntent)
+ }
+
+ fun formatBytes(bytes: Long): String {
+ return when {
+ bytes < 1024 -> "$bytes B"
+ bytes < 1024 * 1024 -> String.format("%.1f KB", bytes / 1024.0)
+ bytes < 1024 * 1024 * 1024 -> String.format("%.1f MB", bytes / (1024.0 * 1024.0))
+ else -> String.format("%.2f GB", bytes / (1024.0 * 1024.0 * 1024.0))
+ }
+ }
+
+ fun clearDownloadedApk(context: Context) {
+ val cacheDir = context.externalCacheDir ?: context.cacheDir
+ val apkFile = File(cacheDir, APK_FILE_NAME)
+ if (apkFile.exists()) {
+ apkFile.delete()
+ }
+ }
+
+ fun getDownloadedApk(context: Context): File? {
+ val cacheDir = context.externalCacheDir ?: context.cacheDir
+ val apkFile = File(cacheDir, APK_FILE_NAME)
+ return if (apkFile.exists()) apkFile else null
+ }
+}
diff --git a/app/src/main/kotlin/com/metrolist/music/utils/Updater.kt b/app/src/main/kotlin/com/metrolist/music/utils/Updater.kt
index 5eb6a77720..91d1041ae9 100644
--- a/app/src/main/kotlin/com/metrolist/music/utils/Updater.kt
+++ b/app/src/main/kotlin/com/metrolist/music/utils/Updater.kt
@@ -8,7 +8,9 @@ package com.metrolist.music.utils
import com.metrolist.music.BuildConfig
import io.ktor.client.HttpClient
import io.ktor.client.request.get
+import io.ktor.client.request.header
import io.ktor.client.statement.bodyAsText
+import io.ktor.http.HttpHeaders
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONArray
@@ -127,19 +129,27 @@ object Updater {
if (cachedReleaseInfo != null && !forceRefresh) {
return@runCatching cachedReleaseInfo!!
}
-
- val response = client.get("$GITHUB_API_BASE/releases/latest")
- .bodyAsText()
+
+ val response = client.get("$GITHUB_API_BASE/releases/latest") {
+ header(HttpHeaders.UserAgent, "Metrolist/${BuildConfig.VERSION_NAME}")
+ header(HttpHeaders.Accept, "application/vnd.github+json")
+ }.bodyAsText()
val json = JSONObject(response)
-
+
+ // Check for GitHub API error responses
+ if (json.has("message") && !json.has("tag_name")) {
+ val message = json.getString("message")
+ throw Exception(message)
+ }
+
val releaseInfo = ReleaseInfo(
tagName = json.getString("tag_name"),
- versionName = json.getString("name"),
- description = json.getString("body"),
- releaseDate = json.getString("published_at"),
- assets = parseAssets(json.getJSONArray("assets"))
+ versionName = json.optString("name", json.getString("tag_name")),
+ description = json.optString("body", ""),
+ releaseDate = json.optString("published_at", ""),
+ assets = parseAssets(json.optJSONArray("assets") ?: org.json.JSONArray())
)
-
+
cachedReleaseInfo = releaseInfo
lastCheckTime = System.currentTimeMillis()
releaseInfo
@@ -161,8 +171,10 @@ object Updater {
var hasMore = true
while (hasMore && page <= 10) { // Limit to 10 pages
- val response = client.get("$GITHUB_API_BASE/releases?page=$page&per_page=30")
- .bodyAsText()
+ val response = client.get("$GITHUB_API_BASE/releases?page=$page&per_page=30") {
+ header(HttpHeaders.UserAgent, "Metrolist/${BuildConfig.VERSION_NAME}")
+ header(HttpHeaders.Accept, "application/vnd.github+json")
+ }.bodyAsText()
val json = JSONArray(response)
if (json.length() == 0) {
diff --git a/app/src/main/kotlin/com/metrolist/music/utils/updater/AppInstaller.kt b/app/src/main/kotlin/com/metrolist/music/utils/updater/AppInstaller.kt
new file mode 100644
index 0000000000..f1f129bc85
--- /dev/null
+++ b/app/src/main/kotlin/com/metrolist/music/utils/updater/AppInstaller.kt
@@ -0,0 +1,399 @@
+/**
+ * Metrolist Project (C) 2026
+ * Licensed under GPL-3.0 | See git history for contributors
+ *
+ * Based on Aurora Store installer implementation
+ * https://github.com/whyorean/AuroraStore
+ */
+
+package com.metrolist.music.utils.updater
+
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.content.pm.IPackageInstaller
+import android.content.pm.IPackageInstallerSession
+import android.content.pm.IPackageManager
+import android.content.pm.PackageInstaller
+import android.content.pm.PackageInstallerHidden
+import android.content.pm.PackageManager
+import android.content.pm.PackageManagerHidden
+import android.net.Uri
+import android.os.Build
+import android.os.IBinder
+import android.os.IInterface
+import android.util.Log
+import androidx.annotation.RequiresApi
+import androidx.core.content.FileProvider
+import com.metrolist.music.R
+import com.rosan.dhizuku.api.Dhizuku
+import com.topjohnwu.superuser.Shell
+import dev.rikka.tools.refine.Refine
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import rikka.shizuku.Shizuku
+import rikka.shizuku.ShizukuBinderWrapper
+import rikka.shizuku.SystemServiceHelper
+import java.io.File
+import java.util.regex.Pattern
+
+sealed class InstallResult {
+ data object Success : InstallResult()
+ data class Error(val message: String) : InstallResult()
+ data object RequiresUserAction : InstallResult()
+}
+
+object AppInstaller {
+ private const val TAG = "AppInstaller"
+ private const val SHIZUKU_PACKAGE = "moe.shizuku.privileged.api"
+ private const val DHIZUKU_PACKAGE = "com.rosan.dhizuku"
+ // Removed PLAY_PACKAGE_NAME - use context.packageName instead
+
+ // Extension functions for Shizuku binder wrapping
+ private fun IBinder.wrap() = ShizukuBinderWrapper(this)
+ private fun IInterface.asShizukuBinder() = this.asBinder().wrap()
+
+ // Extension functions for Dhizuku binder wrapping
+ private fun IBinder.wrapDhizuku(): IBinder = Dhizuku.binderWrapper(this)
+ private fun IInterface.asDhizukuBinder(): IBinder = Dhizuku.binderWrapper(this.asBinder())
+
+ // Cached Dhizuku binders for performance
+ private var cachedDhizukuIPackageInstaller: IPackageInstaller? = null
+ private var cachedDhizukuPackageInstaller: PackageInstaller? = null
+
+ fun getAvailableInstallers(context: Context): List {
+ // Return all installers - permission checks happen when user selects them
+ return listOf(
+ InstallerRegistry.NATIVE,
+ InstallerRegistry.ROOT,
+ InstallerRegistry.SHIZUKU,
+ InstallerRegistry.DHIZUKU
+ )
+ }
+
+ fun hasRootAccess(): Boolean = Shell.getShell().isRoot
+
+ fun hasShizukuOrSui(context: Context): Boolean {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return false
+ return try {
+ context.packageManager.getPackageInfo(SHIZUKU_PACKAGE, 0)
+ true
+ } catch (e: PackageManager.NameNotFoundException) {
+ false
+ }
+ }
+
+ fun hasShizukuPermission(): Boolean {
+ return try {
+ Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED
+ } catch (e: Exception) {
+ false
+ }
+ }
+
+ fun isShizukuAlive(): Boolean {
+ return try {
+ Shizuku.pingBinder()
+ } catch (e: Exception) {
+ false
+ }
+ }
+
+ fun hasDhizuku(context: Context): Boolean {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return false
+ return try {
+ context.packageManager.getPackageInfo(DHIZUKU_PACKAGE, 0)
+ true
+ } catch (e: PackageManager.NameNotFoundException) {
+ false
+ }
+ }
+
+ fun hasDhizukuPermission(context: Context): Boolean {
+ return try {
+ Dhizuku.init(context)
+ Dhizuku.isPermissionGranted()
+ } catch (e: Exception) {
+ false
+ }
+ }
+
+ private fun ensureDhizukuInit(context: Context): Boolean {
+ return try {
+ Dhizuku.init(context)
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to initialize Dhizuku", e)
+ // Clear cache on init failure
+ cachedDhizukuIPackageInstaller = null
+ cachedDhizukuPackageInstaller = null
+ false
+ }
+ }
+
+ suspend fun install(
+ context: Context,
+ apkFile: File,
+ installerType: InstallerType
+ ): InstallResult = withContext(Dispatchers.IO) {
+ when (installerType) {
+ InstallerType.NATIVE -> installNative(context, apkFile)
+ InstallerType.ROOT -> installRoot(context, apkFile)
+ InstallerType.SHIZUKU -> installShizuku(context, apkFile)
+ InstallerType.DHIZUKU -> installDhizuku(context, apkFile)
+ }
+ }
+
+ private fun installNative(context: Context, apkFile: File): InstallResult {
+ return try {
+ val apkUri: Uri = FileProvider.getUriForFile(
+ context,
+ "${context.packageName}.FileProvider",
+ apkFile
+ )
+
+ val installIntent = Intent(Intent.ACTION_VIEW).apply {
+ setDataAndType(apkUri, "application/vnd.android.package-archive")
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION
+ putExtra(Intent.EXTRA_NOT_UNKNOWN_SOURCE, true)
+ putExtra(Intent.EXTRA_INSTALLER_PACKAGE_NAME, context.packageName)
+ }
+
+ context.startActivity(installIntent)
+ InstallResult.RequiresUserAction
+ } catch (e: Exception) {
+ InstallResult.Error(e.message ?: "Failed to launch installer")
+ }
+ }
+
+ /**
+ * Root installation using libsu - based on Aurora Store's RootInstaller
+ */
+ private fun installRoot(context: Context, apkFile: File): InstallResult {
+ if (!Shell.getShell().isRoot) {
+ return InstallResult.Error(context.getString(R.string.installer_root_unavailable))
+ }
+
+ return try {
+ val totalSize = apkFile.length()
+
+ // Create install session via pm
+ val createResult = Shell.cmd(
+ "pm install-create -i ${context.packageName} --user 0 -r -S $totalSize"
+ ).exec()
+
+ if (!createResult.isSuccess) {
+ return InstallResult.Error(createResult.err.joinToString("\n").ifEmpty { "Failed to create install session" })
+ }
+
+ val response = createResult.out
+ val sessionIdPattern = Pattern.compile("(\\d+)")
+ val sessionIdMatcher = sessionIdPattern.matcher(response.firstOrNull() ?: "")
+
+ if (!sessionIdMatcher.find()) {
+ return InstallResult.Error("Failed to get session ID")
+ }
+
+ val sessionId = sessionIdMatcher.group(1)?.toInt()
+ ?: return InstallResult.Error("Invalid session ID")
+
+ // Write APK to session
+ val writeResult = Shell.cmd(
+ "cat \"${apkFile.absolutePath}\" | pm install-write -S ${apkFile.length()} $sessionId \"${apkFile.name}\""
+ ).exec()
+
+ if (!writeResult.isSuccess) {
+ return InstallResult.Error(writeResult.err.joinToString("\n").ifEmpty { "Failed to write APK" })
+ }
+
+ // Commit session
+ val commitResult = Shell.cmd("pm install-commit $sessionId").exec()
+
+ if (commitResult.isSuccess) {
+ InstallResult.Success
+ } else {
+ InstallResult.Error(commitResult.err.joinToString("\n").ifEmpty { "Install commit failed" })
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Root install failed", e)
+ InstallResult.Error(e.message ?: "Root install failed")
+ }
+ }
+
+ /**
+ * Shizuku installation - based on Aurora Store's ShizukuInstaller
+ * Uses hidden APIs via rikka.tools.refine
+ */
+ @RequiresApi(Build.VERSION_CODES.O)
+ private fun installShizuku(context: Context, apkFile: File): InstallResult {
+ if (!isShizukuAlive()) {
+ return InstallResult.Error(context.getString(R.string.shizuku_not_running))
+ }
+ if (!hasShizukuPermission()) {
+ return InstallResult.Error(context.getString(R.string.shizuku_permission_required))
+ }
+
+ return try {
+ // Get package manager via Shizuku - exactly like Aurora Store
+ val iPackageManager = IPackageManager.Stub.asInterface(
+ SystemServiceHelper.getSystemService("package").wrap()
+ )
+
+ // Get package installer via Shizuku
+ val iPackageInstaller = IPackageInstaller.Stub.asInterface(
+ iPackageManager.packageInstaller.asShizukuBinder()
+ )
+
+ // Create PackageInstaller instance
+ val packageInstaller = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ Refine.unsafeCast(
+ PackageInstallerHidden(iPackageInstaller, context.packageName, null, 0)
+ )
+ } else {
+ Refine.unsafeCast(
+ PackageInstallerHidden(iPackageInstaller, context.packageName, 0)
+ )
+ }
+
+ // Create session params with replace flag
+ val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
+ var flags = Refine.unsafeCast(params).installFlags
+ flags = flags or PackageManagerHidden.INSTALL_REPLACE_EXISTING
+ Refine.unsafeCast(params).installFlags = flags
+
+ // Create and open session
+ val sessionId = packageInstaller.createSession(params)
+ val iSession = IPackageInstallerSession.Stub.asInterface(
+ iPackageInstaller.openSession(sessionId).asShizukuBinder()
+ )
+ val session = Refine.unsafeCast(
+ PackageInstallerHidden.SessionHidden(iSession)
+ )
+
+ // Write APK to session
+ apkFile.inputStream().use { input ->
+ session.openWrite("metrolist_${System.currentTimeMillis()}", 0, -1).use { output ->
+ input.copyTo(output)
+ session.fsync(output)
+ }
+ }
+
+ // Create callback intent
+ val callBackIntent = Intent(context, InstallReceiver::class.java).apply {
+ action = InstallReceiver.ACTION_INSTALL_STATUS
+ setPackage(context.packageName)
+ addFlags(Intent.FLAG_RECEIVER_FOREGROUND)
+ }
+
+ val pendingIntent = PendingIntent.getBroadcast(
+ context,
+ sessionId,
+ callBackIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
+ )
+
+ // Commit session
+ session.commit(pendingIntent.intentSender)
+ session.close()
+
+ InstallResult.RequiresUserAction
+ } catch (e: NoSuchMethodError) {
+ // Hidden API changed in Android 16+
+ Log.e(TAG, "Shizuku install failed - incompatible Android version", e)
+ InstallResult.Error(context.getString(R.string.shizuku_not_supported_version))
+ } catch (e: Exception) {
+ Log.e(TAG, "Shizuku install failed", e)
+ InstallResult.Error(e.message ?: "Shizuku install failed")
+ }
+ }
+
+ /**
+ * Dhizuku installation - based on Aurora Store's DhizukuInstaller
+ * Uses Dhizuku API for device owner based installation
+ */
+ @RequiresApi(Build.VERSION_CODES.O)
+ private fun installDhizuku(context: Context, apkFile: File): InstallResult {
+ // Ensure Dhizuku is initialized
+ if (!ensureDhizukuInit(context)) {
+ return InstallResult.Error(context.getString(R.string.installer_dhizuku_unavailable))
+ }
+
+ if (!Dhizuku.isPermissionGranted()) {
+ return InstallResult.Error(context.getString(R.string.installer_dhizuku_unavailable))
+ }
+
+ return try {
+ // Get IPackageInstaller via Dhizuku
+ val iPackageInstaller = cachedDhizukuIPackageInstaller ?: run {
+ val iPackageManager = IPackageManager.Stub.asInterface(
+ SystemServiceHelper.getSystemService("package").wrapDhizuku()
+ )
+ IPackageInstaller.Stub.asInterface(
+ iPackageManager.packageInstaller.asDhizukuBinder()
+ ).also { cachedDhizukuIPackageInstaller = it }
+ }
+
+ // Get PackageInstaller instance
+ val packageInstaller = cachedDhizukuPackageInstaller ?: run {
+ val installer = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ Refine.unsafeCast(
+ PackageInstallerHidden(iPackageInstaller, DHIZUKU_PACKAGE, null, 0)
+ )
+ } else {
+ Refine.unsafeCast(
+ PackageInstallerHidden(iPackageInstaller, DHIZUKU_PACKAGE, 0)
+ )
+ }
+ installer.also { cachedDhizukuPackageInstaller = it }
+ }
+
+ // Create session params with replace flag
+ val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
+ var flags = Refine.unsafeCast(params).installFlags
+ flags = flags or PackageManagerHidden.INSTALL_REPLACE_EXISTING
+ Refine.unsafeCast(params).installFlags = flags
+
+ // Create and open session
+ val sessionId = packageInstaller.createSession(params)
+ val iSession = IPackageInstallerSession.Stub.asInterface(
+ iPackageInstaller.openSession(sessionId).asDhizukuBinder()
+ )
+ val session = Refine.unsafeCast(
+ PackageInstallerHidden.SessionHidden(iSession)
+ )
+
+ // Write APK to session
+ apkFile.inputStream().use { input ->
+ session.openWrite("metrolist_${System.currentTimeMillis()}", 0, -1).use { output ->
+ input.copyTo(output)
+ session.fsync(output)
+ }
+ }
+
+ // Create callback intent
+ val callBackIntent = Intent(context, InstallReceiver::class.java).apply {
+ action = InstallReceiver.ACTION_INSTALL_STATUS
+ setPackage(context.packageName)
+ addFlags(Intent.FLAG_RECEIVER_FOREGROUND)
+ }
+
+ val pendingIntent = PendingIntent.getBroadcast(
+ context,
+ sessionId,
+ callBackIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
+ )
+
+ // Commit session
+ session.commit(pendingIntent.intentSender)
+ session.close()
+
+ InstallResult.RequiresUserAction
+ } catch (e: Exception) {
+ Log.e(TAG, "Dhizuku install failed", e)
+ // Clear cache on failure in case binder is stale
+ cachedDhizukuIPackageInstaller = null
+ cachedDhizukuPackageInstaller = null
+ InstallResult.Error(e.message ?: context.getString(R.string.installer_dhizuku_unavailable))
+ }
+ }
+}
diff --git a/app/src/main/kotlin/com/metrolist/music/utils/updater/InstallReceiver.kt b/app/src/main/kotlin/com/metrolist/music/utils/updater/InstallReceiver.kt
new file mode 100644
index 0000000000..9558a3ce95
--- /dev/null
+++ b/app/src/main/kotlin/com/metrolist/music/utils/updater/InstallReceiver.kt
@@ -0,0 +1,62 @@
+/**
+ * Metrolist Project (C) 2026
+ * Licensed under GPL-3.0 | See git history for contributors
+ */
+
+package com.metrolist.music.utils.updater
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageInstaller
+import android.os.Build
+import android.widget.Toast
+import com.metrolist.music.R
+
+class InstallReceiver : BroadcastReceiver() {
+ companion object {
+ const val ACTION_INSTALL_STATUS = "com.metrolist.music.INSTALL_STATUS"
+ }
+
+ override fun onReceive(context: Context, intent: Intent) {
+ if (intent.action != ACTION_INSTALL_STATUS) return
+
+ val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, -1)
+ val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
+
+ when (status) {
+ PackageInstaller.STATUS_PENDING_USER_ACTION -> {
+ val confirmIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)
+ } else {
+ @Suppress("DEPRECATION")
+ intent.getParcelableExtra(Intent.EXTRA_INTENT)
+ }
+ confirmIntent?.let {
+ it.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ context.startActivity(it)
+ }
+ }
+ PackageInstaller.STATUS_SUCCESS -> {
+ Toast.makeText(
+ context,
+ R.string.install_success,
+ Toast.LENGTH_SHORT
+ ).show()
+ }
+ PackageInstaller.STATUS_FAILURE,
+ PackageInstaller.STATUS_FAILURE_ABORTED,
+ PackageInstaller.STATUS_FAILURE_BLOCKED,
+ PackageInstaller.STATUS_FAILURE_CONFLICT,
+ PackageInstaller.STATUS_FAILURE_INCOMPATIBLE,
+ PackageInstaller.STATUS_FAILURE_INVALID,
+ PackageInstaller.STATUS_FAILURE_STORAGE -> {
+ Toast.makeText(
+ context,
+ context.getString(R.string.install_failed, message ?: "Unknown error"),
+ Toast.LENGTH_LONG
+ ).show()
+ }
+ }
+ }
+}
diff --git a/app/src/main/kotlin/com/metrolist/music/utils/updater/Installer.kt b/app/src/main/kotlin/com/metrolist/music/utils/updater/Installer.kt
new file mode 100644
index 0000000000..577a4cd33a
--- /dev/null
+++ b/app/src/main/kotlin/com/metrolist/music/utils/updater/Installer.kt
@@ -0,0 +1,53 @@
+/**
+ * Metrolist Project (C) 2026
+ * Licensed under GPL-3.0 | See git history for contributors
+ */
+
+package com.metrolist.music.utils.updater
+
+import androidx.annotation.StringRes
+import com.metrolist.music.R
+
+enum class InstallerType {
+ NATIVE,
+ ROOT,
+ SHIZUKU,
+ DHIZUKU
+}
+
+data class InstallerInfo(
+ val type: InstallerType,
+ @StringRes val title: Int,
+ @StringRes val description: Int,
+ val available: Boolean
+)
+
+object InstallerRegistry {
+ val NATIVE = InstallerInfo(
+ type = InstallerType.NATIVE,
+ title = R.string.installer_native_title,
+ description = R.string.installer_native_desc,
+ available = true
+ )
+
+ val ROOT = InstallerInfo(
+ type = InstallerType.ROOT,
+ title = R.string.installer_root_title,
+ description = R.string.installer_root_desc,
+ available = true // Will be checked at runtime
+ )
+
+ val SHIZUKU = InstallerInfo(
+ type = InstallerType.SHIZUKU,
+ title = R.string.installer_shizuku_title,
+ description = R.string.installer_shizuku_desc,
+ available = true // Will be checked at runtime
+ )
+
+ val DHIZUKU = InstallerInfo(
+ type = InstallerType.DHIZUKU,
+ title = R.string.installer_dhizuku_title,
+ description = R.string.installer_dhizuku_desc,
+ available = true // Will be checked at runtime
+ )
+}
diff --git a/app/src/main/res/values/metrolist_strings.xml b/app/src/main/res/values/metrolist_strings.xml
index 85c349e44a..0ebd6cadd8 100644
--- a/app/src/main/res/values/metrolist_strings.xml
+++ b/app/src/main/res/values/metrolist_strings.xml
@@ -322,6 +322,33 @@
Update available
App updates
Notifications about new versions
+ Downloading update
+ Download complete
+ Download failed
+ Download
+ Install
+ %1$s → %2$s
+ Download URL not found for your device
+ Update installed successfully
+ Installation failed: %s
+ Installation method
+ Standard
+ Opens system package installer
+ Root
+ Silent installation using root access
+ Shizuku
+ Silent installation using Shizuku
+ Shizuku permission required
+ Shizuku installer not supported on this Android version
+ Root access not available
+ Not available on this device
+ Installation method
+ Shizuku is not running
+ Dhizuku
+ Silent installation via shared device owner
+ Dhizuku is not available or permission not granted
+ Installing…
+ Retry
Enable offload
diff --git a/build.gradle.kts b/build.gradle.kts
index 888b738f68..30f9038291 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -2,6 +2,7 @@ plugins {
alias(libs.plugins.hilt) apply (false)
alias(libs.plugins.kotlin.ksp) apply (false)
alias(libs.plugins.kotlin.serialization) apply false
+ alias(libs.plugins.rikka.tools.refine) apply false
}
buildscript {
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 2e9628b049..b6c2f74294 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -35,6 +35,12 @@ kuromojiIpadic = "0.9.0"
newpipeextractor = "v0.26.0"
tinypinyin = "2.0.3"
protobuf = "4.34.0"
+shizuku = "13.1.5"
+dhizuku = "2.5.4"
+libsu = "6.0.0"
+rikkaTools = "4.4.0"
+rikkaHiddenAPI = "4.4.0"
+hiddenapibypass = "6.1"
[libraries]
guava = { module = "com.google.guava:guava", version.ref = "guava" }
@@ -115,8 +121,18 @@ tinypinyin = { module = "com.github.promeG:tinypinyin", version.ref = "tinypinyi
protobuf-javalite = { module = "com.google.protobuf:protobuf-javalite", version.ref = "protobuf" }
protobuf-kotlin-lite = { module = "com.google.protobuf:protobuf-kotlin-lite", version.ref = "protobuf" }
+shizuku-api = { module = "dev.rikka.shizuku:api", version.ref = "shizuku" }
+shizuku-provider = { module = "dev.rikka.shizuku:provider", version.ref = "shizuku" }
+
+libsu-core = { module = "com.github.topjohnwu.libsu:core", version.ref = "libsu" }
+dhizuku-api = { module = "io.github.iamr0s:Dhizuku-API", version.ref = "dhizuku" }
+rikka-hidden-stub = { module = "dev.rikka.hidden:stub", version.ref = "rikkaHiddenAPI" }
+rikka-tools-refine-runtime = { module = "dev.rikka.tools.refine:runtime", version.ref = "rikkaTools" }
+lsposed-hiddenapibypass = { module = "org.lsposed.hiddenapibypass:hiddenapibypass", version.ref = "hiddenapibypass" }
+
[plugins]
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
kotlin-ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
+rikka-tools-refine = { id = "dev.rikka.tools.refine", version.ref = "rikkaTools" }