Skip to content

Commit ba84ade

Browse files
committed
feat(core): implement app uninstallation support
This commit introduces the ability to uninstall applications directly from the app. It adds the necessary infrastructure in the domain and data layers, specifically for Android, and updates the UI in both the apps list and details screens to support this action. It also improves the downgrade experience by prompting for uninstallation when a version downgrade is detected. - **feat(core)**: Added `uninstall` method to the `Installer` interface. - **feat(android)**: Implemented `uninstall` in `AndroidInstaller` using `ACTION_DELETE` and added `REQUEST_DELETE_PACKAGES` permission to `AndroidManifest.xml`. - **feat(details)**: Added `UninstallApp` action to `DetailsViewModel` and integrated an uninstall button into `SmartInstallButton`. - **feat(details)**: Introduced `ShowDowngradeWarning` event to display an `AlertDialog` when a downgrade is attempted, offering uninstallation as a prerequisite. - **feat(apps)**: Added `OnUninstallApp` action and logic to `AppsViewModel`. - **feat(apps)**: Integrated an uninstallation icon button (trash can) into the app list items in `AppsRoot`. - **refactor(data)**: Simplified update check logic in `InstalledAppsRepositoryImpl` by relying on tag comparison. - **chore(desktop)**: Added a stub for `uninstall` in `DesktopInstaller` as it is currently unsupported.
1 parent 5388d7f commit ba84ade

13 files changed

Lines changed: 222 additions & 41 deletions

File tree

composeApp/src/androidMain/AndroidManifest.xml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
android:name="android.permission.REQUEST_INSTALL_PACKAGES"
1313
tools:ignore="RequestInstallPackagesPolicy" />
1414

15+
<uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES" />
16+
1517
<application
1618
android:name=".app.GithubStoreApp"
1719
android:allowBackup="true"

core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidInstaller.kt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,20 @@ class AndroidInstaller(
140140
}
141141
}
142142

143+
override fun uninstall(packageName: String) {
144+
Logger.d { "Requesting uninstall for: $packageName" }
145+
val intent = Intent(Intent.ACTION_DELETE).apply {
146+
data = "package:$packageName".toUri()
147+
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
148+
}
149+
try {
150+
context.startActivity(intent)
151+
} catch (e: Exception) {
152+
Logger.w { "Failed to start uninstall for $packageName: ${e.message}" }
153+
}
154+
155+
}
156+
143157
override fun isObtainiumInstalled(): Boolean {
144158
return try {
145159
context.packageManager.getPackageInfo("dev.imranr.obtainium.fdroid", 0)

core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -124,14 +124,7 @@ class InstalledAppsRepositoryImpl(
124124
}
125125
val primaryAsset = installer.choosePrimaryAsset(installableAssets)
126126

127-
// Use versionCode comparison when available (more reliable for rooted/downgraded devices)
128-
// Fall back to tag string comparison when versionCode is not available
129-
val isUpdateAvailable = if (app.installedVersionCode > 0L) {
130-
// Compare tags first — if same tag, no update regardless of versionCode
131-
normalizedInstalledTag != normalizedLatestTag
132-
} else {
133-
normalizedInstalledTag != normalizedLatestTag
134-
}
127+
val isUpdateAvailable = normalizedInstalledTag != normalizedLatestTag
135128

136129
Logger.d {
137130
"Update check for ${app.appName}: " +

core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopInstaller.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,11 @@ class DesktopInstaller(
322322
}
323323
}
324324

325+
override fun uninstall(packageName: String) {
326+
// Desktop doesn't have a unified uninstall mechanism
327+
Logger.d { "Uninstall not supported on desktop for: $packageName" }
328+
}
329+
325330
override suspend fun install(filePath: String, extOrMime: String) =
326331
withContext(Dispatchers.IO) {
327332
val file = File(filePath)

core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/Installer.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ interface Installer {
99
suspend fun ensurePermissionsOrThrow(extOrMime: String)
1010

1111
suspend fun install(filePath: String, extOrMime: String)
12+
fun uninstall(packageName: String)
1213

1314
fun isAssetInstallable(assetName: String): Boolean
1415
fun choosePrimaryAsset(assets: List<GithubAsset>): GithubAsset?

feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsAction.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,5 @@ sealed interface AppsAction {
1313
data object OnCheckAllForUpdates : AppsAction
1414
data object OnRefresh : AppsAction
1515
data class OnNavigateToRepo(val repoId: Long) : AppsAction
16+
data class OnUninstallApp(val app: InstalledApp) : AppsAction
1617
}

feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsRoot.kt

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import androidx.compose.material.icons.filled.Close
2727
import androidx.compose.material.icons.filled.Refresh
2828
import androidx.compose.material.icons.filled.Search
2929
import androidx.compose.material.icons.filled.Update
30+
import androidx.compose.material.icons.outlined.DeleteOutline
3031
import androidx.compose.material3.Button
3132
import androidx.compose.material3.ButtonDefaults
3233
import androidx.compose.material3.Card
@@ -301,6 +302,7 @@ fun AppsScreen(
301302
onOpenClick = { onAction(AppsAction.OnOpenApp(appItem.installedApp)) },
302303
onUpdateClick = { onAction(AppsAction.OnUpdateApp(appItem.installedApp)) },
303304
onCancelClick = { onAction(AppsAction.OnCancelUpdate(appItem.installedApp.packageName)) },
305+
onUninstallClick = { onAction(AppsAction.OnUninstallApp(appItem.installedApp)) },
304306
onRepoClick = { onAction(AppsAction.OnNavigateToRepo(appItem.installedApp.repoId)) },
305307
modifier = Modifier.liquefiable(liquidState)
306308
)
@@ -374,6 +376,7 @@ fun AppItemCard(
374376
onOpenClick: () -> Unit,
375377
onUpdateClick: () -> Unit,
376378
onCancelClick: () -> Unit,
379+
onUninstallClick: () -> Unit,
377380
onRepoClick: () -> Unit,
378381
modifier: Modifier = Modifier
379382
) {
@@ -553,6 +556,22 @@ fun AppItemCard(
553556
horizontalArrangement = Arrangement.spacedBy(8.dp),
554557
verticalAlignment = Alignment.CenterVertically
555558
) {
559+
if (!app.isPendingInstall &&
560+
appItem.updateState !is UpdateState.Downloading &&
561+
appItem.updateState !is UpdateState.Installing &&
562+
appItem.updateState !is UpdateState.CheckingUpdate
563+
) {
564+
IconButton(
565+
onClick = onUninstallClick
566+
) {
567+
Icon(
568+
imageVector = Icons.Outlined.DeleteOutline,
569+
contentDescription = stringResource(Res.string.uninstall),
570+
tint = MaterialTheme.colorScheme.error
571+
)
572+
}
573+
}
574+
556575
Button(
557576
onClick = onOpenClick,
558577
modifier = Modifier.weight(1f),

feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,26 @@ class AppsViewModel(
193193
_events.send(AppsEvent.NavigateToRepo(action.repoId))
194194
}
195195
}
196+
197+
is AppsAction.OnUninstallApp -> {
198+
uninstallApp(action.app)
199+
}
200+
}
201+
}
202+
203+
private fun uninstallApp(app: InstalledApp) {
204+
viewModelScope.launch {
205+
try {
206+
installer.uninstall(app.packageName)
207+
logger.debug("Requested uninstall for ${app.packageName}")
208+
} catch (e: Exception) {
209+
logger.error("Failed to request uninstall for ${app.packageName}: ${e.message}")
210+
_events.send(
211+
AppsEvent.ShowError(
212+
getString(Res.string.failed_to_uninstall, app.appName)
213+
)
214+
)
215+
}
196216
}
197217
}
198218

feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsAction.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import zed.rainxch.details.domain.model.ReleaseCategory
77
sealed interface DetailsAction {
88
data object Retry : DetailsAction
99
data object InstallPrimary : DetailsAction
10+
data object UninstallApp : DetailsAction
1011
data class DownloadAsset(
1112
val downloadUrl: String,
1213
val assetName: String,

feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsEvent.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,9 @@ sealed interface DetailsEvent {
44
data class OnOpenRepositoryInApp(val repositoryId: Long) : DetailsEvent
55
data class InstallTrackingFailed(val message: String) : DetailsEvent
66
data class OnMessage(val message: String) : DetailsEvent
7+
data class ShowDowngradeWarning(
8+
val packageName: String,
9+
val currentVersion: String,
10+
val targetVersion: String
11+
) : DetailsEvent
712
}

0 commit comments

Comments
 (0)