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