diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 953a479d..db14845a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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 diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt index 93212850..539ce95f 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt @@ -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.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() - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/bitchat/android/ui/ApkDownloadViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ApkDownloadViewModel.kt new file mode 100644 index 00000000..ba9d387e --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/ApkDownloadViewModel.kt @@ -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 = _state.asStateFlow() + + private val _effect = Channel(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() + 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().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) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/bitchat/android/util/ApkDownloadWorker.kt b/app/src/main/java/com/bitchat/android/util/ApkDownloadWorker.kt new file mode 100644 index 00000000..87e41c87 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/util/ApkDownloadWorker.kt @@ -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) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/bitchat/android/util/ApkDownloader.kt b/app/src/main/java/com/bitchat/android/util/ApkDownloader.kt new file mode 100644 index 00000000..3bf234ae --- /dev/null +++ b/app/src/main/java/com/bitchat/android/util/ApkDownloader.kt @@ -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 + + /** + * 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() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/bitchat/android/util/ApkInstaller.kt b/app/src/main/java/com/bitchat/android/util/ApkInstaller.kt deleted file mode 100644 index e6e87f28..00000000 --- a/app/src/main/java/com/bitchat/android/util/ApkInstaller.kt +++ /dev/null @@ -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): 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) - } - } -} diff --git a/app/src/main/java/com/bitchat/android/util/UniversalApkManager.kt b/app/src/main/java/com/bitchat/android/util/UniversalApkManager.kt index d40558f3..a49daf67 100644 --- a/app/src/main/java/com/bitchat/android/util/UniversalApkManager.kt +++ b/app/src/main/java/com/bitchat/android/util/UniversalApkManager.kt @@ -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 { + 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. */ diff --git a/app/src/main/java/com/bitchat/android/util/WorkManagerApkDownloader.kt b/app/src/main/java/com/bitchat/android/util/WorkManagerApkDownloader.kt new file mode 100644 index 00000000..55d9245e --- /dev/null +++ b/app/src/main/java/com/bitchat/android/util/WorkManagerApkDownloader.kt @@ -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 = + workManager.getWorkInfosForUniqueWorkFlow(ApkDownloadWorker.WORK_NAME) + .map { workInfos -> mapWorkInfoToState(workInfos.firstOrNull()) } + + override fun startDownload() { + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + val request = OneTimeWorkRequestBuilder() + .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 + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cad0d18a..8e1aac70 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -179,6 +179,8 @@ Update Available A newer version (%1$s) is available. Current: %2$s Please prepare the app for sharing first. + Download interrupted + Download cancelled Share via Hotspot diff --git a/gradle.properties b/gradle.properties index 0838af96..fce5240f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -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 \ No newline at end of file +org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b6a5411a..d682dd11 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }