feat: Add offline and online app sharing features

This commit introduces a comprehensive feature set for sharing the application, both offline via a Wi-Fi hotspot and online through standard Android sharing mechanisms.

Key additions:

- **Prepare for Sharing UI:**
    - Adds a "Prepare App for Sharing" option in the settings sheet.
    - This feature downloads a universal APK from a remote source, suitable for all Android devices.
    - The UI displays the status: not downloaded, downloading (with progress), ready, or if an update is available.
    - Users can download, update, or delete the cached universal APK.

- **Offline Sharing via Wi-Fi Hotspot:**
    - Adds a "Share via Wi-Fi Hotspot" option.
    - This launches a new `HotspotActivity` to share the prepared universal APK with nearby devices without an internet connection.
    - A dialog informs the user if the APK hasn't been prepared yet.

- **Online & Local Sharing:**
    - Adds an option to share via Bluetooth, email, etc., using the standard Android share sheet.
    - This method shares the *installed* version of the app, which may be a split APK.
    - An explanatory dialog is shown first, instructing the receiver on how to install split APKs if necessary.
    - Implements logic to correctly package and share single or multiple split APK files using `FileProvider`.
This commit is contained in:
Moe Hamade 2026-01-16 14:46:53 +02:00 committed by Moe Hamade
parent c5964184e9
commit d05034c3c0

View File

@ -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>(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()
}
}
}