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