feat: Run APK download as foreground (dataSync) work

Long transfers — especially over Tor — can exceed WorkManager's
~10-minute background execution window, getting the worker stopped and
rescheduled repeatedly. Promote the download to foreground work with a
progress notification (cancel action included) so it can run to
completion.

- setForeground() with FOREGROUND_SERVICE_TYPE_DATA_SYNC; the manifest
  already holds the FOREGROUND_SERVICE_DATA_SYNC permission, and the
  WorkManager SystemForegroundService is merged with type dataSync.
- If Android 12+ rejects the promotion (app backgrounded), the worker
  logs and continues as regular background work, relying on Range-resume.
- Notification updates are throttled to 5% steps and degrade gracefully
  without POST_NOTIFICATIONS.

Addresses the Codex review finding on ApkDownloadWorker.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Moe Hamade 2026-07-24 19:49:31 +03:00
parent 9700e9cc92
commit 13f940271b
3 changed files with 98 additions and 0 deletions

View File

@ -151,5 +151,12 @@
android:label="Share BitChat"
android:theme="@style/Theme.BitchatAndroid"
android:launchMode="singleTop" />
<!-- Declare the foreground service type for WorkManager's foreground
service so the APK download worker can run as dataSync work -->
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="dataSync"
tools:node="merge" />
</application>
</manifest>

View File

@ -1,15 +1,26 @@
package com.bitchat.android.util
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.content.pm.ServiceInfo
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.work.CoroutineWorker
import androidx.work.Data
import androidx.work.ForegroundInfo
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import com.bitchat.android.R
/**
* WorkManager worker that downloads the universal APK in the background.
* Survives app backgrounding and process death. Transient network errors are
* retried with backoff; partial downloads resume via HTTP Range requests.
*
* Runs as foreground (dataSync) work when possible so slow transfers (e.g.
* over Tor) are not killed by WorkManager's background execution window.
*/
class ApkDownloadWorker(
appContext: Context,
@ -28,15 +39,34 @@ class ApkDownloadWorker(
const val KEY_RESUMABLE_PERCENT = "resumable_percent"
private const val MAX_RETRIES = 3
private const val CHANNEL_ID = "apk_download"
private const val NOTIFICATION_ID = 4201
private const val NOTIFY_STEP_PERCENT = 5
}
private val apkManager = UniversalApkManager(applicationContext)
private val notificationManager =
applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
private var lastNotifiedProgress = -NOTIFY_STEP_PERCENT
override suspend fun doWork(): Result {
Log.d(TAG, "Starting APK download work")
// Promote to foreground so long transfers aren't stopped by the
// ~10-minute background execution window. Android 12+ can reject the
// promotion when the app is backgrounded — continue as regular
// background work and rely on Range-resume in that case.
try {
setForeground(createForegroundInfo(apkManager.getPartialDownloadProgress() ?: 0))
} catch (e: Exception) {
Log.w(TAG, "Could not promote download to foreground work", e)
}
val result = apkManager.downloadUniversalApk { progress ->
setProgressAsync(Data.Builder().putInt(KEY_PROGRESS, progress).build())
updateNotification(progress)
}
return if (result.isSuccess) {
@ -69,4 +99,63 @@ class ApkDownloadWorker(
Result.failure(outputData)
}
}
override suspend fun getForegroundInfo(): ForegroundInfo {
return createForegroundInfo(apkManager.getPartialDownloadProgress() ?: 0)
}
private fun createForegroundInfo(progress: Int): ForegroundInfo {
ensureChannel()
val notification = buildNotification(progress)
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ForegroundInfo(
NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
)
} else {
ForegroundInfo(NOTIFICATION_ID, notification)
}
}
private fun buildNotification(progress: Int): android.app.Notification {
val cancelIntent = WorkManager.getInstance(applicationContext)
.createCancelPendingIntent(id)
return NotificationCompat.Builder(applicationContext, CHANNEL_ID)
.setContentTitle(applicationContext.getString(R.string.apk_download_notification_title))
.setSmallIcon(R.drawable.ic_notification)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setProgress(100, progress, progress <= 0)
.addAction(
android.R.drawable.ic_delete,
applicationContext.getString(android.R.string.cancel),
cancelIntent
)
.build()
}
private fun updateNotification(progress: Int) {
if (progress - lastNotifiedProgress < NOTIFY_STEP_PERCENT) return
lastNotifiedProgress = progress
try {
notificationManager.notify(NOTIFICATION_ID, buildNotification(progress))
} catch (e: Exception) {
// Missing POST_NOTIFICATIONS permission just drops the update;
// the download itself is unaffected.
Log.w(TAG, "Could not update download notification", e)
}
}
private fun ensureChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
applicationContext.getString(R.string.apk_download_channel_name),
NotificationManager.IMPORTANCE_LOW
)
notificationManager.createNotificationChannel(channel)
}
}
}

View File

@ -185,6 +185,8 @@
<string name="prepare_apk_required">Please prepare the app for sharing first.</string>
<string name="prepare_apk_download_interrupted">Download interrupted</string>
<string name="prepare_apk_download_cancelled">Download cancelled</string>
<string name="apk_download_notification_title">Downloading universal APK</string>
<string name="apk_download_channel_name">APK downloads</string>
<!-- Hotspot Sharing -->
<string name="hotspot_share_via">Share via Hotspot</string>