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

+
    +
  1. Tap the download button above
  2. +
  3. Wait for the download to complete
  4. +
  5. Open the downloaded APK file
  6. +
  7. If prompted, enable "Install from unknown sources" for your browser
  8. +
  9. Follow the installation prompts
  10. +
+
+ +
+ ⚠️ 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 @@ + +