diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index 6aff64147..75735631a 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -137,6 +137,15 @@ + + + + + + + ().start(get()) + } + private fun createNotificationChannels() { val notificationManager = getSystemService(NotificationManager::class.java) @@ -61,6 +67,17 @@ class GithubStoreApp : Application() { setShowBadge(false) } notificationManager.createNotificationChannel(serviceChannel) + + val downloadsChannel = + NotificationChannel( + DOWNLOADS_CHANNEL_ID, + "Downloads", + NotificationManager.IMPORTANCE_LOW, + ).apply { + description = "Live progress for in-flight downloads" + setShowBadge(false) + } + notificationManager.createNotificationChannel(downloadsChannel) } private fun registerPackageEventReceiver() { @@ -167,5 +184,6 @@ class GithubStoreApp : Application() { "https://raw.githubusercontent.com/OpenHub-Store/GitHub-Store/refs/heads/main/media-resources/app_icon.png" const val UPDATES_CHANNEL_ID = "app_updates" const val UPDATE_SERVICE_CHANNEL_ID = "update_service" + const val DOWNLOADS_CHANNEL_ID = "app_downloads" } } diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/di/PlatformModule.android.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/di/PlatformModule.android.kt index f8984db18..5abdc9f29 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/di/PlatformModule.android.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/di/PlatformModule.android.kt @@ -9,6 +9,7 @@ import zed.rainxch.core.data.local.data_store.createDataStore import zed.rainxch.core.data.local.db.AppDatabase import zed.rainxch.core.data.local.db.initDatabase import zed.rainxch.core.data.services.AndroidDownloader +import zed.rainxch.core.data.services.AndroidDownloadProgressNotifier import zed.rainxch.core.data.services.AndroidFileLocationsProvider import zed.rainxch.core.data.services.AndroidInstaller import zed.rainxch.core.data.services.AndroidInstallerInfoExtractor @@ -16,6 +17,7 @@ import zed.rainxch.core.data.services.AndroidLocalizationManager import zed.rainxch.core.data.services.AndroidPackageMonitor import zed.rainxch.core.data.services.AndroidPendingInstallNotifier import zed.rainxch.core.data.services.AndroidUpdateScheduleManager +import zed.rainxch.core.data.services.DownloadNotificationObserver import zed.rainxch.core.data.services.FileLocationsProvider import zed.rainxch.core.data.services.LocalizationManager import zed.rainxch.core.data.services.shizuku.AndroidInstallerStatusProvider @@ -26,6 +28,8 @@ import zed.rainxch.core.data.utils.AndroidBrowserHelper import zed.rainxch.core.data.utils.AndroidClipboardHelper import zed.rainxch.core.data.utils.AndroidShareManager import zed.rainxch.core.domain.network.Downloader +import zed.rainxch.core.domain.system.DownloadOrchestrator +import zed.rainxch.core.domain.system.DownloadProgressNotifier import zed.rainxch.core.domain.system.Installer import zed.rainxch.core.domain.system.InstallerStatusProvider import zed.rainxch.core.domain.system.PackageMonitor @@ -89,6 +93,17 @@ actual val corePlatformModule = AndroidPendingInstallNotifier(context = androidContext()) } + single { + AndroidDownloadProgressNotifier(context = androidContext()) + } + + single { + DownloadNotificationObserver( + orchestrator = get(), + notifier = get(), + ) + } + single { AndroidPackageMonitor(androidContext()) } diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidDownloadProgressNotifier.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidDownloadProgressNotifier.kt new file mode 100644 index 000000000..dfa6e6ed2 --- /dev/null +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidDownloadProgressNotifier.kt @@ -0,0 +1,150 @@ +package zed.rainxch.core.data.services + +import android.Manifest +import android.annotation.SuppressLint +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import zed.rainxch.core.domain.system.DownloadProgressNotifier + +/** + * Android implementation of [DownloadProgressNotifier]. + * + * # Behaviour + * + * - Ongoing notification on channel `app_downloads` (low-importance — + * no heads-up, no sound; long downloads shouldn't be noisy). + * - `setOnlyAlertOnce(true)` so repeated tick updates don't buzz. + * - `setOngoing(true)` prevents swipe-dismiss while downloading; + * cleared explicitly on completion / cancellation / failure. + * - Indeterminate spinner when the server omitted `Content-Length`. + * - "Cancel" action broadcasts [DownloadCancelReceiver.ACTION_CANCEL] + * with the package name; the receiver resolves the orchestrator + * via Koin and calls `cancel(packageName)`. + * + * # Permission gating + * + * POST_NOTIFICATIONS on Android 13+. If denied, silently skip — the + * orchestrator's in-app UI still reflects progress. + */ +class AndroidDownloadProgressNotifier( + private val context: Context, +) : DownloadProgressNotifier { + @SuppressLint("MissingPermission") + override fun notifyProgress( + packageName: String, + appName: String, + versionTag: String, + percent: Int?, + bytesDownloaded: Long, + totalBytes: Long?, + ) { + if (!hasNotificationPermission()) return + + // Encode the package in the Intent's data URI so PendingIntent + // identity (driven by Intent.filterEquals, which considers `data` + // but not extras) is uniquely per-package. Relying on + // packageName.hashCode() as requestCode alone risks a collision + // that would have FLAG_UPDATE_CURRENT overwrite another + // download's cancel extras. + val cancelIntent = + Intent(context, DownloadCancelReceiver::class.java).apply { + action = DownloadCancelReceiver.ACTION_CANCEL + data = Uri.parse("githubstore-cancel://$packageName") + setPackage(context.packageName) + putExtra(DownloadCancelReceiver.EXTRA_PACKAGE_NAME, packageName) + } + val cancelPendingIntent = + PendingIntent.getBroadcast( + context, + packageName.hashCode(), + cancelIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + + val progressText = formatProgressText(versionTag, bytesDownloaded, totalBytes) + val indeterminate = percent == null + + val builder = + NotificationCompat + .Builder(context, DOWNLOADS_CHANNEL_ID) + .setSmallIcon(android.R.drawable.stat_sys_download) + .setContentTitle(appName) + .setContentText(progressText) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setProgress(100, percent ?: 0, indeterminate) + .addAction( + NotificationCompat.Action.Builder( + android.R.drawable.ic_menu_close_clear_cancel, + CANCEL_LABEL, + cancelPendingIntent, + ).build(), + ) + + NotificationManagerCompat + .from(context) + .notify(notificationIdFor(packageName), builder.build()) + } + + override fun clearProgress(packageName: String) { + NotificationManagerCompat + .from(context) + .cancel(notificationIdFor(packageName)) + } + + private fun hasNotificationPermission(): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return true + return ContextCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS, + ) == PackageManager.PERMISSION_GRANTED + } + + /** + * Stable id in a range disjoint from the pending-install notifier + * (2000..2FFFFFF) and worker ids (1001..1005). Hash collisions are + * acceptable — worst case, two downloads share a single row. + */ + private fun notificationIdFor(packageName: String): Int = + NOTIFICATION_ID_BASE + (packageName.hashCode() and 0x00FFFFFF) + + private fun formatProgressText( + versionTag: String, + bytesDownloaded: Long, + totalBytes: Long?, + ): String { + val downloaded = formatBytes(bytesDownloaded) + val total = totalBytes?.let { formatBytes(it) } + return if (total != null) { + "$versionTag · $downloaded / $total" + } else { + "$versionTag · $downloaded" + } + } + + private fun formatBytes(bytes: Long): String { + if (bytes < 1024) return "$bytes B" + val kb = bytes / 1024.0 + if (kb < 1024) return "%.1f KB".format(kb) + val mb = kb / 1024.0 + if (mb < 1024) return "%.1f MB".format(mb) + val gb = mb / 1024.0 + return "%.2f GB".format(gb) + } + + private companion object { + const val DOWNLOADS_CHANNEL_ID = "app_downloads" + const val CANCEL_LABEL = "Cancel" + + // Disjoint from PendingInstall (2000..) and workers (1001..). + const val NOTIFICATION_ID_BASE = 3000 + } +} diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/DownloadCancelReceiver.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/DownloadCancelReceiver.kt new file mode 100644 index 000000000..d3f876cc7 --- /dev/null +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/DownloadCancelReceiver.kt @@ -0,0 +1,64 @@ +package zed.rainxch.core.data.services + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import co.touchlab.kermit.Logger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import org.koin.core.context.GlobalContext +import zed.rainxch.core.domain.system.DownloadOrchestrator + +/** + * Receives the "Cancel" action fired by the download progress + * notification (see [AndroidDownloadProgressNotifier]) and routes it to + * [DownloadOrchestrator.cancel]. + * + * Declared in the manifest so it fires even when the app is in the + * background and the process has been trimmed — static registration is + * what Android guarantees survival for post-notification callbacks. + * + * Koin's [GlobalContext] is used to resolve the orchestrator and the + * application-scoped [CoroutineScope] because a manifest-registered + * receiver has no injection point; the orchestrator is already a + * singleton so `GlobalContext.get()` returns the same instance the rest + * of the app uses. + */ +class DownloadCancelReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action != ACTION_CANCEL) return + val packageName = intent.getStringExtra(EXTRA_PACKAGE_NAME).orEmpty() + if (packageName.isBlank()) { + Logger.w { "DownloadCancelReceiver: missing package name extra" } + return + } + + // goAsync keeps the BroadcastReceiver alive long enough for the + // suspend call to complete. Without it, the receiver returns as + // soon as onReceive exits and the coroutine may be killed. + val pending = goAsync() + val koin = GlobalContext.getOrNull() + if (koin == null) { + Logger.w { "DownloadCancelReceiver: Koin not initialized, ignoring cancel for $packageName" } + pending.finish() + return + } + + val orchestrator = koin.get() + val scope = koin.get() + scope.launch { + try { + orchestrator.cancel(packageName) + } catch (t: Throwable) { + Logger.e(t) { "DownloadCancelReceiver: cancel failed for $packageName" } + } finally { + pending.finish() + } + } + } + + companion object { + const val ACTION_CANCEL = "zed.rainxch.githubstore.action.CANCEL_DOWNLOAD" + const val EXTRA_PACKAGE_NAME = "zed.rainxch.githubstore.extra.PACKAGE_NAME" + } +} diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/DownloadNotificationObserver.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/DownloadNotificationObserver.kt new file mode 100644 index 000000000..1c2d41017 --- /dev/null +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/DownloadNotificationObserver.kt @@ -0,0 +1,157 @@ +package zed.rainxch.core.data.services + +import android.os.SystemClock +import co.touchlab.kermit.Logger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import zed.rainxch.core.domain.system.DownloadOrchestrator +import zed.rainxch.core.domain.system.DownloadProgressNotifier +import zed.rainxch.core.domain.system.DownloadStage +import zed.rainxch.core.domain.system.OrchestratedDownload + +/** + * Single long-lived subscriber that translates + * [DownloadOrchestrator.downloads] state transitions into calls on + * [DownloadProgressNotifier]. + * + * # Why it's its own class + * + * The orchestrator stays platform-agnostic (common code, no Android + * imports) and doesn't know about notifications. The observer lives on + * `androidMain` alongside the Android notifier and is only started from + * [zed.rainxch.githubstore.app.GithubStoreApp], which means the whole + * feature is Android-only without any platform branches in shared code. + * + * # Transition rules + * + * - `Queued`, `Downloading` → post / update progress notification. + * - Anything else (`Installing`, `AwaitingInstall`, `Completed`, + * `Cancelled`, `Failed`) or entry removal → clear. `AwaitingInstall` + * is owned by [zed.rainxch.core.domain.system.PendingInstallNotifier] + * which posts its own "ready to install" row. + * + * # Throttling + * + * The orchestrator emits on every ~8KB chunk (hundreds of emissions + * per second on a fast link). Android silently drops notification + * updates posted faster than ~200ms for the same id, and every + * `NotificationManagerCompat.notify` is a Binder round-trip, so + * letting every emission through both wastes CPU and produces a stuck + * progress bar that jumps at the end. + * + * We coalesce in-stage ticks to at most one post per + * [PROGRESS_UPDATE_INTERVAL_MS] per package, but always flush + * immediately on stage transitions (`Queued → Downloading`, + * `Downloading → Completed`, etc.) and on 100%-percent emissions so + * the final frame is never skipped. + * + * # Lifecycle + * + * Started once from the Application's `onCreate` via [start], collected + * on the app-scoped coroutine scope (same one Koin provides). No + * explicit stop — the process going away ends the flow. + */ +class DownloadNotificationObserver( + private val orchestrator: DownloadOrchestrator, + private val notifier: DownloadProgressNotifier, +) { + @Volatile + private var job: Job? = null + + private val lastStages = mutableMapOf() + private val lastNotifiedAt = mutableMapOf() + + fun start(scope: CoroutineScope) { + if (job?.isActive == true) return + job = + scope.launch { + try { + orchestrator.downloads.collect { snapshot -> + try { + reconcile(snapshot) + } catch (t: Throwable) { + // Never let a NotificationManager hiccup + // collapse the whole flow — progress + // notifications are best-effort. + Logger.w(t) { "DownloadNotificationObserver: reconcile failed, continuing" } + } + } + } finally { + // Reset so a subsequent start() on this process + // (e.g. after the caller's scope restarts) can + // resubscribe instead of silently no-op'ing on the + // `job?.isActive == true` guard. + job = null + } + } + } + + private fun reconcile(snapshot: Map) { + // Clear notifications for entries that vanished from the map + // (e.g. dismissed after Completed, or cleared on Cancelled). + val removed = lastStages.keys - snapshot.keys + for (pkg in removed) { + clearProgressSafely(pkg) + lastStages.remove(pkg) + lastNotifiedAt.remove(pkg) + } + + for ((pkg, entry) in snapshot) { + val previous = lastStages[pkg] + val stageChanged = previous != entry.stage + when (entry.stage) { + DownloadStage.Queued, DownloadStage.Downloading -> { + val now = SystemClock.uptimeMillis() + val last = lastNotifiedAt[pkg] ?: 0L + val shouldPost = + stageChanged || + entry.progressPercent == 100 || + (now - last) >= PROGRESS_UPDATE_INTERVAL_MS + if (shouldPost) { + try { + notifier.notifyProgress( + packageName = pkg, + appName = entry.displayAppName, + versionTag = entry.releaseTag.ifBlank { entry.assetName }, + percent = entry.progressPercent, + bytesDownloaded = entry.bytesDownloaded, + totalBytes = entry.totalBytes, + ) + lastNotifiedAt[pkg] = now + } catch (t: Throwable) { + Logger.w(t) { "DownloadNotificationObserver: notifyProgress failed for $pkg" } + } + } + } + + DownloadStage.Installing, + DownloadStage.AwaitingInstall, + DownloadStage.Completed, + DownloadStage.Cancelled, + DownloadStage.Failed, + -> { + if (previous == DownloadStage.Queued || previous == DownloadStage.Downloading) { + clearProgressSafely(pkg) + lastNotifiedAt.remove(pkg) + } + } + } + lastStages[pkg] = entry.stage + } + } + + private fun clearProgressSafely(pkg: String) { + try { + notifier.clearProgress(pkg) + } catch (t: Throwable) { + Logger.w(t) { "DownloadNotificationObserver: clearProgress failed for $pkg" } + } + } + + private companion object { + // Comfortably above Android's ~200ms internal drop threshold + // while still feeling live to the eye. + const val PROGRESS_UPDATE_INTERVAL_MS = 400L + } +} diff --git a/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/di/PlatformModule.jvm.kt b/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/di/PlatformModule.jvm.kt index 6aa6f1f2e..70ccaaa11 100644 --- a/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/di/PlatformModule.jvm.kt +++ b/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/di/PlatformModule.jvm.kt @@ -11,6 +11,7 @@ import zed.rainxch.core.data.utils.DesktopAppLauncher import zed.rainxch.core.data.utils.DesktopBrowserHelper import zed.rainxch.core.data.utils.DesktopClipboardHelper import zed.rainxch.core.data.services.DesktopDownloader +import zed.rainxch.core.data.services.DesktopDownloadProgressNotifier import zed.rainxch.core.data.services.DesktopFileLocationsProvider import zed.rainxch.core.data.services.DesktopInstaller import zed.rainxch.core.data.services.DesktopLocalizationManager @@ -18,6 +19,7 @@ import zed.rainxch.core.data.services.DesktopPackageMonitor import zed.rainxch.core.data.services.DesktopPendingInstallNotifier import zed.rainxch.core.data.services.DesktopUpdateScheduleManager import zed.rainxch.core.data.services.FileLocationsProvider +import zed.rainxch.core.domain.system.DownloadProgressNotifier import zed.rainxch.core.domain.system.Installer import zed.rainxch.core.domain.system.InstallerStatusProvider import zed.rainxch.core.domain.system.PendingInstallNotifier @@ -104,4 +106,8 @@ actual val corePlatformModule = module { single { DesktopPendingInstallNotifier() } + + single { + DesktopDownloadProgressNotifier() + } } \ No newline at end of file diff --git a/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopDownloadProgressNotifier.kt b/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopDownloadProgressNotifier.kt new file mode 100644 index 000000000..af304542b --- /dev/null +++ b/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopDownloadProgressNotifier.kt @@ -0,0 +1,21 @@ +package zed.rainxch.core.data.services + +import zed.rainxch.core.domain.system.DownloadProgressNotifier + +/** + * Desktop has no system-shade equivalent that matches the Android + * download-notification UX, so this stays a no-op. The orchestrator + * still calls in unconditionally, avoiding a platform branch. + */ +class DesktopDownloadProgressNotifier : DownloadProgressNotifier { + override fun notifyProgress( + packageName: String, + appName: String, + versionTag: String, + percent: Int?, + bytesDownloaded: Long, + totalBytes: Long?, + ) = Unit + + override fun clearProgress(packageName: String) = Unit +} diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/InstalledApp.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/InstalledApp.kt index b905ae535..e0d72ee78 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/InstalledApp.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/InstalledApp.kt @@ -113,3 +113,21 @@ data class InstalledApp( */ val pendingInstallAssetName: String? = null, ) + +/** + * True when the app actually exists on device. A row with + * [InstalledApp.isPendingInstall] set means the bytes are parked on disk + * but the system install has not completed (or failed) — callers that + * surface an "Installed" badge must treat that case as *not* installed, + * otherwise the Details screen (which checks `isPendingInstall`) and the + * Home/Search cards drift out of sync after a failed install. + */ +fun InstalledApp?.isReallyInstalled(): Boolean = this != null && !this.isPendingInstall + +/** + * True when a genuine update is pending install — mirrors the check + * [zed.rainxch.details.presentation.components.SmartInstallButton] does + * so non-Details surfaces render the same state machine. + */ +fun InstalledApp?.hasActualUpdate(): Boolean = + this != null && this.isUpdateAvailable && !this.isPendingInstall diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/DownloadProgressNotifier.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/DownloadProgressNotifier.kt new file mode 100644 index 000000000..1709e08ad --- /dev/null +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/DownloadProgressNotifier.kt @@ -0,0 +1,53 @@ +package zed.rainxch.core.domain.system + +/** + * Surfaces live download progress in the system notification shade so + * users can track downloads while the app is in the background (see + * GitHub-Store#373). + * + * Lifecycle: [DownloadOrchestrator] is the single source of truth for + * download state. A platform observer subscribes to + * [DownloadOrchestrator.downloads] and calls [notifyProgress] on every + * `Queued` / `Downloading` emission, and [clearProgress] on any terminal + * or install-stage transition. The orchestrator itself does not know + * about this notifier — wiring is one-way. + * + * Platform contracts: + * - **Android**: persistent ongoing notification with progress bar and + * a "Cancel" action that broadcasts back to + * `DownloadCancelReceiver`. Channel id `app_downloads`. + * - **JVM/Desktop**: no-op. Desktop downloads happen with the window + * visible and installers complete synchronously via the OS dialog; + * there is no equivalent of the Android notification shade to target. + */ +interface DownloadProgressNotifier { + /** + * Posts or updates the progress notification for [packageName]. + * Safe to call on every progress tick — the Android impl uses + * `setOnlyAlertOnce(true)` so the notification updates silently. + * + * @param packageName Stable key; also drives the notification id. + * @param appName User-visible title. + * @param versionTag Release tag shown alongside byte counts. + * @param percent 0..100, or `null` when the server did not send + * a `Content-Length` header (indeterminate spinner). + * @param bytesDownloaded Bytes received so far — shown in the + * notification's content text. + * @param totalBytes Expected total, or `null` if unknown. + */ + fun notifyProgress( + packageName: String, + appName: String, + versionTag: String, + percent: Int?, + bytesDownloaded: Long, + totalBytes: Long?, + ) + + /** + * Dismisses the progress notification for [packageName]. Called on + * any transition out of `Queued` / `Downloading` — including + * `AwaitingInstall`, which is owned by [PendingInstallNotifier]. + */ + fun clearProgress(packageName: String) +} diff --git a/feature/dev-profile/data/src/commonMain/kotlin/zed/rainxch/devprofile/data/repository/DeveloperProfileRepositoryImpl.kt b/feature/dev-profile/data/src/commonMain/kotlin/zed/rainxch/devprofile/data/repository/DeveloperProfileRepositoryImpl.kt index 34702c7e6..04692c696 100644 --- a/feature/dev-profile/data/src/commonMain/kotlin/zed/rainxch/devprofile/data/repository/DeveloperProfileRepositoryImpl.kt +++ b/feature/dev-profile/data/src/commonMain/kotlin/zed/rainxch/devprofile/data/repository/DeveloperProfileRepositoryImpl.kt @@ -140,7 +140,12 @@ class DeveloperProfileRepositoryImpl( return repo.toDomain( hasReleases = hasReleases, hasInstallableAssets = hasInstallableAssets, - isInstalled = installedApp != null, + // Treat a row as installed only if the actual install + // completed; a parked-download row left by a failed install + // would otherwise leak "Installed" into the UI (see + // `InstalledApp.isReallyInstalled` for the domain-model + // equivalent of this check). + isInstalled = installedApp != null && !installedApp.isPendingInstall, isFavorite = isFavorite, latestVersion = latestVersion, ) diff --git a/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeRoot.kt b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeRoot.kt index 3876eb0fe..39a747b3f 100644 --- a/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeRoot.kt +++ b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeRoot.kt @@ -89,8 +89,6 @@ import zed.rainxch.core.presentation.locals.LocalScrollbarEnabled import zed.rainxch.core.presentation.theme.GithubStoreTheme import zed.rainxch.core.presentation.utils.ObserveAsEvents import zed.rainxch.core.presentation.utils.arrowKeyScroll -import zed.rainxch.core.presentation.utils.isScrollingUp -import zed.rainxch.core.presentation.utils.arrowKeyScroll import zed.rainxch.core.presentation.utils.toIcons import zed.rainxch.core.presentation.utils.toLabel import zed.rainxch.githubstore.core.presentation.res.* diff --git a/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeViewModel.kt b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeViewModel.kt index c96328c0a..ea7e6f633 100644 --- a/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeViewModel.kt +++ b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeViewModel.kt @@ -20,6 +20,8 @@ import org.jetbrains.compose.resources.getString import zed.rainxch.core.domain.logging.GitHubStoreLogger import zed.rainxch.core.domain.model.DiscoveryPlatform import zed.rainxch.core.domain.model.Platform +import zed.rainxch.core.domain.model.hasActualUpdate +import zed.rainxch.core.domain.model.isReallyInstalled import zed.rainxch.core.domain.repository.FavouritesRepository import zed.rainxch.core.domain.repository.InstalledAppsRepository import zed.rainxch.core.domain.repository.SeenReposRepository @@ -111,11 +113,11 @@ class HomeViewModel( .map { homeRepo -> val app = installedMap[homeRepo.repository.id] homeRepo.copy( - isInstalled = app != null, - isUpdateAvailable = app?.isUpdateAvailable ?: false, + isInstalled = app.isReallyInstalled(), + isUpdateAvailable = app.hasActualUpdate(), ) }.toImmutableList(), - isUpdateAvailable = installedMap.any { it.value.isUpdateAvailable }, + isUpdateAvailable = installedMap.values.any { it.hasActualUpdate() }, ) } } @@ -361,11 +363,11 @@ class HomeViewModel( val starred = starredReposMap[repo.id] DiscoveryRepositoryUi( - isInstalled = app != null, + isInstalled = app.isReallyInstalled(), isFavourite = favourite != null, isStarred = starred != null, isSeen = repo.id in seenIds, - isUpdateAvailable = app?.isUpdateAvailable ?: false, + isUpdateAvailable = app.hasActualUpdate(), repository = repo.toUi(), ) } diff --git a/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchViewModel.kt b/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchViewModel.kt index 0fd83e3c7..17f9530af 100644 --- a/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchViewModel.kt +++ b/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchViewModel.kt @@ -21,6 +21,8 @@ import org.jetbrains.compose.resources.getString import zed.rainxch.core.domain.logging.GitHubStoreLogger import zed.rainxch.core.domain.model.Platform import zed.rainxch.core.domain.model.RateLimitException +import zed.rainxch.core.domain.model.hasActualUpdate +import zed.rainxch.core.domain.model.isReallyInstalled import zed.rainxch.core.domain.repository.FavouritesRepository import zed.rainxch.core.domain.repository.InstalledAppsRepository import zed.rainxch.core.domain.repository.SearchHistoryRepository @@ -226,8 +228,8 @@ class SearchViewModel( .map { searchRepo -> val app = installedMap[searchRepo.repository.id] searchRepo.copy( - isInstalled = app != null, - isUpdateAvailable = app?.isUpdateAvailable ?: false, + isInstalled = app.isReallyInstalled(), + isUpdateAvailable = app.hasActualUpdate(), ) }.toImmutableList(), ) @@ -360,11 +362,11 @@ class SearchViewModel( val starred = starredReposMap[repo.id] DiscoveryRepositoryUi( - isInstalled = app != null, + isInstalled = app.isReallyInstalled(), isFavourite = favourite != null, isStarred = starred != null, isSeen = repo.id in seenIds, - isUpdateAvailable = app?.isUpdateAvailable ?: false, + isUpdateAvailable = app.hasActualUpdate(), repository = repo.toUi(), ) } @@ -770,12 +772,13 @@ class SearchViewModel( val deduped = newRepos .filter { it.id !in existingIds } .map { repo -> + val app = installedMap[repo.id] DiscoveryRepositoryUi( - isInstalled = installedMap[repo.id] != null, + isInstalled = app.isReallyInstalled(), isFavourite = favoritesMap[repo.id] != null, isStarred = starredMap[repo.id] != null, isSeen = repo.id in seenIds, - isUpdateAvailable = installedMap[repo.id]?.isUpdateAvailable ?: false, + isUpdateAvailable = app.hasActualUpdate(), repository = repo.toUi(), ) }