mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-15 06:56:30 +00:00
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:
parent
24db3c4fee
commit
075548249a
@ -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
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
332
app/src/main/java/com/bitchat/android/ui/ApkDownloadViewModel.kt
Normal file
332
app/src/main/java/com/bitchat/android/ui/ApkDownloadViewModel.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
36
app/src/main/java/com/bitchat/android/util/ApkDownloader.kt
Normal file
36
app/src/main/java/com/bitchat/android/util/ApkDownloader.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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.
|
||||
*/
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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" }
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user