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(),
)
}