Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions composeApp/src/androidMain/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,15 @@
</intent-filter>
</receiver>

<!-- Cancel action fired from the download progress notification (#373) -->
<receiver
android:name="zed.rainxch.core.data.services.DownloadCancelReceiver"
android:exported="false">
<intent-filter>
<action android:name="zed.rainxch.githubstore.action.CANCEL_DOWNLOAD" />
</intent-filter>
</receiver>

<!-- WorkManager foreground service declaration for update workers -->
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import org.koin.android.ext.android.get
import org.koin.android.ext.koin.androidContext
import zed.rainxch.core.data.services.DownloadNotificationObserver
import zed.rainxch.core.data.services.PackageEventReceiver
import zed.rainxch.core.data.services.UpdateScheduler
import zed.rainxch.core.domain.model.InstallSource
Expand All @@ -34,10 +35,15 @@ class GithubStoreApp : Application() {

createNotificationChannels()
registerPackageEventReceiver()
startDownloadNotificationObserver()
scheduleBackgroundUpdateChecks()
registerSelfAsInstalledApp()
}

private fun startDownloadNotificationObserver() {
get<DownloadNotificationObserver>().start(get<CoroutineScope>())
}

private fun createNotificationChannels() {
val notificationManager = getSystemService(NotificationManager::class.java)

Expand All @@ -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() {
Expand Down Expand Up @@ -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"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ 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
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
Expand All @@ -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
Expand Down Expand Up @@ -89,6 +93,17 @@ actual val corePlatformModule =
AndroidPendingInstallNotifier(context = androidContext())
}

single<DownloadProgressNotifier> {
AndroidDownloadProgressNotifier(context = androidContext())
}

single {
DownloadNotificationObserver(
orchestrator = get<DownloadOrchestrator>(),
notifier = get<DownloadProgressNotifier>(),
)
}

single<PackageMonitor> {
AndroidPackageMonitor(androidContext())
}
Expand Down
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,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
)

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
}
}
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"
}
}
Loading