-
-
Notifications
You must be signed in to change notification settings - Fork 445
feat: implement system-shade download progress notifications for Android #453
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
82fb010
feat: implement system-shade download progress notifications for Android
rainxchzed 143c07a
domain: improve app installation status and update logic
rainxchzed 5a2b1d9
Update feature/home/presentation/src/commonMain/kotlin/zed/rainxch/ho…
rainxchzed 1aa872e
android: improve download notification reliability and intent handling
rainxchzed 4efa234
Merge remote-tracking branch 'origin/download-progress-notifier' into…
rainxchzed File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
150 changes: 150 additions & 0 deletions
150
.../src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidDownloadProgressNotifier.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| } | ||
| } | ||
64 changes: 64 additions & 0 deletions
64
core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/DownloadCancelReceiver.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<DownloadOrchestrator>() | ||
| val scope = koin.get<CoroutineScope>() | ||
| 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" | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.