diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index f4dc784b..1a1daa8d 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -151,5 +151,12 @@
android:label="Share BitChat"
android:theme="@style/Theme.BitchatAndroid"
android:launchMode="singleTop" />
+
+
+
diff --git a/app/src/main/java/com/bitchat/android/util/ApkDownloadWorker.kt b/app/src/main/java/com/bitchat/android/util/ApkDownloadWorker.kt
index 8b968923..b8285321 100644
--- a/app/src/main/java/com/bitchat/android/util/ApkDownloadWorker.kt
+++ b/app/src/main/java/com/bitchat/android/util/ApkDownloadWorker.kt
@@ -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)
+ }
+ }
}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 54500cfb..538d14f3 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -185,6 +185,8 @@
Please prepare the app for sharing first.
Download interrupted
Download cancelled
+ Downloading universal APK
+ APK downloads
Share via Hotspot