diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index f83ecefb..f4dc784b 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -22,11 +22,14 @@
-
+
-
+
+
+
+
@@ -140,5 +143,13 @@
+
+
+
diff --git a/app/src/main/java/com/bitchat/android/hotspot/ApkWebServer.kt b/app/src/main/java/com/bitchat/android/hotspot/ApkWebServer.kt
new file mode 100644
index 00000000..bb401310
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/hotspot/ApkWebServer.kt
@@ -0,0 +1,317 @@
+package com.bitchat.android.hotspot
+
+import android.content.Context
+import android.util.Log
+import fi.iki.elonen.NanoHTTPD
+import java.io.File
+import java.io.FileInputStream
+
+/**
+ * Lightweight HTTP server for serving the universal APK over Wi-Fi P2P hotspot.
+ * Based on NanoHTTPD.
+ */
+class ApkWebServer(
+ private val context: Context,
+ private val apkFile: File,
+ private val port: Int = DEFAULT_PORT
+) : NanoHTTPD(port) {
+
+ companion object {
+ private const val TAG = "ApkWebServer"
+ const val DEFAULT_PORT = 9999
+ }
+
+ private val appVersion: String by lazy {
+ try {
+ val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0)
+ packageInfo.versionName ?: "Unknown"
+ } catch (e: Exception) {
+ "Unknown"
+ }
+ }
+
+ override fun serve(session: IHTTPSession): Response {
+ val uri = session.uri ?: "/"
+
+ Log.d(TAG, "Request: ${session.method} $uri from ${session.remoteIpAddress}")
+
+ return when {
+ uri.endsWith(".apk") || uri == "/bitchat.apk" -> {
+ serveApk()
+ }
+ uri == "/favicon.ico" -> {
+ newFixedLengthResponse(Response.Status.NOT_FOUND, "text/plain", "Not found")
+ }
+ else -> {
+ serveLandingPage()
+ }
+ }
+ }
+
+ /**
+ * Serve the APK file.
+ */
+ private fun serveApk(): Response {
+ return try {
+ if (!apkFile.exists()) {
+ Log.e(TAG, "APK file not found: ${apkFile.path}")
+ return newFixedLengthResponse(
+ Response.Status.NOT_FOUND,
+ "text/plain",
+ "APK file not found"
+ )
+ }
+
+ Log.d(TAG, "Serving APK: ${apkFile.name} (${apkFile.length() / 1024 / 1024}MB)")
+
+ val inputStream = FileInputStream(apkFile)
+ val response = newFixedLengthResponse(
+ Response.Status.OK,
+ "application/vnd.android.package-archive",
+ inputStream,
+ apkFile.length()
+ )
+
+ response.addHeader("Content-Disposition", "attachment; filename=\"bitchat-${appVersion}.apk\"")
+ response.addHeader("Accept-Ranges", "bytes")
+
+ response
+ } catch (e: Exception) {
+ Log.e(TAG, "Error serving APK", e)
+ newFixedLengthResponse(
+ Response.Status.INTERNAL_ERROR,
+ "text/plain",
+ "Error serving APK: ${e.message}"
+ )
+ }
+ }
+
+ /**
+ * Serve the HTML landing page.
+ */
+ private fun serveLandingPage(): Response {
+ val html = generateLandingPageHtml()
+ return newFixedLengthResponse(
+ Response.Status.OK,
+ "text/html",
+ html
+ )
+ }
+
+ /**
+ * Generate HTML landing page.
+ */
+ private fun generateLandingPageHtml(): String {
+ val apkSizeMb = apkFile.length() / 1024 / 1024
+
+ return """
+
+
+
+
+
+ Download BitChat
+
+
+
+
+
🔒
+
BitChat
+
Secure Mesh Messaging
+
+
+
+
Version
+
$appVersion
+
+
+
Size
+
${apkSizeMb} MB
+
+
+
+
+ 📥 Download BitChat
+
+
+
+
📱 Installation Instructions
+
+ - Tap the download button above
+ - Wait for the download to complete
+ - Open the downloaded APK file
+ - If prompted, enable "Install from unknown sources" for your browser
+ - Follow the installation prompts
+
+
+
+
+ ⚠️ Note:
+ If you already have BitChat installed, you may need to uninstall it first before installing this version. Make sure to backup your data if needed.
+
+
+
+
+ """.trimIndent()
+ }
+
+ /**
+ * Start the server.
+ */
+ fun startServer() {
+ try {
+ start(NanoHTTPD.SOCKET_READ_TIMEOUT, false)
+ Log.d(TAG, "Web server started on port $port")
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to start web server", e)
+ throw e
+ }
+ }
+
+ /**
+ * Stop the server.
+ */
+ fun stopServer() {
+ try {
+ stop()
+ Log.d(TAG, "Web server stopped")
+ } catch (e: Exception) {
+ Log.e(TAG, "Error stopping web server", e)
+ }
+ }
+}
diff --git a/app/src/main/java/com/bitchat/android/hotspot/HotspotActivity.kt b/app/src/main/java/com/bitchat/android/hotspot/HotspotActivity.kt
new file mode 100644
index 00000000..250c7065
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/hotspot/HotspotActivity.kt
@@ -0,0 +1,683 @@
+package com.bitchat.android.hotspot
+
+import android.Manifest
+import android.content.Intent
+import android.graphics.Bitmap
+import android.os.Build
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.viewModels
+import androidx.compose.animation.Crossfade
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.ContentCopy
+import androidx.compose.material.icons.filled.Wifi
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.platform.LocalClipboardManager
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.bitchat.android.ui.theme.BitchatTheme
+import com.bitchat.android.util.UniversalApkManager
+import com.google.accompanist.permissions.ExperimentalPermissionsApi
+import com.google.accompanist.permissions.isGranted
+import com.google.accompanist.permissions.rememberPermissionState
+import com.google.accompanist.permissions.shouldShowRationale
+import java.io.File
+
+/**
+ * Activity for managing Wi-Fi P2P hotspot for offline APK sharing.
+ * Pure Compose implementation, no fragments.
+ */
+class HotspotActivity : ComponentActivity() {
+
+ companion object {
+ const val EXTRA_APK_PATH = "apk_path"
+ private const val TAG = "HotspotActivity"
+ }
+
+ private val viewModel: HotspotViewModel by viewModels()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ // Get APK path from intent
+ val apkPath = intent.getStringExtra(EXTRA_APK_PATH)
+ val apkFile = if (apkPath != null) {
+ File(apkPath)
+ } else {
+ // Fallback: Try to get cached APK
+ UniversalApkManager(this).getCachedApk()
+ }
+
+ if (apkFile == null || !apkFile.exists()) {
+ // No APK available, show error and finish
+ finish()
+ return
+ }
+
+ setContent {
+ BitchatTheme {
+ HotspotScreen(
+ viewModel = viewModel,
+ apkFile = apkFile,
+ onClose = { finish() }
+ )
+ }
+ }
+ }
+
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+ // Handle notification action to stop hotspot
+ if (intent.action == "STOP_HOTSPOT") {
+ viewModel.stopHotspot()
+ finish()
+ }
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ viewModel.stopHotspot()
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun HotspotScreen(
+ viewModel: HotspotViewModel,
+ apkFile: File,
+ onClose: () -> Unit
+) {
+ val state by viewModel.state.collectAsStateWithLifecycle()
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(
+ text = "Share BitChat",
+ fontFamily = FontFamily.Monospace
+ )
+ },
+ navigationIcon = {
+ IconButton(onClick = onClose) {
+ Icon(Icons.Default.Close, contentDescription = "Close")
+ }
+ },
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.surface
+ )
+ )
+ }
+ ) { padding ->
+ Crossfade(
+ targetState = state,
+ label = "HotspotStateCrossfade",
+ modifier = Modifier.padding(padding)
+ ) { currentState ->
+ when (currentState) {
+ is HotspotViewModel.HotspotState.Intro -> {
+ IntroScreen(
+ onStartHotspot = { viewModel.startHotspot(apkFile) }
+ )
+ }
+ is HotspotViewModel.HotspotState.Starting -> {
+ LoadingScreen()
+ }
+ is HotspotViewModel.HotspotState.Active -> {
+ ActiveHotspotScreen(state = currentState)
+ }
+ is HotspotViewModel.HotspotState.Error -> {
+ ErrorScreen(
+ message = currentState.message,
+ onRetry = { viewModel.resetToIntro() },
+ onClose = onClose
+ )
+ }
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalPermissionsApi::class)
+@Composable
+fun IntroScreen(onStartHotspot: () -> Unit) {
+ // Determine which permission to request based on Android version
+ val requiredPermission = when {
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> Manifest.permission.NEARBY_WIFI_DEVICES
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> Manifest.permission.ACCESS_FINE_LOCATION
+ else -> null // No runtime permission needed on Android < 10
+ }
+
+ val permissionState = requiredPermission?.let { rememberPermissionState(it) }
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(24.dp)
+ .verticalScroll(rememberScrollState()),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(24.dp)
+ ) {
+ Spacer(modifier = Modifier.height(32.dp))
+
+ Icon(
+ imageVector = Icons.Default.Wifi,
+ contentDescription = null,
+ modifier = Modifier.size(80.dp),
+ tint = MaterialTheme.colorScheme.primary
+ )
+
+ Text(
+ text = "Offline App Sharing",
+ style = MaterialTheme.typography.headlineMedium,
+ fontWeight = FontWeight.Bold
+ )
+
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant
+ )
+ ) {
+ Column(
+ modifier = Modifier.padding(20.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Text(
+ text = "How it works:",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold
+ )
+ InfoItem("1. Your device creates a Wi-Fi hotspot")
+ InfoItem("2. Others connect to your hotspot")
+ InfoItem("3. They scan a QR code or enter a URL")
+ InfoItem("4. BitChat downloads directly to their device")
+ }
+ }
+
+ // Permission rationale (if needed)
+ if (permissionState != null && !permissionState.status.isGranted && permissionState.status.shouldShowRationale) {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.primaryContainer
+ )
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Text(
+ text = "ℹ️ Permission Required",
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onPrimaryContainer
+ )
+ Text(
+ text = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ "BitChat needs nearby devices permission to create a Wi-Fi hotspot for sharing the app offline."
+ } else {
+ "BitChat needs location permission to create a Wi-Fi hotspot. This is required by Android for Wi-Fi scanning, but no location data is collected."
+ },
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f)
+ )
+ }
+ }
+ }
+
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f)
+ )
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Text(
+ text = "⚠️ Note",
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.error
+ )
+ Text(
+ text = "This will create a Wi-Fi hotspot on your device. Your current Wi-Fi connection may be interrupted.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.weight(1f))
+
+ Button(
+ onClick = {
+ // Check permission before starting hotspot
+ if (permissionState == null || permissionState.status.isGranted) {
+ // No permission needed or already granted
+ onStartHotspot()
+ } else {
+ // Request permission
+ permissionState.launchPermissionRequest()
+ }
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(56.dp),
+ shape = RoundedCornerShape(16.dp)
+ ) {
+ Text(
+ text = if (permissionState != null && !permissionState.status.isGranted) {
+ "Grant Permission"
+ } else {
+ "Start Hotspot"
+ },
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold
+ )
+ }
+ }
+}
+
+@Composable
+fun InfoItem(text: String) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.Top
+ ) {
+ Text(
+ text = "•",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.primary
+ )
+ Text(
+ text = text,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f)
+ )
+ }
+}
+
+@Composable
+fun LoadingScreen() {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(48.dp)
+ )
+ Text(
+ text = "Starting hotspot...",
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
+ )
+ }
+ }
+}
+
+@Composable
+fun ActiveHotspotScreen(state: HotspotViewModel.HotspotState.Active) {
+ var selectedTab by remember { mutableStateOf(0) }
+ val tabs = listOf("Wi-Fi", "Website")
+
+ Column(
+ modifier = Modifier.fillMaxSize()
+ ) {
+ // Status banner
+ Surface(
+ color = MaterialTheme.colorScheme.primaryContainer,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Row(
+ modifier = Modifier
+ .padding(16.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column {
+ Text(
+ text = "Hotspot Active",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onPrimaryContainer
+ )
+ Text(
+ text = "${state.connectedPeers} device(s) connected",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
+ )
+ }
+ Icon(
+ imageVector = Icons.Default.Wifi,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(32.dp)
+ )
+ }
+ }
+
+ // Tabs
+ TabRow(
+ selectedTabIndex = selectedTab,
+ containerColor = MaterialTheme.colorScheme.surface,
+ contentColor = MaterialTheme.colorScheme.primary
+ ) {
+ tabs.forEachIndexed { index, title ->
+ Tab(
+ selected = selectedTab == index,
+ onClick = { selectedTab = index },
+ text = {
+ Text(
+ text = title,
+ fontFamily = FontFamily.Monospace,
+ fontWeight = if (selectedTab == index) FontWeight.Bold else FontWeight.Normal
+ )
+ }
+ )
+ }
+ }
+
+ // Tab content
+ when (selectedTab) {
+ 0 -> WifiTabContent(
+ ssid = state.ssid,
+ password = state.password
+ )
+ 1 -> WebsiteTabContent(
+ ipAddress = state.ipAddress,
+ port = state.port
+ )
+ }
+ }
+}
+
+@Composable
+fun WifiTabContent(ssid: String, password: String) {
+ val clipboardManager = LocalClipboardManager.current
+ val context = LocalContext.current
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(24.dp)
+ ) {
+ Text(
+ text = "Step 1: Connect to Wi-Fi",
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Bold,
+ textAlign = TextAlign.Center
+ )
+
+ Text(
+ text = "Have others scan this QR code to connect:",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
+ textAlign = TextAlign.Center
+ )
+
+ // QR Code
+ val qrSize = with(LocalDensity.current) { 280.dp.toPx().toInt() }
+ val wifiQr = remember(ssid, password, qrSize) {
+ QrCodeGenerator.generateWifiQr(ssid, password, qrSize)
+ }
+
+ if (wifiQr != null) {
+ Box(
+ modifier = Modifier
+ .clip(RoundedCornerShape(16.dp))
+ .background(Color.White)
+ .padding(16.dp)
+ ) {
+ Image(
+ bitmap = wifiQr.asImageBitmap(),
+ contentDescription = "Wi-Fi QR Code",
+ modifier = Modifier.size(280.dp)
+ )
+ }
+ }
+
+ Text(
+ text = "Or enter manually:",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
+ )
+
+ // SSID
+ CredentialCard(
+ label = "Network Name (SSID)",
+ value = ssid,
+ onCopy = {
+ clipboardManager.setText(AnnotatedString(ssid))
+ }
+ )
+
+ // Password
+ CredentialCard(
+ label = "Password",
+ value = password,
+ onCopy = {
+ clipboardManager.setText(AnnotatedString(password))
+ }
+ )
+ }
+}
+
+@Composable
+fun WebsiteTabContent(ipAddress: String, port: Int) {
+ val url = "http://$ipAddress:$port"
+ val clipboardManager = LocalClipboardManager.current
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(24.dp)
+ ) {
+ Text(
+ text = "Step 2: Download BitChat",
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Bold,
+ textAlign = TextAlign.Center
+ )
+
+ Text(
+ text = "After connecting to the Wi-Fi, scan this QR code:",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
+ textAlign = TextAlign.Center
+ )
+
+ // QR Code
+ val qrSize = with(LocalDensity.current) { 280.dp.toPx().toInt() }
+ val urlQr = remember(url, qrSize) {
+ QrCodeGenerator.generateUrlQr(url, qrSize)
+ }
+
+ if (urlQr != null) {
+ Box(
+ modifier = Modifier
+ .clip(RoundedCornerShape(16.dp))
+ .background(Color.White)
+ .padding(16.dp)
+ ) {
+ Image(
+ bitmap = urlQr.asImageBitmap(),
+ contentDescription = "Website URL QR Code",
+ modifier = Modifier.size(280.dp)
+ )
+ }
+ }
+
+ Text(
+ text = "Or open in browser:",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
+ )
+
+ // URL
+ CredentialCard(
+ label = "Website URL",
+ value = url,
+ onCopy = {
+ clipboardManager.setText(AnnotatedString(url))
+ }
+ )
+
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant
+ )
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Text(
+ text = "📱 Instructions",
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.Bold
+ )
+ Text(
+ text = "1. Make sure you're connected to the Wi-Fi network above\n" +
+ "2. Open a web browser on your device\n" +
+ "3. Visit the URL above or scan the QR code\n" +
+ "4. Tap 'Download BitChat'\n" +
+ "5. Install the downloaded APK",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun CredentialCard(
+ label: String,
+ value: String,
+ onCopy: () -> Unit
+) {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.secondaryContainer
+ )
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = label,
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.7f)
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text = value,
+ style = MaterialTheme.typography.bodyLarge,
+ fontFamily = FontFamily.Monospace,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onSecondaryContainer
+ )
+ }
+ IconButton(onClick = onCopy) {
+ Icon(
+ imageVector = Icons.Default.ContentCopy,
+ contentDescription = "Copy",
+ tint = MaterialTheme.colorScheme.onSecondaryContainer
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun ErrorScreen(
+ message: String,
+ onRetry: () -> Unit,
+ onClose: () -> Unit
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ Text(
+ text = "❌",
+ fontSize = 64.sp
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Text(
+ text = "Error",
+ style = MaterialTheme.typography.headlineMedium,
+ fontWeight = FontWeight.Bold
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Text(
+ text = message,
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
+ )
+
+ Spacer(modifier = Modifier.height(32.dp))
+
+ Button(
+ onClick = onRetry,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text("Try Again")
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ TextButton(onClick = onClose) {
+ Text("Close")
+ }
+ }
+}
diff --git a/app/src/main/java/com/bitchat/android/hotspot/HotspotManager.kt b/app/src/main/java/com/bitchat/android/hotspot/HotspotManager.kt
new file mode 100644
index 00000000..b64e12b2
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/hotspot/HotspotManager.kt
@@ -0,0 +1,435 @@
+package com.bitchat.android.hotspot
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.net.wifi.p2p.WifiP2pConfig
+import android.net.wifi.p2p.WifiP2pGroup
+import android.net.wifi.p2p.WifiP2pManager
+import android.net.wifi.p2p.WifiP2pManager.*
+import android.os.Build
+import android.os.Handler
+import android.os.Looper
+import android.os.PowerManager
+import android.util.Log
+import java.net.NetworkInterface
+import java.security.SecureRandom
+import kotlin.random.Random
+
+/**
+ * Manages Wi-Fi P2P (Wi-Fi Direct) hotspot for offline APK sharing.
+ * Based on Briar's implementation.
+ */
+class HotspotManager(private val context: Context) {
+
+ companion object {
+ private const val TAG = "HotspotMgr"
+
+ // Retry configuration
+ private const val MAX_FRAMEWORK_ATTEMPTS = 5
+ private const val RETRY_DELAY_MILLIS = 1000L
+
+ // Group info polling interval
+ private const val GROUP_INFO_POLL_INTERVAL_MILLIS = 1000L
+
+ // SSID and password configuration
+ private const val SSID_PREFIX = "DIRECT-BC-" // BC for BitChat
+ private const val SSID_SUFFIX_LENGTH = 8
+ private const val PASSWORD_LENGTH = 16
+
+ // Characters to use for random generation (excluding confusing ones)
+ private const val RANDOM_CHARS = "ABCDEFGHJKLMNPQRTUVWXY34679" // No 0,O,5,S,1,l,I
+ }
+
+ private val wifiP2pManager: WifiP2pManager? =
+ context.getSystemService(Context.WIFI_P2P_SERVICE) as? WifiP2pManager
+
+ private var channel: Channel? = null
+ private var wakeLock: PowerManager.WakeLock? = null
+ private var wifiLock: android.net.wifi.WifiManager.WifiLock? = null
+
+ private val handler = Handler(Looper.getMainLooper())
+ private val random = SecureRandom()
+
+ private var currentGroup: WifiP2pGroup? = null
+ private var callback: HotspotCallback? = null
+ private var isStarting = false
+ private var hasNotifiedStarted = false // Track if we've notified the callback
+
+ // Saved credentials for reconnection
+ private var savedSsid: String? = null
+ private var savedPassword: String? = null
+
+ // Broadcast receiver for Wi-Fi P2P events
+ private val broadcastReceiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ when (intent.action) {
+ WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION -> {
+ val state = intent.getIntExtra(WifiP2pManager.EXTRA_WIFI_STATE, -1)
+ Log.d(TAG, "Wi-Fi P2P state changed: $state")
+ }
+ WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION -> {
+ Log.d(TAG, "Wi-Fi P2P connection changed")
+ requestGroupInfo()
+ }
+ }
+ }
+ }
+
+ /**
+ * Start the Wi-Fi P2P hotspot.
+ */
+ fun startHotspot(callback: HotspotCallback) {
+ if (isStarting) {
+ Log.w(TAG, "Hotspot already starting")
+ return
+ }
+
+ if (wifiP2pManager == null) {
+ Log.e(TAG, "Wi-Fi P2P not available on this device")
+ callback.onError("Wi-Fi Direct not supported on this device")
+ return
+ }
+
+ this.callback = callback
+ isStarting = true
+
+ Log.d(TAG, "Starting Wi-Fi P2P hotspot")
+
+ // Register broadcast receiver
+ val intentFilter = IntentFilter().apply {
+ addAction(WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION)
+ addAction(WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION)
+ }
+ context.registerReceiver(broadcastReceiver, intentFilter)
+
+ // Acquire locks
+ acquireLocks()
+
+ // Load or generate credentials
+ if (savedSsid == null || savedPassword == null) {
+ savedSsid = generateSsid()
+ savedPassword = generatePassword()
+ Log.d(TAG, "Generated new credentials: SSID=$savedSsid")
+ } else {
+ Log.d(TAG, "Using saved credentials: SSID=$savedSsid")
+ }
+
+ // Start P2P framework with retries
+ startWifiP2pFramework(1)
+ }
+
+ /**
+ * Stop the hotspot.
+ */
+ fun stopHotspot() {
+ Log.d(TAG, "Stopping hotspot")
+
+ isStarting = false
+ hasNotifiedStarted = false
+
+ // Stop group info polling
+ handler.removeCallbacksAndMessages(null)
+
+ // Remove group
+ channel?.let { ch ->
+ wifiP2pManager?.removeGroup(ch, object : ActionListener {
+ override fun onSuccess() {
+ Log.d(TAG, "Group removed successfully")
+ }
+ override fun onFailure(reason: Int) {
+ Log.w(TAG, "Failed to remove group: $reason")
+ }
+ })
+ }
+
+ // Release locks
+ releaseLocks()
+
+ // Unregister receiver
+ try {
+ context.unregisterReceiver(broadcastReceiver)
+ } catch (e: Exception) {
+ Log.w(TAG, "Error unregistering receiver", e)
+ }
+
+ currentGroup = null
+ channel = null
+ callback = null
+ }
+
+ /**
+ * Get current connection information.
+ */
+ fun getConnectionInfo(): ConnectionInfo? {
+ val group = currentGroup ?: return null
+ val ipAddress = getAccessPointAddress()
+
+ return ConnectionInfo(
+ ssid = group.networkName ?: savedSsid ?: "",
+ password = group.passphrase ?: savedPassword ?: "",
+ ipAddress = ipAddress ?: "192.168.49.1", // Fallback to standard P2P IP
+ connectedPeers = group.clientList?.size ?: 0
+ )
+ }
+
+ /**
+ * Start Wi-Fi P2P framework with retry logic.
+ */
+ private fun startWifiP2pFramework(attempt: Int) {
+ if (attempt > MAX_FRAMEWORK_ATTEMPTS) {
+ Log.e(TAG, "Failed to start P2P framework after $MAX_FRAMEWORK_ATTEMPTS attempts")
+ isStarting = false
+ callback?.onError("Failed to start hotspot. Please try again.")
+ return
+ }
+
+ Log.d(TAG, "Starting P2P framework (attempt $attempt/$MAX_FRAMEWORK_ATTEMPTS)")
+
+ channel = wifiP2pManager?.initialize(context, Looper.getMainLooper(), null)
+
+ if (channel == null) {
+ Log.e(TAG, "Failed to initialize P2P channel")
+ handler.postDelayed({
+ startWifiP2pFramework(attempt + 1)
+ }, RETRY_DELAY_MILLIS)
+ return
+ }
+
+ createGroup(attempt)
+ }
+
+ /**
+ * Create Wi-Fi P2P group.
+ */
+ private fun createGroup(attempt: Int) {
+ val ch = channel ?: return
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ // Android 10+: Custom SSID and password
+ val config = WifiP2pConfig.Builder()
+ .setNetworkName(savedSsid!!)
+ .setPassphrase(savedPassword!!)
+ .setGroupOperatingBand(WifiP2pConfig.GROUP_OWNER_BAND_2GHZ) // Force 2.4GHz for compatibility
+ .build()
+
+ wifiP2pManager?.createGroup(ch, config, object : ActionListener {
+ override fun onSuccess() {
+ Log.d(TAG, "P2P group created successfully")
+ isStarting = false
+ // Don't call onHotspotStarted() yet - wait for group info
+ startGroupInfoPolling()
+ }
+
+ override fun onFailure(reason: Int) {
+ handleGroupCreationFailure(reason, attempt)
+ }
+ })
+ } else {
+ // Android 9 and below: System-generated SSID/password
+ wifiP2pManager?.createGroup(ch, object : ActionListener {
+ override fun onSuccess() {
+ Log.d(TAG, "P2P group created successfully")
+ isStarting = false
+ // Don't call onHotspotStarted() yet - wait for group info
+ startGroupInfoPolling()
+ }
+
+ override fun onFailure(reason: Int) {
+ handleGroupCreationFailure(reason, attempt)
+ }
+ })
+ }
+ }
+
+ /**
+ * Handle group creation failure with retry logic.
+ */
+ private fun handleGroupCreationFailure(reason: Int, attempt: Int) {
+ val reasonStr = when (reason) {
+ ERROR -> "ERROR"
+ P2P_UNSUPPORTED -> "P2P_UNSUPPORTED"
+ BUSY -> "BUSY"
+ else -> "UNKNOWN($reason)"
+ }
+
+ Log.w(TAG, "Failed to create group: $reasonStr")
+
+ if (reason == BUSY && attempt < MAX_FRAMEWORK_ATTEMPTS) {
+ // Framework is busy, retry
+ Log.d(TAG, "P2P framework busy, retrying...")
+ handler.postDelayed({
+ startWifiP2pFramework(attempt + 1)
+ }, RETRY_DELAY_MILLIS)
+ } else {
+ isStarting = false
+ callback?.onError("Failed to create hotspot: $reasonStr")
+ }
+ }
+
+ /**
+ * Start polling for group info to track connected clients.
+ */
+ private fun startGroupInfoPolling() {
+ requestGroupInfo()
+
+ handler.postDelayed(object : Runnable {
+ override fun run() {
+ if (channel != null && currentGroup != null) {
+ requestGroupInfo()
+ handler.postDelayed(this, GROUP_INFO_POLL_INTERVAL_MILLIS)
+ }
+ }
+ }, GROUP_INFO_POLL_INTERVAL_MILLIS)
+ }
+
+ /**
+ * Request current group information.
+ */
+ private fun requestGroupInfo() {
+ val ch = channel ?: return
+
+ wifiP2pManager?.requestGroupInfo(ch) { group ->
+ if (group != null) {
+ currentGroup = group
+
+ // Update saved credentials if using system-generated ones
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
+ savedSsid = group.networkName
+ savedPassword = group.passphrase
+ }
+
+ // Notify callback on FIRST successful group info retrieval
+ if (!hasNotifiedStarted) {
+ hasNotifiedStarted = true
+ Log.d(TAG, "Group info received, notifying callback")
+ callback?.onHotspotStarted()
+ } else {
+ // Subsequent updates
+ callback?.onConnectionInfoUpdated(getConnectionInfo())
+ }
+ } else {
+ Log.w(TAG, "requestGroupInfo returned null group")
+ }
+ }
+ }
+
+ /**
+ * Acquire WakeLock and WifiLock to keep hotspot active.
+ */
+ private fun acquireLocks() {
+ try {
+ val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager
+ wakeLock = powerManager.newWakeLock(
+ PowerManager.FULL_WAKE_LOCK,
+ "BitChat:HotspotWakeLock"
+ )
+ wakeLock?.acquire(10 * 60 * 1000L) // 10 minutes max
+
+ val wifiManager = context.getSystemService(Context.WIFI_SERVICE) as android.net.wifi.WifiManager
+ val lockType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ android.net.wifi.WifiManager.WIFI_MODE_FULL_HIGH_PERF
+ } else {
+ android.net.wifi.WifiManager.WIFI_MODE_FULL
+ }
+ wifiLock = wifiManager.createWifiLock(lockType, "BitChat:HotspotWifiLock")
+ wifiLock?.acquire()
+
+ Log.d(TAG, "Acquired WakeLock and WifiLock")
+ } catch (e: Exception) {
+ Log.e(TAG, "Error acquiring locks", e)
+ }
+ }
+
+ /**
+ * Release WakeLock and WifiLock.
+ */
+ private fun releaseLocks() {
+ try {
+ wakeLock?.let {
+ if (it.isHeld) {
+ it.release()
+ }
+ }
+ wakeLock = null
+
+ wifiLock?.let {
+ if (it.isHeld) {
+ it.release()
+ }
+ }
+ wifiLock = null
+
+ Log.d(TAG, "Released WakeLock and WifiLock")
+ } catch (e: Exception) {
+ Log.e(TAG, "Error releasing locks", e)
+ }
+ }
+
+ /**
+ * Get the IP address of the P2P access point.
+ * Looks for network interface starting with "p2p".
+ */
+ private fun getAccessPointAddress(): String? {
+ try {
+ val interfaces = NetworkInterface.getNetworkInterfaces()
+ while (interfaces.hasMoreElements()) {
+ val iface = interfaces.nextElement()
+ if (iface.name.startsWith("p2p")) {
+ val addresses = iface.interfaceAddresses
+ for (addr in addresses) {
+ val address = addr.address
+ // IPv4 only (4 bytes)
+ if (address.address.size == 4) {
+ return address.hostAddress
+ }
+ }
+ }
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Error getting access point address", e)
+ }
+ return null
+ }
+
+ /**
+ * Generate random SSID.
+ * Format: DIRECT-BC-XXXXXXXX
+ */
+ private fun generateSsid(): String {
+ val suffix = (1..SSID_SUFFIX_LENGTH)
+ .map { RANDOM_CHARS[random.nextInt(RANDOM_CHARS.length)] }
+ .joinToString("")
+ return "$SSID_PREFIX$suffix"
+ }
+
+ /**
+ * Generate random password.
+ * 16 characters, excluding confusing characters.
+ */
+ private fun generatePassword(): String {
+ return (1..PASSWORD_LENGTH)
+ .map { RANDOM_CHARS[random.nextInt(RANDOM_CHARS.length)] }
+ .joinToString("")
+ }
+
+ /**
+ * Connection information for the hotspot.
+ */
+ data class ConnectionInfo(
+ val ssid: String,
+ val password: String,
+ val ipAddress: String,
+ val connectedPeers: Int
+ )
+
+ /**
+ * Callback interface for hotspot events.
+ */
+ interface HotspotCallback {
+ fun onHotspotStarted()
+ fun onConnectionInfoUpdated(info: ConnectionInfo?)
+ fun onError(message: String)
+ }
+}
diff --git a/app/src/main/java/com/bitchat/android/hotspot/HotspotViewModel.kt b/app/src/main/java/com/bitchat/android/hotspot/HotspotViewModel.kt
new file mode 100644
index 00000000..ef2cee86
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/hotspot/HotspotViewModel.kt
@@ -0,0 +1,146 @@
+package com.bitchat.android.hotspot
+
+import android.app.Application
+import android.util.Log
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import java.io.File
+
+/**
+ * ViewModel for managing hotspot state and lifecycle.
+ */
+class HotspotViewModel(application: Application) : AndroidViewModel(application) {
+
+ companion object {
+ private const val TAG = "HotspotViewModel"
+ }
+
+ private val _state = MutableStateFlow(HotspotState.Intro)
+ val state: StateFlow = _state.asStateFlow()
+
+ private var hotspotManager: HotspotManager? = null
+ private var webServer: ApkWebServer? = null
+ private val context = application.applicationContext
+
+ /**
+ * Start the hotspot with the provided APK file.
+ */
+ fun startHotspot(apkFile: File) {
+ if (_state.value is HotspotState.Starting || _state.value is HotspotState.Active) {
+ Log.w(TAG, "Hotspot already starting or active")
+ return
+ }
+
+ Log.d(TAG, "Starting hotspot with APK: ${apkFile.name}")
+ _state.value = HotspotState.Starting
+
+ viewModelScope.launch {
+ try {
+ // Start hotspot
+ val manager = HotspotManager(context)
+ hotspotManager = manager
+
+ manager.startHotspot(object : HotspotManager.HotspotCallback {
+ override fun onHotspotStarted() {
+ Log.d(TAG, "Hotspot started successfully")
+
+ // Get connection info
+ val info = manager.getConnectionInfo()
+ if (info == null) {
+ _state.value = HotspotState.Error("Failed to get hotspot connection info")
+ return
+ }
+
+ // Start web server
+ try {
+ val server = ApkWebServer(context, apkFile)
+ server.startServer()
+ webServer = server
+
+ Log.d(TAG, "Web server started on port ${ApkWebServer.DEFAULT_PORT}")
+
+ // Update state with connection info
+ _state.value = HotspotState.Active(
+ ssid = info.ssid,
+ password = info.password,
+ ipAddress = info.ipAddress,
+ port = ApkWebServer.DEFAULT_PORT,
+ connectedPeers = info.connectedPeers
+ )
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to start web server", e)
+ manager.stopHotspot()
+ _state.value = HotspotState.Error("Failed to start web server: ${e.message}")
+ }
+ }
+
+ override fun onConnectionInfoUpdated(info: HotspotManager.ConnectionInfo?) {
+ // Update peer count if we're active
+ val currentState = _state.value
+ if (currentState is HotspotState.Active && info != null) {
+ _state.value = currentState.copy(connectedPeers = info.connectedPeers)
+ }
+ }
+
+ override fun onError(message: String) {
+ Log.e(TAG, "Hotspot error: $message")
+ _state.value = HotspotState.Error(message)
+ }
+ })
+
+ } catch (e: Exception) {
+ Log.e(TAG, "Error starting hotspot", e)
+ _state.value = HotspotState.Error(e.message ?: "Unknown error")
+ }
+ }
+ }
+
+ /**
+ * Stop the hotspot and web server.
+ */
+ fun stopHotspot() {
+ Log.d(TAG, "Stopping hotspot")
+
+ webServer?.stopServer()
+ webServer = null
+
+ hotspotManager?.stopHotspot()
+ hotspotManager = null
+
+ _state.value = HotspotState.Intro
+ }
+
+ /**
+ * Reset to intro state (for retry after error).
+ */
+ fun resetToIntro() {
+ stopHotspot()
+ _state.value = HotspotState.Intro
+ }
+
+ override fun onCleared() {
+ super.onCleared()
+ Log.d(TAG, "ViewModel cleared, stopping hotspot")
+ stopHotspot()
+ }
+
+ /**
+ * Hotspot state sealed class.
+ */
+ sealed class HotspotState {
+ object Intro : HotspotState()
+ object Starting : HotspotState()
+ data class Active(
+ val ssid: String,
+ val password: String,
+ val ipAddress: String,
+ val port: Int,
+ val connectedPeers: Int
+ ) : HotspotState()
+ data class Error(val message: String) : HotspotState()
+ }
+}
diff --git a/app/src/main/java/com/bitchat/android/util/ApkInstaller.kt b/app/src/main/java/com/bitchat/android/util/ApkInstaller.kt
new file mode 100644
index 00000000..e6e87f28
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/util/ApkInstaller.kt
@@ -0,0 +1,170 @@
+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/ApkSharingUtils.kt b/app/src/main/java/com/bitchat/android/util/ApkSharingUtils.kt
new file mode 100644
index 00000000..3d6558bc
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/util/ApkSharingUtils.kt
@@ -0,0 +1,131 @@
+package com.bitchat.android.util
+
+import android.content.Context
+import android.util.Log
+import java.io.File
+
+/**
+ * Utility object for sharing the installed APK with other users.
+ * Supports both single APK installations (direct downloads) and split APK installations (Play Store AAB).
+ */
+object ApkSharingUtils {
+ private const val TAG = "ApkSharingUtils"
+ private const val APK_SHARE_DIR = "apk_share"
+
+ /**
+ * Detects if the app is installed as split APKs (from AAB) or single APK.
+ *
+ * @param context Application context
+ * @return Pair> where:
+ * - First: true if split APKs, false if single APK
+ * - Second: List of APK files to share
+ */
+ fun detectAndCollectApks(context: Context): Pair> {
+ val applicationInfo = context.applicationInfo
+ val sourceDir = applicationInfo.sourceDir // Base APK path
+ val splitSourceDirs = applicationInfo.splitSourceDirs // Split APKs (null if single)
+
+ return if (splitSourceDirs.isNullOrEmpty()) {
+ // Single APK installation (direct download/sideload)
+ Log.d(TAG, "Detected single APK installation: $sourceDir")
+ Pair(false, listOf(File(sourceDir)))
+ } else {
+ // Split APK installation (Play Store AAB)
+ val apkFiles = mutableListOf(File(sourceDir)) // Base APK
+ splitSourceDirs.forEach { splitPath ->
+ apkFiles.add(File(splitPath))
+ }
+ Log.d(TAG, "Detected split APK installation with ${apkFiles.size} files")
+ Pair(true, apkFiles)
+ }
+ }
+
+ /**
+ * Prepares APKs for sharing by copying them to the cache directory with friendly names.
+ * This is necessary because APKs in /data/app/ are not directly accessible for sharing.
+ *
+ * @param context Application context
+ * @return List of cached APK files ready for sharing, or null on error
+ */
+ fun prepareApksForSharing(context: Context): List? {
+ return try {
+ val (isSplit, apkFiles) = detectAndCollectApks(context)
+
+ // Create cache subdirectory for APKs
+ val cacheDir = File(context.cacheDir, APK_SHARE_DIR).apply {
+ if (exists()) {
+ deleteRecursively() // Clean old files
+ Log.d(TAG, "Cleaned existing APK share cache")
+ }
+ mkdirs()
+ }
+
+ val cachedFiles = mutableListOf()
+
+ apkFiles.forEachIndexed { index, sourceFile ->
+ if (!sourceFile.exists()) {
+ Log.e(TAG, "Source APK not found: ${sourceFile.path}")
+ return null
+ }
+
+ // Generate friendly names
+ val fileName = when {
+ isSplit && index == 0 -> "bitchat-base.apk"
+ isSplit -> {
+ // Extract split name (e.g., config.arm64_v8a, config.xxhdpi)
+ val splitName = sourceFile.name
+ .replace("split_config.", "")
+ .replace("split_", "")
+ .replace(".apk", "")
+ "bitchat-$splitName.apk"
+ }
+ else -> "bitchat.apk"
+ }
+
+ val destFile = File(cacheDir, fileName)
+ sourceFile.copyTo(destFile, overwrite = true)
+ cachedFiles.add(destFile)
+ Log.d(TAG, "Copied ${sourceFile.name} -> ${destFile.name} (${destFile.length()} bytes)")
+ }
+
+ Log.d(TAG, "Prepared ${cachedFiles.size} APK(s) for sharing, total size: ${cachedFiles.sumOf { it.length() }} bytes")
+
+ // TEMPORARY DEBUG: Also copy to Downloads for testing
+ try {
+ val downloadsDir = android.os.Environment.getExternalStoragePublicDirectory(android.os.Environment.DIRECTORY_DOWNLOADS)
+ cachedFiles.forEach { cachedFile ->
+ val testFile = File(downloadsDir, "debug-${cachedFile.name}")
+ cachedFile.copyTo(testFile, overwrite = true)
+ Log.d(TAG, "DEBUG: Copied to Downloads for testing: ${testFile.absolutePath} (${testFile.length()} bytes)")
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "DEBUG: Could not copy to Downloads (this is OK)", e)
+ }
+
+ cachedFiles
+
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to prepare APKs for sharing", e)
+ null
+ }
+ }
+
+ /**
+ * Cleans up the cached APKs after sharing.
+ * This prevents accumulation of APK copies in the cache directory.
+ *
+ * @param context Application context
+ */
+ fun cleanupSharedApks(context: Context) {
+ try {
+ val cacheDir = File(context.cacheDir, APK_SHARE_DIR)
+ if (cacheDir.exists()) {
+ val deletedFiles = cacheDir.listFiles()?.size ?: 0
+ cacheDir.deleteRecursively()
+ Log.d(TAG, "Cleaned up $deletedFiles shared APK file(s)")
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to cleanup shared APKs", e)
+ }
+ }
+}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 27a5c5e5..4659377f 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -142,6 +142,61 @@
Privacy Protected
cancel
+
+ Share BitChat
+ Share installation file for offline distribution
+ Share BitChat App
+ This will share the BitChat installation file(s) so others can install the app without internet access. Perfect for mesh network expansion!
+ The receiver will need to:\n• Enable \"Install from unknown sources\" in Android settings\n• Uninstall BitChat first if already installed (signatures differ)
+ Share App
+ Share BitChat via…
+ Failed to prepare app for sharing. Please try again.
+
+
+ Prepare App for Sharing
+ Download universal APK for offline sharing
+ Not ready • Tap to download
+ Ready to share
+ Downloading… %1$d%%
+ Update available
+ Prepare
+ Update
+ Delete
+ Version %1$s • %2$d MB
+ Download Universal APK?
+ This will download the universal APK (~%1$d MB) from GitHub releases. You only need to do this once.
+ Download
+ Downloading Universal APK
+ Downloading %1$d MB…
+ Verifying checksum…
+ Universal APK ready!
+ Network error. Check your connection.
+ Checksum verification failed. Please try again.
+ Not enough storage space.
+ Failed to fetch release info from GitHub.
+ Delete cached APK?
+ This will free up ~%1$d MB of storage.
+ Update Available
+ A newer version (%1$s) is available. Current: %2$s
+ Please prepare the app for sharing first.
+
+
+ Share via Hotspot
+ Share via Bluetooth/Email
+
+
+ Install Received APK
+ Install BitChat from received files
+ Install BitChat Update
+ Install BitChat from the received APK file(s)? This allows offline app distribution in mesh networks.
+ Permission Required
+ BitChat needs permission to install packages for self-distribution. Please enable \"Install unknown apps\" in the next screen.
+ Install
+ Grant Permission
+ Select APK Files
+ Failed to install APK. Please try again.
+ No APK files selected.
+
Warning
Location Services
diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml
index 725040b7..d85ee1d1 100644
--- a/app/src/main/res/xml/file_paths.xml
+++ b/app/src/main/res/xml/file_paths.xml
@@ -6,4 +6,8 @@
+
+