diff --git a/composeApp/release/baselineProfiles/0/composeApp-release.dm b/composeApp/release/baselineProfiles/0/composeApp-release.dm index bb492694..07706a1c 100644 Binary files a/composeApp/release/baselineProfiles/0/composeApp-release.dm and b/composeApp/release/baselineProfiles/0/composeApp-release.dm differ diff --git a/composeApp/release/baselineProfiles/1/composeApp-release.dm b/composeApp/release/baselineProfiles/1/composeApp-release.dm index ed8aa107..1ef032f7 100644 Binary files a/composeApp/release/baselineProfiles/1/composeApp-release.dm and b/composeApp/release/baselineProfiles/1/composeApp-release.dm differ diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index 2074744d..a8dd5c0d 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -1,18 +1,16 @@ - + - - - - - - - - - - - + + + + + + - - @@ -47,7 +43,6 @@ android:scheme="githubstore" /> - @@ -72,8 +67,7 @@ android:scheme="https" /> - - + diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidDownloader.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidDownloader.kt index e04e76e0..457e3c87 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidDownloader.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidDownloader.kt @@ -50,7 +50,6 @@ class AndroidDownloader( val request = DownloadManager.Request(url.toUri()).apply { setTitle(safeName) setDescription("Downloading asset") - setNotificationVisibility(DownloadManager.Request.VISIBILITY_HIDDEN) setDestinationInExternalFilesDir(context, null, "ghs_downloads/$safeName") diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidInstaller.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidInstaller.kt index 8c7c56e1..ef2c3345 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidInstaller.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidInstaller.kt @@ -24,6 +24,7 @@ class AndroidInstaller( override fun getApkInfoExtractor(): InstallerInfoExtractor { return installerInfoExtractor } + override fun detectSystemArchitecture(): SystemArchitecture { val arch = Build.SUPPORTED_ABIS.firstOrNull() ?: return SystemArchitecture.UNKNOWN return when { @@ -42,7 +43,10 @@ class AndroidInstaller( return isArchitectureCompatible(name, systemArch) } - private fun isArchitectureCompatible(assetName: String, systemArch: SystemArchitecture): Boolean { + private fun isArchitectureCompatible( + assetName: String, + systemArch: SystemArchitecture + ): Boolean { return AssetArchitectureMatcher.isCompatible(assetName, systemArch) } @@ -57,17 +61,37 @@ class AndroidInstaller( val name = asset.name.lowercase() val archBoost = when (systemArch) { SystemArchitecture.X86_64 -> { - if (AssetArchitectureMatcher.isExactMatch(name, SystemArchitecture.X86_64)) 10000 else 0 + if (AssetArchitectureMatcher.isExactMatch( + name, + SystemArchitecture.X86_64 + ) + ) 10000 else 0 } + SystemArchitecture.AARCH64 -> { - if (AssetArchitectureMatcher.isExactMatch(name, SystemArchitecture.AARCH64)) 10000 else 0 + if (AssetArchitectureMatcher.isExactMatch( + name, + SystemArchitecture.AARCH64 + ) + ) 10000 else 0 } + SystemArchitecture.X86 -> { - if (AssetArchitectureMatcher.isExactMatch(name, SystemArchitecture.X86)) 10000 else 0 + if (AssetArchitectureMatcher.isExactMatch( + name, + SystemArchitecture.X86 + ) + ) 10000 else 0 } + SystemArchitecture.ARM -> { - if (AssetArchitectureMatcher.isExactMatch(name, SystemArchitecture.ARM)) 10000 else 0 + if (AssetArchitectureMatcher.isExactMatch( + name, + SystemArchitecture.ARM + ) + ) 10000 else 0 } + SystemArchitecture.UNKNOWN -> 0 } archBoost + asset.size @@ -80,16 +104,14 @@ class AndroidInstaller( } override suspend fun ensurePermissionsOrThrow(extOrMime: String) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val pm = context.packageManager - if (!pm.canRequestPackageInstalls()) { - val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply { - data = "package:${context.packageName}".toUri() - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - } - context.startActivity(intent) - throw IllegalStateException("Please enable 'Install unknown apps' for this app in Settings and try again.") + val pm = context.packageManager + if (!pm.canRequestPackageInstalls()) { + val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply { + data = "package:${context.packageName}".toUri() + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } + context.startActivity(intent) + throw IllegalStateException("Please enable 'Install unknown apps' for this app in Settings and try again.") } } @@ -158,6 +180,38 @@ class AndroidInstaller( } } + override fun uninstall(packageName: String) { + Logger.d { "Requesting uninstall for: $packageName" } + val intent = Intent(Intent.ACTION_DELETE).apply { + data = "package:$packageName".toUri() + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + try { + context.startActivity(intent) + } catch (e: Exception) { + Logger.w { "Failed to start uninstall for $packageName: ${e.message}" } + } + + } + + override fun openApp(packageName: String): Boolean { + val launchIntent = context.packageManager.getLaunchIntentForPackage(packageName) + return if (launchIntent != null) { + try { + launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(launchIntent) + true + } catch (e: ActivityNotFoundException) { + Logger.w { "Failed to launch $packageName: ${e.message}" } + false + } + + } else { + Logger.w { "No launch intent found for $packageName" } + false + } + } + override fun openInAppManager( filePath: String, onOpenInstaller: () -> Unit diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/PackageEventReceiver.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/PackageEventReceiver.kt new file mode 100644 index 00000000..ff864acc --- /dev/null +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/PackageEventReceiver.kt @@ -0,0 +1,104 @@ +package zed.rainxch.core.data.services + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import co.touchlab.kermit.Logger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import zed.rainxch.core.domain.repository.InstalledAppsRepository +import zed.rainxch.core.domain.system.PackageMonitor + +/** + * Listens to system package install/uninstall/replace broadcasts. + * When a tracked package is installed or updated, it resolves the pending + * install flag and updates version info from the system PackageManager. + * When a tracked package is removed, it deletes the record from the database. + */ +class PackageEventReceiver( + private val installedAppsRepository: InstalledAppsRepository, + private val packageMonitor: PackageMonitor +) : BroadcastReceiver() { + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + override fun onReceive(context: Context?, intent: Intent?) { + val packageName = intent?.data?.schemeSpecificPart ?: return + + Logger.d { "PackageEventReceiver: ${intent.action} for $packageName" } + + when (intent.action) { + Intent.ACTION_PACKAGE_ADDED, + Intent.ACTION_PACKAGE_REPLACED -> { + scope.launch { onPackageInstalled(packageName) } + } + + Intent.ACTION_PACKAGE_FULLY_REMOVED -> { + scope.launch { onPackageRemoved(packageName) } + } + } + } + + private suspend fun onPackageInstalled(packageName: String) { + try { + val app = installedAppsRepository.getAppByPackage(packageName) ?: return + + if (app.isPendingInstall) { + val systemInfo = packageMonitor.getInstalledPackageInfo(packageName) + if (systemInfo != null) { + installedAppsRepository.updateApp( + app.copy( + isPendingInstall = false, + isUpdateAvailable = false, + installedVersionName = systemInfo.versionName, + installedVersionCode = systemInfo.versionCode, + latestVersionName = systemInfo.versionName, + latestVersionCode = systemInfo.versionCode + ) + ) + Logger.i { "Resolved pending install via broadcast: $packageName (v${systemInfo.versionName})" } + } else { + installedAppsRepository.updatePendingStatus(packageName, false) + Logger.i { "Resolved pending install via broadcast (no system info): $packageName" } + } + } else { + val systemInfo = packageMonitor.getInstalledPackageInfo(packageName) + if (systemInfo != null) { + installedAppsRepository.updateApp( + app.copy( + installedVersionName = systemInfo.versionName, + installedVersionCode = systemInfo.versionCode + ) + ) + Logger.d { "Updated version info via broadcast: $packageName (v${systemInfo.versionName})" } + } + } + } catch (e: Exception) { + Logger.e { "PackageEventReceiver error for $packageName: ${e.message}" } + } + } + + private suspend fun onPackageRemoved(packageName: String) { + try { + val app = installedAppsRepository.getAppByPackage(packageName) ?: return + installedAppsRepository.deleteInstalledApp(packageName) + Logger.i { "Removed uninstalled app via broadcast: $packageName" } + } catch (e: Exception) { + Logger.e { "PackageEventReceiver remove error for $packageName: ${e.message}" } + } + } + + companion object { + fun createIntentFilter(): IntentFilter { + return IntentFilter().apply { + addAction(Intent.ACTION_PACKAGE_ADDED) + addAction(Intent.ACTION_PACKAGE_REPLACED) + addAction(Intent.ACTION_PACKAGE_FULLY_REMOVED) + addDataScheme("package") + } + } + } +} diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/dao/InstalledAppDao.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/dao/InstalledAppDao.kt index 214465a9..3a6c434a 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/dao/InstalledAppDao.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/dao/InstalledAppDao.kt @@ -23,6 +23,9 @@ interface InstalledAppDao { @Query("SELECT * FROM installed_apps WHERE repoId = :repoId") suspend fun getAppByRepoId(repoId: Long): InstalledAppEntity? + @Query("SELECT * FROM installed_apps WHERE repoId = :repoId") + fun getAppByRepoIdAsFlow(repoId: Long): Flow + @Query("SELECT COUNT(*) FROM installed_apps WHERE isUpdateAvailable = 1") fun getUpdateCount(): Flow diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt index 2f0e40dc..f49fa14e 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt @@ -65,6 +65,9 @@ class InstalledAppsRepositoryImpl( override suspend fun getAppByRepoId(repoId: Long): InstalledApp? = installedAppsDao.getAppByRepoId(repoId)?.toDomain() + override fun getAppByRepoIdAsFlow(repoId: Long): Flow = + installedAppsDao.getAppByRepoIdAsFlow(repoId).map { it?.toDomain() } + override suspend fun isAppInstalled(repoId: Long): Boolean = installedAppsDao.getAppByRepoId(repoId) != null diff --git a/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopInstaller.kt b/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopInstaller.kt index d182f828..d2e910c1 100644 --- a/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopInstaller.kt +++ b/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopInstaller.kt @@ -56,6 +56,17 @@ class DesktopInstaller( } + override fun uninstall(packageName: String) { + // Desktop doesn't have a unified uninstall mechanism + Logger.d { "Uninstall not supported on desktop for: $packageName" } + } + + override fun openApp(packageName: String): Boolean { + // Desktop apps are launched differently per platform + Logger.d { "Open app not supported on desktop for: $packageName" } + return false + } + override fun isAssetInstallable(assetName: String): Boolean { val name = assetName.lowercase() diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/InstalledAppsRepository.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/InstalledAppsRepository.kt index ac466755..c21dfce2 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/InstalledAppsRepository.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/InstalledAppsRepository.kt @@ -9,6 +9,7 @@ interface InstalledAppsRepository { fun getUpdateCount(): Flow suspend fun getAppByPackage(packageName: String): InstalledApp? suspend fun getAppByRepoId(repoId: Long): InstalledApp? + fun getAppByRepoIdAsFlow(repoId: Long): Flow suspend fun isAppInstalled(repoId: Long): Boolean suspend fun saveInstalledApp(app: InstalledApp) diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/Installer.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/Installer.kt index fbb5c936..ceee0a8d 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/Installer.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/Installer.kt @@ -27,4 +27,8 @@ interface Installer { ) fun getApkInfoExtractor(): InstallerInfoExtractor + + fun uninstall(packageName: String) + + fun openApp(packageName: String): Boolean } \ No newline at end of file diff --git a/core/presentation/src/commonMain/composeResources/values/strings.xml b/core/presentation/src/commonMain/composeResources/values/strings.xml index 45fd37f0..86b001fe 100644 --- a/core/presentation/src/commonMain/composeResources/values/strings.xml +++ b/core/presentation/src/commonMain/composeResources/values/strings.xml @@ -171,6 +171,16 @@ Installing Pending install + + Uninstall + Open + Downgrade requires uninstall + Installing version %1$s requires uninstalling the current version (%2$s) first. Your app data will be lost. + Uninstall first + Install %1$s + Failed to open %1$s + Failed to uninstall %1$s + Open in Obtainium Manage updates automatically diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsAction.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsAction.kt index 1e25b4ce..73327800 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsAction.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsAction.kt @@ -12,5 +12,6 @@ sealed interface AppsAction { data object OnCancelUpdateAll : AppsAction data object OnCheckAllForUpdates : AppsAction data object OnRefresh : AppsAction + data class OnUninstallApp(val app: InstalledApp) : AppsAction data class OnNavigateToRepo(val repoId: Long) : AppsAction } \ No newline at end of file diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsRoot.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsRoot.kt index e19f7dd3..1c0ba136 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsRoot.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsRoot.kt @@ -2,6 +2,7 @@ package zed.rainxch.apps.presentation +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -26,6 +27,7 @@ import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Update +import androidx.compose.material.icons.outlined.DeleteOutline import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card @@ -300,6 +302,7 @@ fun AppsScreen( onOpenClick = { onAction(AppsAction.OnOpenApp(appItem.installedApp)) }, onUpdateClick = { onAction(AppsAction.OnUpdateApp(appItem.installedApp)) }, onCancelClick = { onAction(AppsAction.OnCancelUpdate(appItem.installedApp.packageName)) }, + onUninstallClick = { onAction(AppsAction.OnUninstallApp(appItem.installedApp)) }, onRepoClick = { onAction(AppsAction.OnNavigateToRepo(appItem.installedApp.repoId)) }, modifier = Modifier.liquefiable(liquidState) ) @@ -373,20 +376,22 @@ fun AppItemCard( onOpenClick: () -> Unit, onUpdateClick: () -> Unit, onCancelClick: () -> Unit, + onUninstallClick: () -> Unit, onRepoClick: () -> Unit, modifier: Modifier = Modifier ) { val app = appItem.installedApp Card( - modifier = modifier.fillMaxWidth(), - onClick = onRepoClick + modifier = modifier.fillMaxWidth() ) { Column( modifier = Modifier.padding(16.dp) ) { Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .clickable { onRepoClick() }, horizontalArrangement = Arrangement.spacedBy(12.dp) ) { CoilImage( @@ -548,8 +553,26 @@ fun AppItemCard( Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically ) { + // Uninstall icon button (shown when not pending and not actively updating) + if (!app.isPendingInstall && + appItem.updateState !is UpdateState.Downloading && + appItem.updateState !is UpdateState.Installing && + appItem.updateState !is UpdateState.CheckingUpdate + ) { + IconButton( + onClick = onUninstallClick + ) { + Icon( + imageVector = Icons.Outlined.DeleteOutline, + contentDescription = stringResource(Res.string.uninstall), + tint = MaterialTheme.colorScheme.error + ) + } + } + Button( onClick = onOpenClick, modifier = Modifier.weight(1f), diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt index 878bfb4c..00c92a66 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt @@ -188,6 +188,10 @@ class AppsViewModel( refresh() } + is AppsAction.OnUninstallApp -> { + uninstallApp(action.app) + } + is AppsAction.OnNavigateToRepo -> { viewModelScope.launch { _events.send(AppsEvent.NavigateToRepo(action.repoId)) @@ -196,6 +200,22 @@ class AppsViewModel( } } + private fun uninstallApp(app: InstalledApp) { + viewModelScope.launch { + try { + installer.uninstall(app.packageName) + logger.debug("Requested uninstall for ${app.packageName}") + } catch (e: Exception) { + logger.error("Failed to request uninstall for ${app.packageName}: ${e.message}") + _events.send( + AppsEvent.ShowError( + getString(Res.string.failed_to_uninstall, app.appName) + ) + ) + } + } + } + private fun openApp(app: InstalledApp) { viewModelScope.launch { try { @@ -207,7 +227,7 @@ class AppsViewModel( AppsEvent.ShowError( getString( Res.string.cannot_launch, - arrayOf(app.appName) + app.appName ) ) ) @@ -220,7 +240,7 @@ class AppsViewModel( AppsEvent.ShowError( getString( Res.string.failed_to_open, - arrayOf(app.appName) + app.appName ) ) ) @@ -284,7 +304,8 @@ class AppsViewModel( installer.getApkInfoExtractor().extractPackageInfo(existingPath) val normalizedExisting = apkInfo?.versionName?.removePrefix("v")?.removePrefix("V") ?: "" - val normalizedLatest = latestVersion.removePrefix("v").removePrefix("V") + val normalizedLatest = + latestVersion.removePrefix("v").removePrefix("V") if (normalizedExisting != normalizedLatest) { val deleted = file.delete() logger.debug("Deleted mismatched existing file ($normalizedExisting != $normalizedLatest): $deleted") @@ -371,7 +392,7 @@ class AppsViewModel( AppsEvent.ShowError( getString( Res.string.failed_to_update, - arrayOf(app.appName, e.message ?: "") + app.appName, e.message ?: "" ) ) ) diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsAction.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsAction.kt index fa4677d6..10064610 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsAction.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsAction.kt @@ -28,6 +28,8 @@ sealed interface DetailsAction { data object OnToggleFavorite : DetailsAction data object CheckForUpdates : DetailsAction data object UpdateApp : DetailsAction + data object UninstallApp : DetailsAction + data object OpenApp : DetailsAction data class OnMessage(val messageText: StringResource) : DetailsAction diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsEvent.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsEvent.kt index c559f81a..e940dfd4 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsEvent.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsEvent.kt @@ -4,4 +4,9 @@ sealed interface DetailsEvent { data class OnOpenRepositoryInApp(val repositoryId: Long) : DetailsEvent data class InstallTrackingFailed(val message: String) : DetailsEvent data class OnMessage(val message: String) : DetailsEvent + data class ShowDowngradeWarning( + val packageName: String, + val currentVersion: String, + val targetVersion: String + ) : DetailsEvent } \ No newline at end of file diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsRoot.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsRoot.kt index 396b922e..d2ef6fb1 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsRoot.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsRoot.kt @@ -20,6 +20,7 @@ import androidx.compose.material.icons.filled.FavoriteBorder import androidx.compose.material.icons.filled.OpenInBrowser import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.filled.StarBorder +import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularWavyProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @@ -30,13 +31,17 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue +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.shadow @@ -76,6 +81,9 @@ fun DetailsRoot( val state by viewModel.state.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() } val coroutineScope = rememberCoroutineScope() + var downgradeWarning by remember { + mutableStateOf(null) + } ObserveAsEvents(viewModel.events) { event -> when (event) { @@ -92,9 +100,49 @@ fun DetailsRoot( snackbarHostState.showSnackbar(event.message) } } + + is DetailsEvent.ShowDowngradeWarning -> { + downgradeWarning = event + } } } + downgradeWarning?.let { warning -> + AlertDialog( + onDismissRequest = { downgradeWarning = null }, + title = { + Text(text = stringResource(Res.string.downgrade_requires_uninstall)) + }, + text = { + Text( + text = stringResource( + Res.string.downgrade_warning_message, + warning.targetVersion, + warning.currentVersion + ) + ) + }, + confirmButton = { + TextButton( + onClick = { + downgradeWarning = null + viewModel.onAction(DetailsAction.UninstallApp) + } + ) { + Text( + text = stringResource(Res.string.uninstall_first), + color = MaterialTheme.colorScheme.error + ) + } + }, + dismissButton = { + TextButton(onClick = { downgradeWarning = null }) { + Text(text = stringResource(Res.string.cancel)) + } + } + ) + } + DetailsScreen( state = state, snackbarHostState = snackbarHostState, diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt index 29628fdf..00a269dd 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn @@ -271,6 +272,8 @@ class DetailsViewModel( isAppManagerEnabled = isAppManagerEnabled, installedApp = installedApp, ) + + observeInstalledApp(repo.id) } catch (e: RateLimitException) { logger.error("Rate limited: ${e.message}") _state.value = _state.value.copy( @@ -287,6 +290,16 @@ class DetailsViewModel( } } + private fun observeInstalledApp(repoId: Long) { + viewModelScope.launch { + installedAppsRepository.getAppByRepoIdAsFlow(repoId) + .distinctUntilChanged() + .collect { app -> + _state.update { it.copy(installedApp = app) } + } + } + } + @OptIn(ExperimentalTime::class) fun onAction(action: DetailsAction) { when (action) { @@ -298,7 +311,39 @@ class DetailsViewModel( DetailsAction.InstallPrimary -> { val primary = _state.value.primaryAsset val release = _state.value.selectedRelease + val installedApp = _state.value.installedApp + if (primary != null && release != null) { + if (installedApp != null && + !installedApp.isPendingInstall && + !installedApp.isUpdateAvailable && + normalizeVersion(release.tagName) != normalizeVersion(installedApp.installedVersion) && + platform == Platform.ANDROID + ) { + val isConfirmedDowngrade = if ( + normalizeVersion(release.tagName) == normalizeVersion(installedApp.latestVersion) && + (installedApp.latestVersionCode ?: 0L) > 0 + ) { + installedApp.installedVersionCode > (installedApp.latestVersionCode + ?: 0L) + } else { + true + } + + if (isConfirmedDowngrade) { + viewModelScope.launch { + _events.send( + DetailsEvent.ShowDowngradeWarning( + packageName = installedApp.packageName, + currentVersion = installedApp.installedVersion, + targetVersion = release.tagName + ) + ) + } + return + } + } + installAsset( downloadUrl = primary.downloadUrl, assetName = primary.name, @@ -433,6 +478,41 @@ class DetailsViewModel( } } + DetailsAction.UninstallApp -> { + val installedApp = _state.value.installedApp ?: return + logger.debug("Uninstalling app: ${installedApp.packageName}") + viewModelScope.launch { + try { + installer.uninstall(installedApp.packageName) + } catch (e: Exception) { + logger.error("Failed to request uninstall for ${installedApp.packageName}: ${e.message}") + _events.send( + DetailsEvent.OnMessage( + getString(Res.string.failed_to_uninstall, installedApp.packageName) + ) + ) + } + } + + } + + DetailsAction.OpenApp -> { + val installedApp = _state.value.installedApp ?: return + val launched = installer.openApp(installedApp.packageName) + if (!launched) { + viewModelScope.launch { + _events.send( + DetailsEvent.OnMessage( + getString( + Res.string.failed_to_open_app, + installedApp.appName + ) + ) + ) + } + } + } + DetailsAction.OpenRepoInBrowser -> { _state.value.repository?.htmlUrl?.let { helper.openUrl(url = it) @@ -910,6 +990,10 @@ class DetailsViewModel( } } + private fun normalizeVersion(version: String?): String { + return version?.removePrefix("v")?.removePrefix("V")?.trim() ?: "" + } + private companion object { const val OBTAINIUM_REPO_ID: Long = 523534328 const val APP_MANAGER_REPO_ID: Long = 268006778 diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/SmartInstallButton.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/SmartInstallButton.kt index 7c36fdd6..b1fec6d4 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/SmartInstallButton.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/SmartInstallButton.kt @@ -14,8 +14,10 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.OpenInNew import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.Update import androidx.compose.material3.CardDefaults @@ -60,7 +62,14 @@ fun SmartInstallButton( val installedApp = state.installedApp val isInstalled = installedApp != null && !installedApp.isPendingInstall - val isUpdateAvailable = installedApp?.isUpdateAvailable == true && !installedApp.isPendingInstall + val isUpdateAvailable = + installedApp?.isUpdateAvailable == true && !installedApp.isPendingInstall + + val isSameVersionInstalled = isInstalled && + installedApp != null && + normalizeVersion(installedApp.installedVersion) == normalizeVersion( + state.selectedRelease?.tagName ?: "" + ) val enabled = remember(primaryAsset, isDownloading, isInstalling) { primaryAsset != null && !isDownloading && !isInstalling @@ -68,6 +77,99 @@ fun SmartInstallButton( val isActiveDownload = state.isDownloading || state.downloadStage != DownloadStage.IDLE + // When same version is installed, show Open + Uninstall (Play Store style) + if (isSameVersionInstalled && !isActiveDownload) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + // Uninstall button + ElevatedCard( + onClick = { onAction(DetailsAction.UninstallApp) }, + modifier = Modifier + .weight(1f) + .height(52.dp) + .liquefiable(liquidState), + colors = CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ), + shape = RoundedCornerShape( + topStart = 24.dp, + bottomStart = 24.dp, + topEnd = 6.dp, + bottomEnd = 6.dp + ) + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onErrorContainer + ) + Text( + text = stringResource(Res.string.uninstall), + color = MaterialTheme.colorScheme.onErrorContainer, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleMedium + ) + } + } + } + + // Open button + ElevatedCard( + modifier = Modifier + .weight(1f) + .height(52.dp) + .clickable { onAction(DetailsAction.OpenApp) } + .liquefiable(liquidState), + colors = CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.primary + ), + shape = RoundedCornerShape( + topStart = 6.dp, + bottomStart = 6.dp, + topEnd = 24.dp, + bottomEnd = 24.dp + ) + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.OpenInNew, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onPrimary + ) + Text( + text = stringResource(Res.string.open_app), + color = MaterialTheme.colorScheme.onPrimary, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleMedium + ) + } + } + } + } + return + } + + // Regular install/update button for all other cases val buttonColor = when { !enabled && !isActiveDownload -> MaterialTheme.colorScheme.surfaceContainer isUpdateAvailable -> MaterialTheme.colorScheme.tertiary @@ -77,16 +179,17 @@ fun SmartInstallButton( val buttonText = when { !enabled && primaryAsset == null -> stringResource(Res.string.not_available) - installedApp != null && installedApp.installedVersion != state.selectedRelease?.tagName -> stringResource( - Res.string.update_app - ) - isUpdateAvailable -> stringResource( Res.string.update_to_version, installedApp.latestVersion.toString() ) - isInstalled -> stringResource(Res.string.reinstall) + isInstalled && installedApp.installedVersion != state.selectedRelease?.tagName -> + stringResource( + Res.string.install_version, + state.selectedRelease?.tagName ?: "" + ) + else -> stringResource(Res.string.install_latest) } @@ -342,6 +445,10 @@ fun SmartInstallButton( } } +private fun normalizeVersion(version: String): String { + return version.removePrefix("v").removePrefix("V").trim() +} + @Preview @Composable fun SmartInstallButtonDownloadingPreview() { @@ -369,4 +476,4 @@ fun SmartInstallButtonDownloadingPreview() { downloadProgressPercent = 45 ) ) -} \ No newline at end of file +}