feat: Move APK download to resumable WorkManager pipeline

Replaces the ViewModel-scoped coroutine download with a WorkManager-backed
downloader so downloads survive app backgrounding and process death:

- New ApkDownloader interface with WorkManagerApkDownloader implementation
  and ApkDownloadWorker (CoroutineWorker); transient IO errors return
  Result.retry() and resume via HTTP Range requests from the partial file.
- UniversalApkManager gains resume support (Range header + persisted resume
  metadata) and verifies the downloaded APK is signed with the same
  certificate as the running app (no hardcoded fingerprint; debug-signed
  builds skip enforcement).
- APK downloads now go through the shared OkHttpProvider so they respect
  the app's Tor proxy configuration instead of leaking the direct IP.
- AboutSheet logic extracted into ApkDownloadViewModel (MVI: state/event/
  effect), removing ~240 lines of UI-embedded logic.
- Removes unused ApkInstaller (receivers install via the system installer).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Moe Hamade 2026-07-24 17:12:55 +03:00
parent 24db3c4fee
commit 075548249a
11 changed files with 825 additions and 453 deletions

View File

@ -130,8 +130,11 @@ dependencies {
// WebSocket
implementation(libs.okhttp)
// WorkManager for background APK downloads
implementation(libs.androidx.work.runtime.ktx)
// HTTP Server for hotspot APK sharing
implementation("org.nanohttpd:nanohttpd:2.3.1")
implementation(libs.nanohttpd)
// Arti (Tor in Rust) Android bridge - custom build from latest source
// Built with rustls, 16KB page size support, and onio//un service client

View File

@ -1,7 +1,6 @@
package com.bitchat.android.ui
import android.content.Intent
import android.util.Log
import android.widget.Toast
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
@ -63,7 +62,6 @@ import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -76,7 +74,8 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.FileProvider
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.bitchat.android.R
import com.bitchat.android.core.ui.component.button.CloseButton
import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet
@ -86,10 +85,6 @@ import com.bitchat.android.net.TorMode
import com.bitchat.android.net.TorPreferenceManager
import com.bitchat.android.nostr.NostrProofOfWork
import com.bitchat.android.nostr.PoWPreferenceManager
import com.bitchat.android.util.UniversalApkManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Feature row for displaying app capabilities
@ -514,16 +509,38 @@ fun AboutSheet(
)
// === Prepare App for Sharing Section ===
val scope = rememberCoroutineScope()
val apkManager = remember { UniversalApkManager(context) }
var apkStatus by remember { mutableStateOf<ApkPreparationStatus>(ApkPreparationStatus.Loading) }
var downloadProgress by remember { mutableStateOf(0) }
var showPrepareDialog by remember { mutableStateOf(false) }
var showDeleteDialog by remember { mutableStateOf(false) }
val apkViewModel: ApkDownloadViewModel = viewModel()
val apkUiState by apkViewModel.state.collectAsStateWithLifecycle()
val apkStatus = apkUiState.apkStatus
val downloadProgress = apkUiState.downloadProgress
// Check APK status on launch
// Handle one-shot effects (navigation, toasts, share intents)
LaunchedEffect(Unit) {
apkStatus = checkApkStatus(apkManager)
apkViewModel.onEvent(ApkUiEvent.CheckStatus)
apkViewModel.effect.collect { effect ->
when (effect) {
is ApkUiEffect.NavigateToHotspot -> {
val intent = Intent(context, HotspotActivity::class.java)
intent.putExtra(HotspotActivity.EXTRA_APK_PATH, effect.apkPath)
context.startActivity(intent)
}
is ApkUiEffect.ShareApk -> {
val intent = Intent(Intent.ACTION_SEND).apply {
type = "application/vnd.android.package-archive"
putExtra(Intent.EXTRA_STREAM, effect.apkUri)
clipData = android.content.ClipData.newRawUri("", effect.apkUri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
val chooser = Intent.createChooser(intent, effect.chooserTitle).apply {
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(chooser)
}
is ApkUiEffect.ShowToast -> {
Toast.makeText(context, effect.message, Toast.LENGTH_SHORT).show()
}
}
}
}
// Prepare App for Sharing Row
@ -531,15 +548,7 @@ fun AboutSheet(
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = apkStatus !is ApkPreparationStatus.Downloading) {
when (apkStatus) {
is ApkPreparationStatus.NotDownloaded -> showPrepareDialog =
true
is ApkPreparationStatus.UpdateAvailable -> showPrepareDialog =
true
else -> {}
}
apkViewModel.onEvent(ApkUiEvent.PrepareRowClicked)
}
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically
@ -570,11 +579,13 @@ fun AboutSheet(
is ApkPreparationStatus.Ready -> stringResource(R.string.prepare_apk_status_ready) + "${status.version}${status.sizeMB} MB"
is ApkPreparationStatus.UpdateAvailable -> stringResource(R.string.prepare_apk_status_update_available) + " (${status.newVersion})"
is ApkPreparationStatus.Downloading -> stringResource(R.string.prepare_apk_status_downloading, downloadProgress)
is ApkPreparationStatus.Resumable -> "Tap to resume • ${status.progressPercent}% downloaded"
is ApkPreparationStatus.Error -> status.message
},
style = MaterialTheme.typography.bodySmall,
color = when (apkStatus) {
is ApkPreparationStatus.Error -> colorScheme.error
is ApkPreparationStatus.Resumable -> colorScheme.primary
is ApkPreparationStatus.UpdateAvailable -> colorScheme.primary
else -> colorScheme.onSurface.copy(alpha = 0.6f)
},
@ -592,7 +603,7 @@ fun AboutSheet(
}
is ApkPreparationStatus.Ready, is ApkPreparationStatus.UpdateAvailable -> {
androidx.compose.material3.IconButton(
onClick = { showDeleteDialog = true },
onClick = { apkViewModel.onEvent(ApkUiEvent.DeleteClicked) },
modifier = Modifier.size(32.dp)
) {
Icon(
@ -608,7 +619,7 @@ fun AboutSheet(
}
// Prepare Dialog
if (showPrepareDialog) {
if (apkUiState.showPrepareDialog) {
val status = apkStatus
val sizeMB = when (status) {
is ApkPreparationStatus.NotDownloaded -> status.sizeMB
@ -616,7 +627,7 @@ fun AboutSheet(
else -> 47
}
AlertDialog(
onDismissRequest = { showPrepareDialog = false },
onDismissRequest = { apkViewModel.onEvent(ApkUiEvent.DismissPrepareDialog) },
title = {
Text(
text = if (status is ApkPreparationStatus.UpdateAvailable) {
@ -639,21 +650,13 @@ fun AboutSheet(
},
confirmButton = {
Button(onClick = {
showPrepareDialog = false
apkStatus = ApkPreparationStatus.Downloading
scope.launch {
downloadUniversalApk(apkManager, { progress ->
downloadProgress = progress
}) { result ->
apkStatus = result
}
}
apkViewModel.onEvent(ApkUiEvent.ConfirmDownload)
}) {
Text(stringResource(R.string.prepare_apk_dialog_confirm))
}
},
dismissButton = {
TextButton(onClick = { showPrepareDialog = false }) {
TextButton(onClick = { apkViewModel.onEvent(ApkUiEvent.DismissPrepareDialog) }) {
Text(stringResource(R.string.cancel))
}
},
@ -662,10 +665,10 @@ fun AboutSheet(
}
// Delete Dialog
if (showDeleteDialog) {
if (apkUiState.showDeleteDialog) {
val sizeMB = (apkStatus as? ApkPreparationStatus.Ready)?.sizeMB ?: 0
AlertDialog(
onDismissRequest = { showDeleteDialog = false },
onDismissRequest = { apkViewModel.onEvent(ApkUiEvent.DismissDeleteDialog) },
title = {
Text(
text = stringResource(R.string.prepare_apk_delete_confirm),
@ -681,11 +684,7 @@ fun AboutSheet(
confirmButton = {
Button(
onClick = {
showDeleteDialog = false
apkManager.deleteCachedApk()
scope.launch {
apkStatus = checkApkStatus(apkManager)
}
apkViewModel.onEvent(ApkUiEvent.ConfirmDelete)
},
colors = androidx.compose.material3.ButtonDefaults.buttonColors(
containerColor = colorScheme.error
@ -695,7 +694,7 @@ fun AboutSheet(
}
},
dismissButton = {
TextButton(onClick = { showDeleteDialog = false }) {
TextButton(onClick = { apkViewModel.onEvent(ApkUiEvent.DismissDeleteDialog) }) {
Text(stringResource(R.string.cancel))
}
},
@ -723,17 +722,7 @@ fun AboutSheet(
modifier = Modifier
.fillMaxWidth()
.clickable {
// APK is guaranteed to exist (row only visible when ready)
val cachedApk = apkManager.getCachedApk()!!
val intent = Intent(
context,
HotspotActivity::class.java
)
intent.putExtra(
HotspotActivity.EXTRA_APK_PATH,
cachedApk.absolutePath
)
context.startActivity(intent)
apkViewModel.onEvent(ApkUiEvent.HotspotShareClicked)
}
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically
@ -779,12 +768,10 @@ fun AboutSheet(
)
// === Share via Bluetooth/Email Row (Fallback) ===
var showShareApkDialog by remember { mutableStateOf(false) }
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { showShareApkDialog = true }
.clickable { apkViewModel.onEvent(ApkUiEvent.AppShareClicked) }
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically
) {
@ -825,14 +812,11 @@ fun AboutSheet(
// APK Share Dialog
ApkShareExplanationDialog(
show = showShareApkDialog,
show = apkUiState.showShareApkDialog,
onConfirm = {
showShareApkDialog = false
scope.launch {
shareUniversalApk(context, apkManager)
}
apkViewModel.onEvent(ApkUiEvent.ConfirmAppShare)
},
onDismiss = { showShareApkDialog = false }
onDismiss = { apkViewModel.onEvent(ApkUiEvent.DismissShareDialog) }
)
}
}
@ -1127,118 +1111,6 @@ fun PasswordPromptDialog(
}
}
/**
* Status of universal APK preparation.
*/
private sealed class ApkPreparationStatus {
object Loading : ApkPreparationStatus()
data class NotDownloaded(val sizeMB: Int) : ApkPreparationStatus()
data class Ready(val version: String, val sizeMB: Int) : ApkPreparationStatus()
data class UpdateAvailable(
val currentVersion: String,
val newVersion: String,
val newSizeMB: Int
) : ApkPreparationStatus()
object Downloading : ApkPreparationStatus()
data class Error(val message: String) : ApkPreparationStatus()
}
/**
* Check the status of the universal APK.
*/
private suspend fun checkApkStatus(apkManager: UniversalApkManager): ApkPreparationStatus {
return withContext(Dispatchers.IO) {
try {
val updateStatus = apkManager.checkForUpdate()
when (updateStatus) {
is UniversalApkManager.UpdateStatus.NotDownloaded -> {
ApkPreparationStatus.NotDownloaded(
sizeMB = (updateStatus.latestRelease.universalApkSize / 1024 / 1024).toInt()
)
}
is UniversalApkManager.UpdateStatus.UpToDate -> {
val info = apkManager.getCachedApkInfo()
if (info != null) {
ApkPreparationStatus.Ready(
version = info.version,
sizeMB = (info.size / 1024 / 1024).toInt()
)
} else {
ApkPreparationStatus.Error("Cached APK info not found")
}
}
is UniversalApkManager.UpdateStatus.UpdateAvailable -> {
val info = apkManager.getCachedApkInfo()
ApkPreparationStatus.UpdateAvailable(
currentVersion = updateStatus.currentVersion,
newVersion = updateStatus.latestRelease.versionName,
newSizeMB = (updateStatus.latestRelease.universalApkSize / 1024 / 1024).toInt()
)
}
is UniversalApkManager.UpdateStatus.Error -> {
val info = apkManager.getCachedApkInfo()
if (info != null) {
// Have cached APK but couldn't check for updates
ApkPreparationStatus.Ready(
version = info.version,
sizeMB = (info.size / 1024 / 1024).toInt()
)
} else {
ApkPreparationStatus.Error(updateStatus.message)
}
}
}
} catch (e: Exception) {
Log.e("AboutSheet", "Error checking APK status", e)
ApkPreparationStatus.Error(e.message ?: "Unknown error")
}
}
}
/**
* Download the universal APK with progress tracking.
*/
private suspend fun downloadUniversalApk(
apkManager: UniversalApkManager,
onProgress: (Int) -> Unit,
onResult: (ApkPreparationStatus) -> Unit
) {
withContext(Dispatchers.IO) {
var lastUpdateTime = 0L
val result = apkManager.downloadUniversalApk { progress ->
// Throttle updates to max 10 per second (100ms interval) to avoid janky UI
val now = System.currentTimeMillis()
if (now - lastUpdateTime >= 100 || progress == 100) {
lastUpdateTime = now
onProgress(progress)
}
}
val status = if (result.isSuccess) {
val file = result.getOrNull()
if (file != null) {
val info = apkManager.getCachedApkInfo()
if (info != null) {
ApkPreparationStatus.Ready(
version = info.version,
sizeMB = (info.size / 1024 / 1024).toInt()
)
} else {
ApkPreparationStatus.Error("Download succeeded but metadata not found")
}
} else {
ApkPreparationStatus.Error("Download failed")
}
} else {
val error = result.exceptionOrNull()
ApkPreparationStatus.Error(error?.message ?: "Download failed")
}
withContext(Dispatchers.Main) {
onResult(status)
}
}
}
/**
* Dialog explaining APK sharing feature before sharing
@ -1327,63 +1199,3 @@ private fun ApkShareExplanationDialog(
}
}
/**
* Shares the universal APK via standard Android share mechanisms (Bluetooth/Email/etc).
* Uses the same universal APK that was downloaded for hotspot sharing.
*/
private suspend fun shareUniversalApk(
context: android.content.Context,
apkManager: UniversalApkManager
) = withContext(Dispatchers.IO) {
try {
// Get the cached universal APK
val apkFile = apkManager.getCachedApk()
if (apkFile == null || !apkFile.exists()) {
withContext(Dispatchers.Main) {
Toast.makeText(
context,
context.getString(R.string.apk_not_ready_please_prepare_it_first),
Toast.LENGTH_SHORT
).show()
}
return@withContext
}
// Get URI using FileProvider
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
apkFile
)
withContext(Dispatchers.Main) {
// Single universal APK - always use ACTION_SEND
val intent = Intent(Intent.ACTION_SEND).apply {
type = "application/vnd.android.package-archive"
putExtra(Intent.EXTRA_STREAM, uri)
// Use ClipData for proper URI permission granting on Android 10+
clipData = android.content.ClipData.newRawUri("", uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
val chooser = Intent.createChooser(
intent,
context.getString(R.string.share_apk_chooser_title)
).apply {
// Grant read permission on the chooser intent as well
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(chooser)
}
} catch (e: Exception) {
Log.e("AboutSheet", "Error sharing universal APK", e)
withContext(Dispatchers.Main) {
Toast.makeText(
context,
context.getString(R.string.share_apk_error),
Toast.LENGTH_SHORT
).show()
}
}
}

View File

@ -0,0 +1,332 @@
package com.bitchat.android.ui
import android.app.Application
import android.util.Log
import androidx.core.content.FileProvider
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.bitchat.android.R
import com.bitchat.android.util.ApkDownloader
import com.bitchat.android.util.UniversalApkManager
import com.bitchat.android.util.WorkManagerApkDownloader
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
// --- State ---
sealed class ApkPreparationStatus {
object Loading : ApkPreparationStatus()
data class NotDownloaded(val sizeMB: Int) : ApkPreparationStatus()
data class Ready(val version: String, val sizeMB: Int) : ApkPreparationStatus()
data class UpdateAvailable(
val currentVersion: String,
val newVersion: String,
val newSizeMB: Int
) : ApkPreparationStatus()
object Downloading : ApkPreparationStatus()
data class Resumable(val progressPercent: Int, val message: String) : ApkPreparationStatus()
data class Error(val message: String) : ApkPreparationStatus()
}
data class ApkUiState(
val apkStatus: ApkPreparationStatus = ApkPreparationStatus.Loading,
val downloadProgress: Int = 0,
val showPrepareDialog: Boolean = false,
val showDeleteDialog: Boolean = false,
val showShareApkDialog: Boolean = false
)
// --- Events (UI → ViewModel) ---
sealed class ApkUiEvent {
object CheckStatus : ApkUiEvent()
object PrepareRowClicked : ApkUiEvent()
object ConfirmDownload : ApkUiEvent()
object DismissPrepareDialog : ApkUiEvent()
object DeleteClicked : ApkUiEvent()
object ConfirmDelete : ApkUiEvent()
object DismissDeleteDialog : ApkUiEvent()
object HotspotShareClicked : ApkUiEvent()
object AppShareClicked : ApkUiEvent()
object ConfirmAppShare : ApkUiEvent()
object DismissShareDialog : ApkUiEvent()
object CancelDownload : ApkUiEvent()
}
// --- Effects (ViewModel → UI, one-shot) ---
sealed class ApkUiEffect {
data class NavigateToHotspot(val apkPath: String) : ApkUiEffect()
data class ShareApk(val apkUri: android.net.Uri, val chooserTitle: String) : ApkUiEffect()
data class ShowToast(val message: String) : ApkUiEffect()
}
/**
* ViewModel for APK download/status/share logic following MVI pattern.
* UI sends [ApkUiEvent], observes [ApkUiState], and collects [ApkUiEffect].
*/
class ApkDownloadViewModel(application: Application) : AndroidViewModel(application) {
companion object {
private const val TAG = "ApkDownloadVM"
}
private val apkManager = UniversalApkManager(application)
private val downloader: ApkDownloader = WorkManagerApkDownloader(application)
private val _state = MutableStateFlow(ApkUiState())
val state: StateFlow<ApkUiState> = _state.asStateFlow()
private val _effect = Channel<ApkUiEffect>(Channel.BUFFERED)
val effect = _effect.receiveAsFlow()
init {
observeDownloader()
}
fun onEvent(event: ApkUiEvent) {
when (event) {
is ApkUiEvent.CheckStatus -> checkStatus()
is ApkUiEvent.PrepareRowClicked -> onPrepareRowClicked()
is ApkUiEvent.ConfirmDownload -> onConfirmDownload()
is ApkUiEvent.DismissPrepareDialog -> _state.update { it.copy(showPrepareDialog = false) }
is ApkUiEvent.DeleteClicked -> _state.update { it.copy(showDeleteDialog = true) }
is ApkUiEvent.ConfirmDelete -> onConfirmDelete()
is ApkUiEvent.DismissDeleteDialog -> _state.update { it.copy(showDeleteDialog = false) }
is ApkUiEvent.HotspotShareClicked -> onHotspotShareClicked()
is ApkUiEvent.AppShareClicked -> _state.update { it.copy(showShareApkDialog = true) }
is ApkUiEvent.ConfirmAppShare -> onConfirmAppShare()
is ApkUiEvent.DismissShareDialog -> _state.update { it.copy(showShareApkDialog = false) }
is ApkUiEvent.CancelDownload -> onCancelDownload()
}
}
private fun onPrepareRowClicked() {
when (_state.value.apkStatus) {
is ApkPreparationStatus.NotDownloaded,
is ApkPreparationStatus.UpdateAvailable,
is ApkPreparationStatus.Error -> {
_state.update { it.copy(showPrepareDialog = true) }
}
is ApkPreparationStatus.Resumable -> {
startDownload()
}
else -> {}
}
}
private fun onConfirmDownload() {
_state.update { it.copy(showPrepareDialog = false) }
startDownload()
}
private fun onConfirmDelete() {
_state.update { it.copy(showDeleteDialog = false) }
downloader.cancelDownload()
apkManager.deleteCachedApk()
checkStatus()
}
private fun onHotspotShareClicked() {
val apkFile = apkManager.getCachedApk()
if (apkFile != null) {
viewModelScope.launch {
_effect.send(ApkUiEffect.NavigateToHotspot(apkFile.absolutePath))
}
} else {
sendToast(getString(R.string.apk_not_ready_please_prepare_it_first))
}
}
private fun onConfirmAppShare() {
_state.update { it.copy(showShareApkDialog = false) }
viewModelScope.launch(Dispatchers.IO) {
try {
val apkFile = apkManager.getCachedApk()
if (apkFile == null || !apkFile.exists()) {
sendToast(getString(R.string.apk_not_ready_please_prepare_it_first))
return@launch
}
val context = getApplication<Application>()
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
apkFile
)
_effect.send(
ApkUiEffect.ShareApk(
apkUri = uri,
chooserTitle = getString(R.string.share_apk_chooser_title)
)
)
} catch (e: Exception) {
Log.e(TAG, "Error preparing APK share", e)
sendToast(getString(R.string.share_apk_error))
}
}
}
private fun onCancelDownload() {
downloader.cancelDownload()
checkStatus()
}
private fun startDownload() {
val partial = apkManager.getPartialDownloadProgress()
_state.update {
it.copy(
apkStatus = ApkPreparationStatus.Downloading,
downloadProgress = partial ?: 0
)
}
downloader.startDownload()
}
private fun checkStatus() {
viewModelScope.launch {
val currentStatus = _state.value.apkStatus
// If we think we're downloading but cache was wiped (e.g., user cleared
// cache from Android Settings), cancel the orphaned work
if (currentStatus is ApkPreparationStatus.Downloading) {
if (apkManager.getPartialDownloadProgress() == null) {
downloader.cancelDownload()
// Fall through to resolve fresh status
} else {
return@launch
}
}
_state.update { it.copy(apkStatus = resolveApkStatus()) }
}
}
private fun observeDownloader() {
viewModelScope.launch {
downloader.downloadState.collect { downloadState ->
when (downloadState) {
is ApkDownloader.DownloadState.Idle -> {
// Don't overwrite — status set by checkStatus()
}
is ApkDownloader.DownloadState.Downloading -> {
_state.update {
it.copy(
apkStatus = ApkPreparationStatus.Downloading,
downloadProgress = downloadState.progressPercent
)
}
}
is ApkDownloader.DownloadState.Success -> {
_state.update {
it.copy(
apkStatus = ApkPreparationStatus.Ready(
version = downloadState.version,
sizeMB = downloadState.sizeMB
),
downloadProgress = 100
)
}
}
is ApkDownloader.DownloadState.Failed -> {
_state.update {
if (downloadState.resumablePercent != null) {
it.copy(
apkStatus = ApkPreparationStatus.Resumable(
progressPercent = downloadState.resumablePercent,
message = downloadState.message
),
downloadProgress = downloadState.resumablePercent
)
} else {
it.copy(apkStatus = ApkPreparationStatus.Error(downloadState.message))
}
}
}
}
}
}
}
private fun sendToast(message: String) {
viewModelScope.launch {
_effect.send(ApkUiEffect.ShowToast(message))
}
}
private fun getString(resId: Int): String {
return getApplication<Application>().getString(resId)
}
private suspend fun resolveApkStatus(): ApkPreparationStatus = withContext(Dispatchers.IO) {
try {
val updateStatus = apkManager.checkForUpdate()
when (updateStatus) {
is UniversalApkManager.UpdateStatus.NotDownloaded -> {
val partial = apkManager.getPartialDownloadProgress()
if (partial != null) {
ApkPreparationStatus.Resumable(
progressPercent = partial,
message = getString(R.string.prepare_apk_download_interrupted)
)
} else {
ApkPreparationStatus.NotDownloaded(
sizeMB = (updateStatus.latestRelease.universalApkSize / 1024 / 1024).toInt()
)
}
}
is UniversalApkManager.UpdateStatus.UpToDate -> {
val info = apkManager.getCachedApkInfo()
if (info != null) {
ApkPreparationStatus.Ready(
version = info.version,
sizeMB = (info.size / 1024 / 1024).toInt()
)
} else {
ApkPreparationStatus.Error("Cached APK info not found")
}
}
is UniversalApkManager.UpdateStatus.UpdateAvailable -> {
ApkPreparationStatus.UpdateAvailable(
currentVersion = updateStatus.currentVersion,
newVersion = updateStatus.latestRelease.versionName,
newSizeMB = (updateStatus.latestRelease.universalApkSize / 1024 / 1024).toInt()
)
}
is UniversalApkManager.UpdateStatus.Error -> {
val info = apkManager.getCachedApkInfo()
if (info != null) {
ApkPreparationStatus.Ready(
version = info.version,
sizeMB = (info.size / 1024 / 1024).toInt()
)
} else {
val partial = apkManager.getPartialDownloadProgress()
if (partial != null) {
ApkPreparationStatus.Resumable(
progressPercent = partial,
message = getString(R.string.prepare_apk_download_interrupted)
)
} else {
// Show as "not downloaded" so user can still tap to try.
// The actual download will re-fetch release info and fail
// with a clear message if network is still unavailable.
ApkPreparationStatus.NotDownloaded(sizeMB = 0)
}
}
}
}
} catch (e: Exception) {
Log.e(TAG, "Error checking APK status", e)
// Don't show a scary error on initial load — let user try manually
ApkPreparationStatus.NotDownloaded(sizeMB = 0)
}
}
}

View File

@ -0,0 +1,67 @@
package com.bitchat.android.util
import android.content.Context
import android.util.Log
import androidx.work.CoroutineWorker
import androidx.work.Data
import androidx.work.WorkerParameters
/**
* 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.
*/
class ApkDownloadWorker(
appContext: Context,
params: WorkerParameters
) : CoroutineWorker(appContext, params) {
companion object {
const val TAG = "ApkDownloadWorker"
const val WORK_NAME = "apk_download"
// Progress keys
const val KEY_PROGRESS = "progress"
const val KEY_VERSION = "version"
const val KEY_SIZE_MB = "size_mb"
const val KEY_ERROR = "error"
const val KEY_RESUMABLE_PERCENT = "resumable_percent"
private const val MAX_RETRIES = 3
}
private val apkManager = UniversalApkManager(applicationContext)
override suspend fun doWork(): Result {
Log.d(TAG, "Starting APK download work")
val result = apkManager.downloadUniversalApk { progress ->
setProgressAsync(Data.Builder().putInt(KEY_PROGRESS, progress).build())
}
return if (result.isSuccess) {
val info = apkManager.getCachedApkInfo()
val outputData = Data.Builder()
.putString(KEY_VERSION, info?.version ?: "")
.putInt(KEY_SIZE_MB, ((info?.size ?: 0L) / 1024 / 1024).toInt())
.build()
Result.success(outputData)
} else {
val error = result.exceptionOrNull()
// Retry transient network errors with backoff; the partial file
// is kept on disk, so the retry resumes where it left off.
if (error is java.io.IOException && runAttemptCount < MAX_RETRIES) {
Log.w(TAG, "Transient download error (attempt $runAttemptCount), retrying", error)
return Result.retry()
}
val partial = apkManager.getPartialDownloadProgress()
val outputData = Data.Builder()
.putString(KEY_ERROR, error?.message ?: "Download failed")
.putInt(KEY_RESUMABLE_PERCENT, partial ?: -1)
.build()
Result.failure(outputData)
}
}
}

View File

@ -0,0 +1,36 @@
package com.bitchat.android.util
import kotlinx.coroutines.flow.Flow
/**
* Interface for APK download operations.
* Abstracts the download mechanism so it can be swapped
* (e.g., WorkManager, ForegroundService, plain coroutine).
*/
interface ApkDownloader {
/**
* Current download state as an observable flow.
*/
val downloadState: Flow<DownloadState>
/**
* Start or resume a download. If a partial download exists, it resumes automatically.
*/
fun startDownload()
/**
* Cancel an in-progress download. The partial file is kept for future resume.
*/
fun cancelDownload()
/**
* Download state reported by the downloader.
*/
sealed class DownloadState {
object Idle : DownloadState()
data class Downloading(val progressPercent: Int) : DownloadState()
data class Success(val version: String, val sizeMB: Int) : DownloadState()
data class Failed(val message: String, val resumablePercent: Int?) : DownloadState()
}
}

View File

@ -1,170 +0,0 @@
package com.bitchat.android.util
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageInstaller
import android.net.Uri
import android.os.Build
import android.util.Log
import androidx.core.content.FileProvider
import java.io.File
import java.io.IOException
/**
* Utility for installing APK files (single or split) using PackageInstaller API.
* This enables BitChat to be self-distributing in offline mesh network scenarios.
*/
object ApkInstaller {
private const val TAG = "ApkInstaller"
const val ACTION_INSTALL_COMPLETE = "com.bitchat.android.INSTALL_COMPLETE"
/**
* Install APK files using PackageInstaller API.
* Handles both single APK and split APKs (from AAB).
*
* @param context Application context
* @param apkFiles List of APK files to install (can be single file or multiple splits)
* @return true if installation session was created successfully, false otherwise
*/
fun installApks(context: Context, apkFiles: List<File>): Boolean {
return try {
Log.d(TAG, "Starting installation of ${apkFiles.size} APK file(s)")
val packageInstaller = context.packageManager.packageInstaller
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
// Create installation session
val sessionId = packageInstaller.createSession(params)
val session = packageInstaller.openSession(sessionId)
try {
// Write each APK file to the session
apkFiles.forEachIndexed { index, apkFile ->
if (!apkFile.exists()) {
Log.e(TAG, "APK file does not exist: ${apkFile.absolutePath}")
session.abandon()
return false
}
val name = if (apkFiles.size == 1) {
"base.apk"
} else {
"split_$index.apk"
}
session.openWrite(name, 0, apkFile.length()).use { output ->
apkFile.inputStream().use { input ->
input.copyTo(output)
session.fsync(output)
}
}
Log.d(TAG, "Wrote ${apkFile.name} to session (${apkFile.length()} bytes)")
}
// Create pending intent for installation result
val intent = Intent(ACTION_INSTALL_COMPLETE).apply {
setPackage(context.packageName)
}
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
} else {
PendingIntent.FLAG_UPDATE_CURRENT
}
val pendingIntent = PendingIntent.getBroadcast(
context,
sessionId,
intent,
flags
)
// Commit the session - this will show the system install dialog
session.commit(pendingIntent.intentSender)
Log.d(TAG, "Installation session committed (ID: $sessionId)")
true
} catch (e: Exception) {
Log.e(TAG, "Error writing APKs to session", e)
session.abandon()
false
}
} catch (e: Exception) {
Log.e(TAG, "Error creating installation session", e)
false
}
}
/**
* Install a single APK file.
*
* @param context Application context
* @param apkFile APK file to install
* @return true if installation session was created successfully, false otherwise
*/
fun installApk(context: Context, apkFile: File): Boolean {
return installApks(context, listOf(apkFile))
}
/**
* Install APK from URI (e.g., content:// URI from FileProvider).
* Copies the URI to a temporary file first, then installs.
*
* @param context Application context
* @param apkUri URI pointing to the APK file
* @return true if installation started successfully, false otherwise
*/
fun installApkFromUri(context: Context, apkUri: Uri): Boolean {
return try {
// Copy URI to temporary file
val tempFile = File(context.cacheDir, "temp_install.apk")
context.contentResolver.openInputStream(apkUri)?.use { input ->
tempFile.outputStream().use { output ->
input.copyTo(output)
}
}
if (!tempFile.exists() || tempFile.length() == 0L) {
Log.e(TAG, "Failed to copy APK from URI to temp file")
return false
}
Log.d(TAG, "Copied APK from URI to temp file (${tempFile.length()} bytes)")
installApk(context, tempFile)
} catch (e: IOException) {
Log.e(TAG, "Error installing APK from URI", e)
false
}
}
/**
* Check if the app has permission to install packages.
* On Android 8.0+, user must grant "Install unknown apps" permission.
*
* @param context Application context
* @return true if permission is granted, false otherwise
*/
fun canRequestPackageInstalls(context: Context): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.packageManager.canRequestPackageInstalls()
} else {
true // No permission needed on older Android versions
}
}
/**
* Open system settings to allow installing from this app.
*
* @param context Application context
*/
fun requestInstallPermission(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val intent = Intent(android.provider.Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
data = Uri.parse("package:${context.packageName}")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
}
}
}

View File

@ -1,17 +1,18 @@
package com.bitchat.android.util
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.util.Log
import com.bitchat.android.net.OkHttpProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.security.MessageDigest
import java.util.concurrent.TimeUnit
/**
* Manages downloading, caching, and verifying the universal APK for offline sharing.
@ -22,25 +23,28 @@ class UniversalApkManager(private val context: Context) {
private const val TAG = "UniversalApk"
private const val CACHE_DIR_NAME = "universal_apk"
private const val METADATA_FILE_NAME = "universal_apk_info.json"
private const val PROGRESS_FILE_NAME = "download_progress.json"
private const val APK_FILE_PREFIX = "bitchat-universal-"
// Download buffer size (128KB)
private const val BUFFER_SIZE = 128 * 1024
}
private val cacheDir: File = File(context.cacheDir, CACHE_DIR_NAME).apply {
if (!exists()) {
mkdirs()
}
private val cacheDir: File
get() = File(context.cacheDir, CACHE_DIR_NAME).also { it.mkdirs() }
private val metadataFile: File get() = File(cacheDir, METADATA_FILE_NAME)
private val progressFile: File get() = File(cacheDir, PROGRESS_FILE_NAME)
// Download client: inherits Tor proxy settings but with no call timeout
// for large file downloads that can take minutes
private val downloadClient by lazy {
OkHttpProvider.httpClient().newBuilder()
.callTimeout(0, java.util.concurrent.TimeUnit.SECONDS)
.readTimeout(60, java.util.concurrent.TimeUnit.SECONDS)
.build()
}
private val metadataFile: File = File(cacheDir, METADATA_FILE_NAME)
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.build()
/**
* Get information about the cached universal APK, if it exists.
*/
@ -87,6 +91,22 @@ class UniversalApkManager(private val context: Context) {
return getCachedApkInfo()?.file
}
/**
* Check if a partial (resumable) download exists.
* Returns the progress percentage (0-100) or null if no partial download.
*/
fun getPartialDownloadProgress(): Int? {
val tempFile = File(cacheDir, "download_temp.apk")
val resumeInfo = loadResumeInfo()
if (tempFile.exists() && resumeInfo != null) {
val expectedSize = resumeInfo.optLong("expectedSize", 0L)
if (expectedSize > 0) {
return ((tempFile.length() * 100) / expectedSize).toInt().coerceIn(0, 99)
}
}
return null
}
/**
* Check for updates from GitHub.
* @return UpdateStatus indicating if update is available, current version, etc.
@ -142,7 +162,7 @@ class UniversalApkManager(private val context: Context) {
}
/**
* Download the universal APK from GitHub.
* Download the universal APK from GitHub with resume support.
* @param progressCallback Called with progress percentage (0-100)
* @return Result with File on success, or error message
*/
@ -165,51 +185,86 @@ class UniversalApkManager(private val context: Context) {
// Check available disk space before downloading
checkDiskSpace(expectedSize)
// Download to temporary file first
val tempFile = File(cacheDir, "download_temp.apk")
// Check for resumable download
var existingBytes = 0L
if (tempFile.exists()) {
tempFile.delete()
val resumeInfo = loadResumeInfo()
if (resumeInfo != null &&
resumeInfo.optString("url") == url &&
resumeInfo.optString("versionName") == release.versionName
) {
existingBytes = tempFile.length()
Log.d(TAG, "Resuming download from $existingBytes bytes")
} else {
Log.d(TAG, "Stale temp file found, starting fresh")
tempFile.delete()
progressFile.delete()
}
}
val request = Request.Builder()
val requestBuilder = Request.Builder()
.url(url)
.addHeader("User-Agent", "BitChat-Android")
.build()
val response = client.newCall(request).execute()
if (!response.isSuccessful) {
return@withContext Result.failure(
IOException("Download failed: ${response.code} ${response.message}")
)
if (existingBytes > 0) {
requestBuilder.addHeader("Range", "bytes=$existingBytes-")
Log.d(TAG, "Added Range header: bytes=$existingBytes-")
}
val body = response.body
?: return@withContext Result.failure(IOException("Empty response body"))
val request = requestBuilder.build()
// Download with progress tracking
body.byteStream().use { input ->
FileOutputStream(tempFile).use { output ->
val buffer = ByteArray(BUFFER_SIZE)
var bytesRead: Int
var totalBytesRead = 0L
var lastProgress = 0
downloadClient.newCall(request).execute().use { response ->
if (!response.isSuccessful && response.code != 206) {
return@withContext Result.failure(
IOException("Download failed: ${response.code} ${response.message}")
)
}
while (input.read(buffer).also { bytesRead = it } != -1) {
output.write(buffer, 0, bytesRead)
totalBytesRead += bytesRead
val body = response.body
?: return@withContext Result.failure(IOException("Empty response body"))
// Report progress
if (expectedSize > 0) {
val progress = ((totalBytesRead * 100) / expectedSize).toInt()
if (progress != lastProgress) {
lastProgress = progress
progressCallback?.invoke(progress)
// Handle resume: 206 = partial content (append), 200 = full content (overwrite)
val append = response.code == 206
if (!append && existingBytes > 0) {
Log.d(TAG, "Server didn't honor Range request, starting from scratch")
existingBytes = 0
}
// Save resume metadata
saveResumeInfo(url, expectedSize, release.versionName)
// Report initial progress when resuming
if (existingBytes > 0 && expectedSize > 0) {
val initialProgress = ((existingBytes * 100) / expectedSize).toInt()
progressCallback?.invoke(initialProgress)
}
// Download with progress tracking
body.byteStream().use { input ->
FileOutputStream(tempFile, append).use { output ->
val buffer = ByteArray(BUFFER_SIZE)
var bytesRead: Int
var totalBytesRead = existingBytes
var lastProgress = if (expectedSize > 0) ((existingBytes * 100) / expectedSize).toInt() else 0
while (input.read(buffer).also { bytesRead = it } != -1) {
output.write(buffer, 0, bytesRead)
totalBytesRead += bytesRead
// Report progress
if (expectedSize > 0) {
val progress = ((totalBytesRead * 100) / expectedSize).toInt()
if (progress != lastProgress) {
lastProgress = progress
progressCallback?.invoke(progress)
}
}
}
}
Log.d(TAG, "Download complete: ${totalBytesRead / 1024 / 1024}MB")
Log.d(TAG, "Download complete: ${totalBytesRead / 1024 / 1024}MB")
}
}
}
@ -219,6 +274,7 @@ class UniversalApkManager(private val context: Context) {
val isValid = verifyChecksum(tempFile, release.universalApkSha256)
if (!isValid) {
tempFile.delete()
progressFile.delete()
return@withContext Result.failure(
Exception("Checksum verification failed. Downloaded file may be corrupted.")
)
@ -228,6 +284,17 @@ class UniversalApkManager(private val context: Context) {
Log.w(TAG, "No checksum available for verification")
}
// Verify the downloaded APK is signed with the same certificate as this app
Log.d(TAG, "Verifying APK signature...")
if (!verifyApkSignature(tempFile)) {
tempFile.delete()
progressFile.delete()
return@withContext Result.failure(
Exception("APK signature verification failed. The downloaded APK is not signed with the same key as this app.")
)
}
Log.d(TAG, "Signature verified successfully")
// Move to final location
val finalFileName = "$APK_FILE_PREFIX${release.versionName}.apk"
val finalFile = File(cacheDir, finalFileName)
@ -248,6 +315,9 @@ class UniversalApkManager(private val context: Context) {
tempFile.delete()
}
// Clean up resume metadata on success
progressFile.delete()
// Save metadata
saveMetadata(
version = release.versionName,
@ -268,6 +338,103 @@ class UniversalApkManager(private val context: Context) {
}
}
/**
* Verify the downloaded APK is signed with the same certificate as the running app.
* No hardcoded fingerprint needed: if the certs match, receivers of the shared APK
* end up in the same signature lineage as this installation.
*
* Debug-signed installations (Android Debug keystore) skip enforcement so the
* feature stays testable during development.
*/
private fun verifyApkSignature(apkFile: File): Boolean {
return try {
val ownCerts = signatureDigests(
context.packageManager.getPackageInfo(context.packageName, signingFlags())
)
if (ownCerts.isEmpty()) {
Log.w(TAG, "Could not determine own signing certificate, skipping verification")
return true
}
if (isDebugSigned()) {
Log.w(TAG, "App is debug-signed, skipping signature enforcement")
return true
}
val packageInfo = context.packageManager.getPackageArchiveInfo(apkFile.absolutePath, signingFlags())
?: run {
Log.e(TAG, "Could not parse APK for signature verification")
return false
}
val apkCerts = signatureDigests(packageInfo)
if (apkCerts.isEmpty()) {
Log.e(TAG, "No signatures found in downloaded APK")
return false
}
val matches = apkCerts.intersect(ownCerts).isNotEmpty()
if (!matches) {
Log.e(TAG, "Signature mismatch!")
Log.e(TAG, "Own cert(s): $ownCerts")
Log.e(TAG, "APK cert(s): $apkCerts")
}
matches
} catch (e: Exception) {
Log.e(TAG, "Error verifying APK signature", e)
false
}
}
private fun signingFlags(): Int {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
PackageManager.GET_SIGNING_CERTIFICATES
} else {
@Suppress("DEPRECATION")
PackageManager.GET_SIGNATURES
}
}
private fun signatureDigests(packageInfo: android.content.pm.PackageInfo): Set<String> {
val signatures = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
val signingInfo = packageInfo.signingInfo ?: return emptySet()
if (signingInfo.hasMultipleSigners()) {
signingInfo.apkContentsSigners
} else {
signingInfo.signingCertificateHistory
}
} else {
@Suppress("DEPRECATION")
packageInfo.signatures
}
if (signatures.isNullOrEmpty()) return emptySet()
val digest = MessageDigest.getInstance("SHA-256")
return signatures.map { sig ->
digest.digest(sig.toByteArray()).joinToString("") { "%02x".format(it) }
}.toSet()
}
private fun isDebugSigned(): Boolean {
return try {
val packageInfo = context.packageManager.getPackageInfo(context.packageName, signingFlags())
val signatures = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
packageInfo.signingInfo?.apkContentsSigners
} else {
@Suppress("DEPRECATION")
packageInfo.signatures
} ?: return false
val certFactory = java.security.cert.CertificateFactory.getInstance("X.509")
signatures.any { sig ->
val cert = certFactory.generateCertificate(sig.toByteArray().inputStream())
as java.security.cert.X509Certificate
cert.subjectX500Principal.name.contains("Android Debug")
}
} catch (e: Exception) {
false
}
}
/**
* Verify the SHA256 checksum of a file.
*/
@ -307,6 +474,7 @@ class UniversalApkManager(private val context: Context) {
if (info != null) {
info.file.delete()
metadataFile.delete()
progressFile.delete()
Log.d(TAG, "Deleted cached APK: ${info.version}")
true
} else {
@ -355,6 +523,30 @@ class UniversalApkManager(private val context: Context) {
}
}
private fun saveResumeInfo(url: String, expectedSize: Long, versionName: String) {
try {
val json = JSONObject().apply {
put("url", url)
put("expectedSize", expectedSize)
put("versionName", versionName)
}
progressFile.writeText(json.toString())
} catch (e: Exception) {
Log.e(TAG, "Error saving resume info", e)
}
}
private fun loadResumeInfo(): JSONObject? {
return try {
if (progressFile.exists()) {
JSONObject(progressFile.readText())
} else null
} catch (e: Exception) {
Log.e(TAG, "Error loading resume info", e)
null
}
}
/**
* Information about a cached APK.
*/

View File

@ -0,0 +1,86 @@
package com.bitchat.android.util
import android.content.Context
import androidx.work.Constraints
import com.bitchat.android.R
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkInfo
import androidx.work.WorkManager
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
/**
* WorkManager-backed implementation of [ApkDownloader].
* Downloads survive app backgrounding, process death, and device reboots.
*/
class WorkManagerApkDownloader(context: Context) : ApkDownloader {
private val appContext = context.applicationContext
private val workManager = WorkManager.getInstance(appContext)
private val apkManager = UniversalApkManager(appContext)
override val downloadState: Flow<ApkDownloader.DownloadState> =
workManager.getWorkInfosForUniqueWorkFlow(ApkDownloadWorker.WORK_NAME)
.map { workInfos -> mapWorkInfoToState(workInfos.firstOrNull()) }
override fun startDownload() {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val request = OneTimeWorkRequestBuilder<ApkDownloadWorker>()
.setConstraints(constraints)
.addTag(ApkDownloadWorker.TAG)
.build()
workManager.enqueueUniqueWork(
ApkDownloadWorker.WORK_NAME,
ExistingWorkPolicy.KEEP,
request
)
}
override fun cancelDownload() {
workManager.cancelUniqueWork(ApkDownloadWorker.WORK_NAME)
}
private fun mapWorkInfoToState(workInfo: WorkInfo?): ApkDownloader.DownloadState {
if (workInfo == null) return ApkDownloader.DownloadState.Idle
return when (workInfo.state) {
WorkInfo.State.ENQUEUED,
WorkInfo.State.BLOCKED -> {
// Waiting for constraints (network). Show existing partial progress if any.
val partial = apkManager.getPartialDownloadProgress()
ApkDownloader.DownloadState.Downloading(partial ?: 0)
}
WorkInfo.State.RUNNING -> {
val progress = workInfo.progress.getInt(ApkDownloadWorker.KEY_PROGRESS, 0)
ApkDownloader.DownloadState.Downloading(progress)
}
WorkInfo.State.SUCCEEDED -> {
val version = workInfo.outputData.getString(ApkDownloadWorker.KEY_VERSION) ?: ""
val sizeMB = workInfo.outputData.getInt(ApkDownloadWorker.KEY_SIZE_MB, 0)
ApkDownloader.DownloadState.Success(version, sizeMB)
}
WorkInfo.State.FAILED -> {
val error = workInfo.outputData.getString(ApkDownloadWorker.KEY_ERROR) ?: "Download failed"
val resumable = workInfo.outputData.getInt(ApkDownloadWorker.KEY_RESUMABLE_PERCENT, -1)
ApkDownloader.DownloadState.Failed(error, if (resumable >= 0) resumable else null)
}
WorkInfo.State.CANCELLED -> {
val partial = apkManager.getPartialDownloadProgress()
if (partial != null) {
ApkDownloader.DownloadState.Failed(
appContext.getString(R.string.prepare_apk_download_cancelled),
partial
)
} else {
ApkDownloader.DownloadState.Idle
}
}
}
}
}

View File

@ -179,6 +179,8 @@
<string name="prepare_apk_update_dialog_title">Update Available</string>
<string name="prepare_apk_update_dialog_message">A newer version (%1$s) is available. Current: %2$s</string>
<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>
<!-- Hotspot Sharing -->
<string name="hotspot_share_via">Share via Hotspot</string>

View File

@ -26,4 +26,4 @@ android.nonTransitiveRClass=false
kotlin.code.style=official
# JVM heap size configuration to prevent OutOfMemoryError
org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError
org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError

View File

@ -42,6 +42,12 @@ tor-android-binary = "0.4.4.6"
# Google Play Services
gms-location = "21.3.0"
# WorkManager
work-runtime = "2.10.1"
# NanoHTTPD (hotspot APK sharing)
nanohttpd = "2.3.1"
# Security
security-crypto = "1.1.0-beta01"
@ -111,6 +117,12 @@ tor-android-binary = { module = "org.torproject:tor-android-binary", version.ref
# Google Play Services
gms-location = { module = "com.google.android.gms:play-services-location", version.ref = "gms-location" }
# WorkManager
androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "work-runtime" }
# NanoHTTPD
nanohttpd = { module = "org.nanohttpd:nanohttpd", version.ref = "nanohttpd" }
# Security
androidx-security-crypto = { module = "androidx.security:security-crypto", version.ref = "security-crypto" }