Skip to content

feat: implement system-shade download progress notifications for Android#453

Merged
rainxchzed merged 5 commits intomainfrom
download-progress-notifier
Apr 24, 2026
Merged

feat: implement system-shade download progress notifications for Android#453
rainxchzed merged 5 commits intomainfrom
download-progress-notifier

Conversation

@rainxchzed
Copy link
Copy Markdown
Member

@rainxchzed rainxchzed commented Apr 24, 2026

  • Introduce DownloadProgressNotifier interface and its Android implementation to surface live download progress in the system notification tray.
  • Add DownloadNotificationObserver to translate DownloadOrchestrator state transitions into notification updates with throttling (400ms) to optimize performance.
  • Implement DownloadCancelReceiver to handle download cancellation directly from the notification action.
  • Add a no-op DesktopDownloadProgressNotifier for the JVM target to maintain platform-agnostic common code.
  • Create a new app_downloads notification channel with low importance to ensure non-intrusive progress tracking.
  • Register necessary components in Android Koin modules and AndroidManifest.xml.
  • Clean up redundant imports in HomeRoot.kt.

Summary by CodeRabbit

  • New Features

    • Real-time download notifications showing percent and sizes, with a dedicated downloads channel and ongoing progress rows.
    • Cancel action on download notifications that works even when the app is not active.
    • Cross-platform notifier wiring so desktop is a noop while mobile shows live progress.
  • Bug Fixes

    • Pending/parked installs are no longer reported as "Installed"; updates only appear when truly available.
  • Maintenance

    • Minor import cleanup and DI/module wiring updates.

- Introduce `DownloadProgressNotifier` interface and its Android implementation to surface live download progress in the system notification tray.
- Add `DownloadNotificationObserver` to translate `DownloadOrchestrator` state transitions into notification updates with throttling (400ms) to optimize performance.
- Implement `DownloadCancelReceiver` to handle download cancellation directly from the notification action.
- Add a no-op `DesktopDownloadProgressNotifier` for the JVM target to maintain platform-agnostic common code.
- Create a new `app_downloads` notification channel with low importance to ensure non-intrusive progress tracking.
- Register necessary components in Android Koin modules and `AndroidManifest.xml`.
- Clean up redundant imports in `HomeRoot.kt`.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 24, 2026

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: a179f292-ef83-40c3-8628-8f98738a4922

📥 Commits

Reviewing files that changed from the base of the PR and between 5a2b1d9 and 4efa234.

📒 Files selected for processing (3)
  • composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt
  • core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidDownloadProgressNotifier.kt
  • core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/DownloadNotificationObserver.kt

Walkthrough

Adds a cross-platform download progress notification system: a domain notifier interface, Android and JVM platform implementations (real notifications on Android, no-op on desktop), an observer that reconciles orchestrator state to notifications, a broadcast receiver + manifest entry for cancel actions, and DI/startup wiring.

Changes

Cohort / File(s) Summary
Domain Interface
core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/DownloadProgressNotifier.kt
New interface exposing notifyProgress(...) and clearProgress(...).
Android App Startup & Manifest
composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt, composeApp/src/androidMain/AndroidManifest.xml
Adds DOWNLOADS_CHANNEL_ID, creates app_downloads notification channel at startup, and registers a non-exported DownloadCancelReceiver.
Android Notifier & Observer
core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidDownloadProgressNotifier.kt, core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/DownloadNotificationObserver.kt
Implements Android notifier (permission-aware, determinate/indeterminate progress, cancel action, per-package notification IDs) and observer (reconciles orchestrator downloads, throttles updates, clears terminal states).
Android Cancellation Receiver
core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/DownloadCancelReceiver.kt
Adds BroadcastReceiver to handle ACTION_CANCEL, resolves Koin, and invokes DownloadOrchestrator.cancel(packageName) using goAsync() + coroutine.
DI Wiring (Android & JVM)
core/data/src/androidMain/.../PlatformModule.android.kt, core/data/src/jvmMain/.../PlatformModule.jvm.kt
Registers AndroidDownloadProgressNotifier + DownloadNotificationObserver on Android; registers DesktopDownloadProgressNotifier on JVM.
Desktop No-op Notifier
core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopDownloadProgressNotifier.kt
Adds no-op DownloadProgressNotifier implementation for desktop.
InstalledApp Helpers & Consumers
core/domain/src/commonMain/.../InstalledApp.kt, feature/home/presentation/.../HomeViewModel.kt, feature/search/presentation/.../SearchViewModel.kt, feature/dev-profile/data/.../DeveloperProfileRepositoryImpl.kt
Adds InstalledApp?.isReallyInstalled() and ?.hasActualUpdate() helpers and updates consumers to ignore pending installs when reporting installed/update state.
Small cleanup
feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeRoot.kt
Removed an unused and a duplicate import.

Sequence Diagram(s)

sequenceDiagram
    participant Orchestrator as DownloadOrchestrator
    participant Observer as DownloadNotificationObserver
    participant Notifier as AndroidDownloadProgressNotifier
    participant Android as Android System

    Orchestrator->>Observer: emit download state map
    activate Observer
    Observer->>Observer: reconcile changes & throttle per-package updates

    alt Queued or Downloading
        Observer->>Notifier: notifyProgress(pkg, appName, version, percent?, bytes, total?)
        activate Notifier
        Notifier->>Notifier: format bytes & build notification (Cancel action)
        Notifier->>Android: post/update notification (channel app_downloads)
        Android-->>Notifier: posted
        Notifier-->>Observer: done
        deactivate Notifier
    else Transition to terminal/non-progress stage
        Observer->>Notifier: clearProgress(pkg)
        activate Notifier
        Notifier->>Android: cancel notification by id
        Android-->>Notifier: dismissed
        Notifier-->>Observer: done
        deactivate Notifier
    end
    deactivate Observer
Loading
sequenceDiagram
    participant User as User
    participant Notification as Notification UI
    participant Receiver as DownloadCancelReceiver
    participant Koin as Koin (DI)
    participant Orchestrator as DownloadOrchestrator

    User->>Notification: tap "Cancel"
    Notification->>Receiver: broadcast Intent (ACTION_CANCEL + packageName)
    activate Receiver
    Receiver->>Receiver: goAsync()
    Receiver->>Koin: getKoin()
    activate Koin
    Koin-->>Receiver: DownloadOrchestrator + CoroutineScope
    deactivate Koin
    Receiver->>Orchestrator: launch coroutine -> cancel(packageName)
    activate Orchestrator
    Orchestrator-->>Receiver: cancel processed
    deactivate Orchestrator
    Receiver->>Receiver: pending.finish()
    deactivate Receiver
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • PR #453: Overlaps Android notification pipeline, DI wiring, and cancel receiver additions.
  • PR #266: Modifies GithubStoreApp.onCreate startup wiring — may conflict with observer/channel initialization.
  • PR #416: Also introduces platform notifier implementations and DI registrations for download notifications.

Poem

🐰
I nibble bytes as progress climbs, each hop a cheerful beat,
Notifications glow, and tiny paws pat quick and neat.
A cancel thump — a broadcast flight,
Observers guard the download night.
Hooray for updates and crunchy carrot treats! 🎉

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 20.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: implementing system notification download progress for Android, which is the primary feature across most of the changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch download-progress-notifier

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt (1)

43-47: Start the observer on the shared app scope.

DownloadNotificationObserver is documented as a single long-lived subscriber collected on the app-scoped coroutine scope. Starting it on GithubStoreApp’s private scope creates a second process-wide scope, which makes lifecycle and test behavior diverge from the rest of the app. Prefer resolving the shared CoroutineScope from Koin here.

♻️ Proposed change
     private fun startDownloadNotificationObserver() {
         // The observer translates orchestrator state into progress
         // notifications (see `#373`). Kicked off here so it runs for the
         // whole process lifetime — no ViewModel is involved.
-        get<DownloadNotificationObserver>().start(appScope)
+        get<DownloadNotificationObserver>().start(get())
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt`
around lines 43 - 47, The startDownloadNotificationObserver currently starts
DownloadNotificationObserver using GithubStoreApp's private appScope; change it
to resolve and use the shared application CoroutineScope from Koin instead so
the observer runs on the single app-wide scope. Locate
startDownloadNotificationObserver and replace the call that passes the private
appScope to DownloadNotificationObserver.start(...) with a call that first
resolves the shared CoroutineScope from Koin (the app-scoped CoroutineScope
binding) and passes that shared scope into
DownloadNotificationObserver.start(...).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidDownloadProgressNotifier.kt`:
- Around line 49-60: PendingIntent identity currently relies only on
action/component and requestCode (packageName.hashCode()), which can collide;
update AndroidDownloadProgressNotifier where cancelIntent/cancelPendingIntent
are created so each cancel Intent is uniquely identifiable by adding a data URI
that encodes the package (for example using Uri.parse("package:" + packageName)
or similar) before calling PendingIntent.getBroadcast; keep the existing extras
and flags (DownloadCancelReceiver.ACTION_CANCEL, EXTRA_PACKAGE_NAME,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE) but
setIntent.data to the unique URI so the PendingIntent identity is per-package
and not reliant on hashCode alone.

In
`@core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/DownloadNotificationObserver.kt`:
- Around line 65-72: The collector launched in
DownloadNotificationObserver.start can die if clearProgress throws and leaves
job non-null, so wrap the collect/reconcile body in a try/catch/finally: catch
Throwable around calls that may throw (especially clearProgress and the two
other clearProgress call sites) and handle/log the error (do not rethrow), and
in finally ensure job = null so future start() calls can resubscribe;
additionally protect notifyProgress/clearProgress invocations inside reconcile
with their own small try/catch blocks to prevent a single notification-manager
failure from cancelling the whole orchestrator.downloads.collect coroutine.

---

Nitpick comments:
In
`@composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt`:
- Around line 43-47: The startDownloadNotificationObserver currently starts
DownloadNotificationObserver using GithubStoreApp's private appScope; change it
to resolve and use the shared application CoroutineScope from Koin instead so
the observer runs on the single app-wide scope. Locate
startDownloadNotificationObserver and replace the call that passes the private
appScope to DownloadNotificationObserver.start(...) with a call that first
resolves the shared CoroutineScope from Koin (the app-scoped CoroutineScope
binding) and passes that shared scope into
DownloadNotificationObserver.start(...).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: c3f55b8e-00c4-403a-8f09-5fabcaa36943

📥 Commits

Reviewing files that changed from the base of the PR and between d3a243c and 82fb010.

📒 Files selected for processing (10)
  • composeApp/src/androidMain/AndroidManifest.xml
  • composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt
  • core/data/src/androidMain/kotlin/zed/rainxch/core/data/di/PlatformModule.android.kt
  • core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidDownloadProgressNotifier.kt
  • core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/DownloadCancelReceiver.kt
  • core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/DownloadNotificationObserver.kt
  • core/data/src/jvmMain/kotlin/zed/rainxch/core/data/di/PlatformModule.jvm.kt
  • core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopDownloadProgressNotifier.kt
  • core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/DownloadProgressNotifier.kt
  • feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeRoot.kt
💤 Files with no reviewable changes (1)
  • feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeRoot.kt

- Introduce `isReallyInstalled()` and `hasActualUpdate()` extension functions for `InstalledApp` to correctly handle pending installation states.
- Update `DeveloperProfileRepositoryImpl` to treat apps as installed only if they are not in a pending install state, preventing UI leakage of failed/parked downloads.
- Refactor `SearchViewModel` and `HomeViewModel` to use the new status checks, ensuring consistency between discovery cards and the details screen.
- Fix a synchronization issue where "Installed" badges or update notifications would persist incorrectly after a failed installation.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
feature/dev-profile/data/src/commonMain/kotlin/zed/rainxch/devprofile/data/repository/DeveloperProfileRepositoryImpl.kt (1)

143-148: Consider calling the domain helper directly instead of re-inlining the predicate.

The comment already points at InstalledApp.isReallyInstalled() as the canonical form of this check, but the expression re-implements it inline. Delegating to the helper removes the risk that the two definitions drift (e.g. if "really installed" later grows another clause such as a non-null installedVersion) and matches the pattern adopted in HomeViewModel/SearchViewModel in this same PR.

♻️ Proposed refactor
 import zed.rainxch.core.domain.model.Platform
 import zed.rainxch.core.domain.model.RateLimitException
+import zed.rainxch.core.domain.model.isReallyInstalled
 import zed.rainxch.core.domain.repository.FavouritesRepository
         return repo.toDomain(
             hasReleases = hasReleases,
             hasInstallableAssets = hasInstallableAssets,
-            // 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,
+            isInstalled = installedApp.isReallyInstalled(),
             isFavorite = isFavorite,
             latestVersion = latestVersion,
         )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@feature/dev-profile/data/src/commonMain/kotlin/zed/rainxch/devprofile/data/repository/DeveloperProfileRepositoryImpl.kt`
around lines 143 - 148, Replace the inline predicate used to set isInstalled in
DeveloperProfileRepositoryImpl with the canonical domain helper: call
InstalledApp.isReallyInstalled() on installedApp instead of evaluating
"installedApp != null && !installedApp.isPendingInstall"; this keeps logic
consistent with HomeViewModel/SearchViewModel and avoids drift if the domain
rule changes.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeViewModel.kt`:
- Line 120: The top-level aggregate is using the raw per-repo flag
(`it.value.isUpdateAvailable`) which counts pending-install rows and can drift
from each card's state; change the aggregate to use the same computed predicate
used for per-repo mapping (call the repository-state helper `hasActualUpdate()`
on each mapped value) so replace the `installedMap.any {
it.value.isUpdateAvailable }` check with `installedMap.any {
it.value.hasActualUpdate() }` (ensure you reference the same symbol
`hasActualUpdate()` used at lines ~116–117 and ~366–370 so
HomeState.isUpdateAvailable reflects the per-card update visibility).

---

Nitpick comments:
In
`@feature/dev-profile/data/src/commonMain/kotlin/zed/rainxch/devprofile/data/repository/DeveloperProfileRepositoryImpl.kt`:
- Around line 143-148: Replace the inline predicate used to set isInstalled in
DeveloperProfileRepositoryImpl with the canonical domain helper: call
InstalledApp.isReallyInstalled() on installedApp instead of evaluating
"installedApp != null && !installedApp.isPendingInstall"; this keeps logic
consistent with HomeViewModel/SearchViewModel and avoids drift if the domain
rule changes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: c06dd3d4-f370-427c-b077-79ae8e6d22a7

📥 Commits

Reviewing files that changed from the base of the PR and between 82fb010 and 143c07a.

📒 Files selected for processing (4)
  • core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/InstalledApp.kt
  • feature/dev-profile/data/src/commonMain/kotlin/zed/rainxch/devprofile/data/repository/DeveloperProfileRepositoryImpl.kt
  • feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeViewModel.kt
  • feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchViewModel.kt

rainxchzed and others added 3 commits April 24, 2026 21:03
…me/presentation/HomeViewModel.kt

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
- Fix a potential `PendingIntent` collision in `AndroidDownloadProgressNotifier` by adding a unique data URI to the cancel action, ensuring distinct identities for concurrent downloads.
- Enhance `DownloadNotificationObserver` robustness by wrapping `reconcile` and `clearProgress` calls in try-catch blocks to prevent notification errors from crashing the download flow.
- Ensure `DownloadNotificationObserver` can be restarted by resetting its internal job reference to `null` upon completion or cancellation.
- Refactor `GithubStoreApp` to use a consistent, shared `CoroutineScope` for the `DownloadNotificationObserver`, aligning its lifecycle with the `DownloadOrchestrator`.
@rainxchzed rainxchzed merged commit 3e11fc5 into main Apr 24, 2026
1 check was pending
@rainxchzed rainxchzed deleted the download-progress-notifier branch April 24, 2026 16:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant