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 f137ac63..e0f31bca 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt @@ -1,40 +1,92 @@ package com.bitchat.android.ui +import android.content.Intent +import android.util.Log +import android.widget.Toast import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Bluetooth +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.CloudDownload +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Public -import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.filled.Security +import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Speed -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material.icons.filled.Wifi +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +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 import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle 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 com.bitchat.android.nostr.NostrProofOfWork -import com.bitchat.android.nostr.PoWPreferenceManager -import androidx.compose.ui.res.stringResource +import androidx.core.content.FileProvider import com.bitchat.android.R import com.bitchat.android.core.ui.component.button.CloseButton import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet +import com.bitchat.android.hotspot.HotspotActivity +import com.bitchat.android.net.ArtiTorManager import com.bitchat.android.net.TorMode import com.bitchat.android.net.TorPreferenceManager -import com.bitchat.android.net.ArtiTorManager +import com.bitchat.android.nostr.NostrProofOfWork +import com.bitchat.android.nostr.PoWPreferenceManager +import com.bitchat.android.util.UniversalApkManager +import com.bitchat.android.util.ApkSharingUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext /** * Feature row for displaying app capabilities @@ -452,6 +504,334 @@ fun AboutSheet( } } else null ) + + HorizontalDivider( + modifier = Modifier.padding(start = 56.dp), + color = colorScheme.outline.copy(alpha = 0.12f) + ) + + // === 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) } + + // Check APK status on launch + LaunchedEffect(Unit) { + apkStatus = checkApkStatus(apkManager) + } + + // Prepare App for Sharing Row + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = apkStatus !is ApkPreparationStatus.Downloading) { + when (val status = apkStatus) { + is ApkPreparationStatus.NotDownloaded -> showPrepareDialog = true + is ApkPreparationStatus.UpdateAvailable -> showPrepareDialog = true + else -> {} + } + } + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.CloudDownload, + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier.size(22.dp) + ) + + Spacer(modifier = Modifier.width(14.dp)) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = stringResource(R.string.prepare_apk_title), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = colorScheme.onSurface + ) + Text( + text = when (val status = apkStatus) { + is ApkPreparationStatus.Loading -> "Checking..." + is ApkPreparationStatus.NotDownloaded -> stringResource(R.string.prepare_apk_status_not_downloaded) + 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.Error -> status.message + }, + style = MaterialTheme.typography.bodySmall, + color = when (apkStatus) { + is ApkPreparationStatus.Error -> colorScheme.error + is ApkPreparationStatus.UpdateAvailable -> colorScheme.primary + else -> colorScheme.onSurface.copy(alpha = 0.6f) + }, + lineHeight = 16.sp + ) + } + + // Action buttons + when (val status = apkStatus) { + is ApkPreparationStatus.Downloading -> { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp + ) + } + is ApkPreparationStatus.Ready, is ApkPreparationStatus.UpdateAvailable -> { + androidx.compose.material3.IconButton( + onClick = { showDeleteDialog = true }, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = "Delete", + tint = colorScheme.error, + modifier = Modifier.size(20.dp) + ) + } + } + else -> {} + } + } + + // Prepare Dialog + if (showPrepareDialog) { + val status = apkStatus + val sizeMB = when (status) { + is ApkPreparationStatus.NotDownloaded -> status.sizeMB + is ApkPreparationStatus.UpdateAvailable -> status.newSizeMB + else -> 47 + } + AlertDialog( + onDismissRequest = { showPrepareDialog = false }, + title = { + Text( + text = if (status is ApkPreparationStatus.UpdateAvailable) { + stringResource(R.string.prepare_apk_update_dialog_title) + } else { + stringResource(R.string.prepare_apk_dialog_title) + }, + style = MaterialTheme.typography.titleLarge + ) + }, + text = { + Text( + text = if (status is ApkPreparationStatus.UpdateAvailable) { + stringResource(R.string.prepare_apk_update_dialog_message, status.newVersion, status.currentVersion) + } else { + stringResource(R.string.prepare_apk_dialog_message, sizeMB) + }, + style = MaterialTheme.typography.bodyMedium + ) + }, + confirmButton = { + Button(onClick = { + showPrepareDialog = false + apkStatus = ApkPreparationStatus.Downloading + scope.launch { + downloadUniversalApk(apkManager, { progress -> + downloadProgress = progress + }) { result -> + apkStatus = result + } + } + }) { + Text(stringResource(R.string.prepare_apk_dialog_confirm)) + } + }, + dismissButton = { + TextButton(onClick = { showPrepareDialog = false }) { + Text(stringResource(R.string.cancel)) + } + }, + containerColor = colorScheme.surface + ) + } + + // Delete Dialog + if (showDeleteDialog) { + val sizeMB = (apkStatus as? ApkPreparationStatus.Ready)?.sizeMB ?: 0 + AlertDialog( + onDismissRequest = { showDeleteDialog = false }, + title = { + Text( + text = stringResource(R.string.prepare_apk_delete_confirm), + style = MaterialTheme.typography.titleLarge + ) + }, + text = { + Text( + text = stringResource(R.string.prepare_apk_delete_message, sizeMB), + style = MaterialTheme.typography.bodyMedium + ) + }, + confirmButton = { + Button( + onClick = { + showDeleteDialog = false + apkManager.deleteCachedApk() + scope.launch { + apkStatus = checkApkStatus(apkManager) + } + }, + colors = androidx.compose.material3.ButtonDefaults.buttonColors( + containerColor = colorScheme.error + ) + ) { + Text("Delete") + } + }, + dismissButton = { + TextButton(onClick = { showDeleteDialog = false }) { + Text(stringResource(R.string.cancel)) + } + }, + containerColor = colorScheme.surface + ) + } + + HorizontalDivider( + modifier = Modifier.padding(start = 56.dp), + color = colorScheme.outline.copy(alpha = 0.12f) + ) + + // === Share via Hotspot Row === + var showHotspotRequiredDialog by remember { mutableStateOf(false) } + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + val cachedApk = apkManager.getCachedApk() + if (cachedApk != null) { + val intent = Intent(context, HotspotActivity::class.java) + intent.putExtra(HotspotActivity.EXTRA_APK_PATH, cachedApk.absolutePath) + context.startActivity(intent) + } else { + showHotspotRequiredDialog = true + } + } + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Wifi, + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier.size(22.dp) + ) + + Spacer(modifier = Modifier.width(14.dp)) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = stringResource(R.string.hotspot_share_via), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = colorScheme.onSurface + ) + Text( + text = "Create Wi-Fi hotspot to share offline", + style = MaterialTheme.typography.bodySmall, + color = colorScheme.onSurface.copy(alpha = 0.6f), + lineHeight = 16.sp + ) + } + + Icon( + imageVector = Icons.Default.ChevronRight, + contentDescription = null, + tint = colorScheme.onSurface.copy(alpha = 0.4f), + modifier = Modifier.size(20.dp) + ) + } + + // Required Dialog + if (showHotspotRequiredDialog) { + AlertDialog( + onDismissRequest = { showHotspotRequiredDialog = false }, + title = { Text("APK Not Ready") }, + text = { Text(stringResource(R.string.prepare_apk_required)) }, + confirmButton = { + Button(onClick = { showHotspotRequiredDialog = false }) { + Text("OK") + } + }, + containerColor = colorScheme.surface + ) + } + + HorizontalDivider( + modifier = Modifier.padding(start = 56.dp), + color = colorScheme.outline.copy(alpha = 0.12f) + ) + + // === Share via Bluetooth/Email Row (Fallback) === + var showShareApkDialog by remember { mutableStateOf(false) } + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { showShareApkDialog = true } + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Bluetooth, + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier.size(22.dp) + ) + + Spacer(modifier = Modifier.width(14.dp)) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = stringResource(R.string.hotspot_share_other), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = colorScheme.onSurface + ) + Text( + text = "Use standard Android sharing", + style = MaterialTheme.typography.bodySmall, + color = colorScheme.onSurface.copy(alpha = 0.6f), + lineHeight = 16.sp + ) + } + + Icon( + imageVector = Icons.Default.ChevronRight, + contentDescription = null, + tint = colorScheme.onSurface.copy(alpha = 0.4f), + modifier = Modifier.size(20.dp) + ) + } + + // APK Share Dialog + ApkShareExplanationDialog( + show = showShareApkDialog, + onConfirm = { + showShareApkDialog = false + scope.launch { + shareApk(context) + } + }, + onDismiss = { showShareApkDialog = false } + ) + } } @@ -741,3 +1121,274 @@ 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) { + val result = apkManager.downloadUniversalApk { progress -> + 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 + */ +@Composable +private fun ApkShareExplanationDialog( + show: Boolean, + onConfirm: () -> Unit, + onDismiss: () -> Unit +) { + if (show) { + val colorScheme = MaterialTheme.colorScheme + + AlertDialog( + onDismissRequest = onDismiss, + icon = { + Icon( + imageVector = Icons.Default.Share, + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier.size(32.dp) + ) + }, + title = { + Text( + text = stringResource(R.string.share_apk_title), + style = MaterialTheme.typography.titleLarge, + color = colorScheme.onSurface + ) + }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + text = stringResource(R.string.share_apk_explanation), + style = MaterialTheme.typography.bodyMedium, + color = colorScheme.onSurface + ) + + // Info box with receiver instructions + Surface( + color = colorScheme.primaryContainer.copy(alpha = 0.3f), + shape = RoundedCornerShape(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier.padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.Top + ) { + Icon( + imageVector = Icons.Outlined.Info, + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + Text( + text = stringResource(R.string.share_apk_receiver_instructions), + style = MaterialTheme.typography.bodySmall, + color = colorScheme.onSurface.copy(alpha = 0.8f), + lineHeight = 18.sp + ) + } + } + } + }, + confirmButton = { + Button(onClick = onConfirm) { + Text( + text = stringResource(R.string.share_apk_confirm), + style = MaterialTheme.typography.bodyMedium + ) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text( + text = stringResource(R.string.cancel), + style = MaterialTheme.typography.bodyMedium, + color = colorScheme.onSurface + ) + } + }, + containerColor = colorScheme.surface, + tonalElevation = 8.dp + ) + } +} + +/** + * Shares the installed APK via standard Android share mechanisms + */ +private suspend fun shareApk(context: android.content.Context) = withContext(Dispatchers.IO) { + try { + val apkFiles = ApkSharingUtils.prepareApksForSharing(context) ?: run { + withContext(Dispatchers.Main) { + Toast.makeText( + context, + context.getString(R.string.share_apk_error), + Toast.LENGTH_SHORT + ).show() + } + return@withContext + } + + val uris = apkFiles.map { file -> + FileProvider.getUriForFile( + context, + "${context.packageName}.fileprovider", + file + ) + } + + withContext(Dispatchers.Main) { + val intent = if (uris.size == 1) { + // Single APK + Intent(Intent.ACTION_SEND).apply { + type = "application/vnd.android.package-archive" + putExtra(Intent.EXTRA_STREAM, uris[0]) + // Use ClipData for proper URI permission granting on Android 10+ + clipData = android.content.ClipData.newRawUri("", uris[0]) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + } else { + // Multiple split APKs + Intent(Intent.ACTION_SEND_MULTIPLE).apply { + type = "application/vnd.android.package-archive" + putParcelableArrayListExtra(Intent.EXTRA_STREAM, ArrayList(uris)) + // Use ClipData for proper URI permission granting + clipData = android.content.ClipData.newRawUri("", uris[0]).apply { + uris.drop(1).forEach { uri -> + addItem(android.content.ClipData.Item(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) + + // Cleanup after a longer delay to allow share to complete + // Files remain available during the share process + // TEMPORARY: Disabled for debugging - check cached APK validity + // delay(60000) // 60 seconds - enough time for Bluetooth/Nearby Share + // ApkSharingUtils.cleanupSharedApks(context) + } + } catch (e: Exception) { + Log.e("AboutSheet", "Error sharing 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