mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-29 07:16:08 +00:00
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:
parent
c5964184e9
commit
d05034c3c0
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user