diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 7ee54989..84ea0cd9 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -6,6 +6,21 @@ plugins {
alias(libs.plugins.kotlin.compose)
}
+val githubReleaseCertSha256 = providers
+ .environmentVariable("BITCHAT_GITHUB_RELEASE_CERT_SHA256")
+ .orElse(providers.gradleProperty("BITCHAT_GITHUB_RELEASE_CERT_SHA256"))
+ .orElse("")
+val normalizedGithubReleaseCertSha256 = githubReleaseCertSha256.get()
+ .replace(":", "")
+ .trim()
+ .lowercase()
+require(
+ normalizedGithubReleaseCertSha256.isEmpty() ||
+ normalizedGithubReleaseCertSha256.matches(Regex("[a-f0-9]{64}"))
+) {
+ "BITCHAT_GITHUB_RELEASE_CERT_SHA256 must be a SHA-256 certificate fingerprint"
+}
+
android {
namespace = "com.bitchat.android"
compileSdk = libs.versions.compileSdk.get().toInt()
@@ -16,6 +31,11 @@ android {
targetSdk = libs.versions.targetSdk.get().toInt()
versionCode = 36
versionName = "1.7.5"
+ buildConfigField(
+ "String",
+ "GITHUB_RELEASE_CERT_SHA256",
+ "\"$normalizedGithubReleaseCertSha256\""
+ )
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -71,6 +91,7 @@ android {
}
buildFeatures {
compose = true
+ buildConfig = true
}
packaging {
resources {
@@ -134,6 +155,12 @@ dependencies {
// WebSocket
implementation(libs.okhttp)
+ // WorkManager for background APK downloads
+ implementation(libs.androidx.work.runtime.ktx)
+
+ // HTTP Server for hotspot APK sharing
+ implementation(libs.nanohttpd)
+
// Arti (Tor in Rust) Android bridge - custom build from latest source
// Built with rustls, 16KB page size support, and onio//un service client
// Native libraries are in src/tor/jniLibs/ (extracted from arti-custom.aar)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 8c732bbc..0c71b94c 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -22,13 +22,18 @@
-
+
-
-
+
+
+
+
+
@@ -142,5 +147,20 @@
+
+
+
+
+
+
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..140f2971
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/hotspot/ApkWebServer.kt
@@ -0,0 +1,323 @@
+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 {
+ context.packageManager
+ .getPackageArchiveInfo(apkFile.absolutePath, 0)
+ ?.versionName
+ ?: "Unknown"
+ } catch (e: Exception) {
+ "Unknown"
+ }
+ }
+
+ // Cache the HTML landing page (generated once, reused for all requests)
+ private val cachedHtml: String by lazy {
+ generateLandingPageHtml()
+ }
+
+ override fun serve(session: IHTTPSession): Response {
+ val uri = session.uri ?: "/"
+
+ Log.d(TAG, "Request: ${session.method} $uri from ${session.remoteIpAddress}")
+
+ return when {
+ 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 {
+ return newFixedLengthResponse(
+ Response.Status.OK,
+ "text/html",
+ cachedHtml
+ )
+ }
+
+ /**
+ * 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..795cce34
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/hotspot/HotspotActivity.kt
@@ -0,0 +1,684 @@
+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()
+ }
+ }
+
+}
+
+@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) { granted ->
+ if (granted) {
+ onStartHotspot()
+ }
+ }
+ }
+
+ 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 (auto-start handled by onPermissionResult callback)
+ permissionState.launchPermissionRequest()
+ }
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(56.dp),
+ shape = RoundedCornerShape(16.dp)
+ ) {
+ Text(
+ // Starting the hotspot is the user's action. Android will ask
+ // for the required permission only when it has not already
+ // been granted.
+ text = "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..bf2f3a93
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/hotspot/HotspotManager.kt
@@ -0,0 +1,503 @@
+package com.bitchat.android.hotspot
+
+import android.Manifest
+import android.annotation.SuppressLint
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.content.pm.PackageManager
+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 androidx.core.content.ContextCompat
+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
+
+ // Give up if the group never forms within this window after creation succeeded
+ private const val GROUP_FORMATION_TIMEOUT_MILLIS = 15_000L
+
+ // 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
+ private var isReceiverRegistered = false // Track receiver registration to prevent leaks
+
+ // 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
+ }
+
+ val missingPermission = requiredRuntimePermission()?.takeUnless {
+ ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
+ }
+ if (missingPermission != null) {
+ Log.w(TAG, "Cannot start hotspot without $missingPermission")
+ callback.onError("Nearby Wi-Fi permission is required to start the hotspot")
+ return
+ }
+
+ this.callback = callback
+ isStarting = true
+
+ Log.d(TAG, "Starting Wi-Fi P2P hotspot")
+
+ // Register broadcast receiver (only if not already registered)
+ if (!isReceiverRegistered) {
+ val intentFilter = IntentFilter().apply {
+ addAction(WIFI_P2P_STATE_CHANGED_ACTION)
+ addAction(WIFI_P2P_CONNECTION_CHANGED_ACTION)
+ }
+ context.registerReceiver(broadcastReceiver, intentFilter)
+ isReceiverRegistered = true
+ Log.d(TAG, "Broadcast receiver registered")
+ }
+
+ // 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 (only if registered)
+ if (isReceiverRegistered) {
+ try {
+ context.unregisterReceiver(broadcastReceiver)
+ isReceiverRegistered = false
+ Log.d(TAG, "Broadcast receiver unregistered")
+ } catch (e: IllegalArgumentException) {
+ Log.w(TAG, "Receiver was not registered", e)
+ isReceiverRegistered = false
+ }
+ }
+
+ 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")
+ failStartup("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.
+ */
+ @SuppressLint("MissingPermission")
+ private fun createGroup(attempt: Int) {
+ val ch = channel ?: return
+
+ try {
+ 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, groupActionListener(attempt, ch))
+ } else {
+ // Android 9 and below: System-generated SSID/password
+ wifiP2pManager?.createGroup(ch, groupActionListener(attempt, ch))
+ }
+ } catch (e: SecurityException) {
+ Log.e(TAG, "Wi-Fi permission was revoked while creating the group", e)
+ failStartup("Nearby Wi-Fi permission was revoked. Grant it and try again.")
+ }
+ }
+
+ private fun groupActionListener(attempt: Int, requestChannel: Channel) = object : ActionListener {
+ override fun onSuccess() {
+ if (channel !== requestChannel) {
+ Log.w(TAG, "Removing group created after hotspot was stopped")
+ wifiP2pManager?.removeGroup(requestChannel, null)
+ return
+ }
+ Log.d(TAG, "P2P group created successfully")
+ isStarting = false
+ // Don't call onHotspotStarted() yet - wait for group info
+ startGroupInfoPolling()
+ }
+
+ override fun onFailure(reason: Int) {
+ if (channel != null) {
+ 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 {
+ failStartup("Failed to create hotspot: $reasonStr")
+ }
+ }
+
+ /**
+ * Terminal startup failure: release all resources (locks, receiver, handler
+ * callbacks) before notifying the callback, so a failed attempt doesn't leak
+ * and block subsequent attempts.
+ */
+ private fun failStartup(message: String) {
+ val cb = callback
+ stopHotspot()
+ cb?.onError(message)
+ }
+
+ /**
+ * Start polling for group info to track connected clients.
+ */
+ private fun startGroupInfoPolling() {
+ requestGroupInfo()
+
+ // Keep polling even while the group info is still null — the first
+ // requestGroupInfo() after createGroup() can legitimately return null
+ // while the group is forming. Give up only after a timeout.
+ var elapsedMillis = 0L
+ handler.postDelayed(object : Runnable {
+ override fun run() {
+ if (channel == null) return
+
+ elapsedMillis += GROUP_INFO_POLL_INTERVAL_MILLIS
+ if (currentGroup == null && !hasNotifiedStarted &&
+ elapsedMillis >= GROUP_FORMATION_TIMEOUT_MILLIS
+ ) {
+ Log.e(TAG, "Group never formed within ${GROUP_FORMATION_TIMEOUT_MILLIS}ms")
+ failStartup("Hotspot failed to start. Please try again.")
+ return
+ }
+
+ requestGroupInfo()
+ handler.postDelayed(this, GROUP_INFO_POLL_INTERVAL_MILLIS)
+ }
+ }, GROUP_INFO_POLL_INTERVAL_MILLIS)
+ }
+
+ /**
+ * Request current group information.
+ */
+ @SuppressLint("MissingPermission")
+ private fun requestGroupInfo() {
+ val ch = channel ?: return
+
+ try {
+ 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")
+ }
+ }
+ } catch (e: SecurityException) {
+ Log.e(TAG, "Wi-Fi permission was revoked while reading group info", e)
+ failStartup("Nearby Wi-Fi permission was revoked. Grant it and try again.")
+ }
+ }
+
+ private fun requiredRuntimePermission(): String? {
+ return 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
+ }
+ }
+
+ /**
+ * 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.PARTIAL_WAKE_LOCK,
+ "BitChat:HotspotWakeLock"
+ )
+ wakeLock?.acquire(30 * 60 * 1000L)
+
+ 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..50b04855
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/hotspot/HotspotViewModel.kt
@@ -0,0 +1,154 @@
+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() {
+ viewModelScope.launch {
+ Log.d(TAG, "Hotspot started successfully")
+
+ // Get connection info
+ val info = manager.getConnectionInfo()
+ if (info == null) {
+ manager.stopHotspot()
+ _state.value = HotspotState.Error("Failed to get hotspot connection info")
+ return@launch
+ }
+
+ // 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?) {
+ viewModelScope.launch {
+ // 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) {
+ viewModelScope.launch {
+ Log.e(TAG, "Hotspot error: $message")
+ _state.value = HotspotState.Error(message)
+ }
+ }
+ })
+
+ } catch (e: Exception) {
+ Log.e(TAG, "Error starting hotspot", e)
+ hotspotManager?.stopHotspot()
+ _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/hotspot/QrCodeGenerator.kt b/app/src/main/java/com/bitchat/android/hotspot/QrCodeGenerator.kt
new file mode 100644
index 00000000..69c2bdf1
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/hotspot/QrCodeGenerator.kt
@@ -0,0 +1,126 @@
+package com.bitchat.android.hotspot
+
+import android.graphics.Bitmap
+import android.util.Log
+import androidx.core.graphics.createBitmap
+import androidx.core.graphics.set
+import com.google.zxing.BarcodeFormat
+import com.google.zxing.common.BitMatrix
+import com.google.zxing.qrcode.QRCodeWriter
+
+/**
+ * Utility for generating QR codes for Wi-Fi connection and URL.
+ */
+object QrCodeGenerator {
+
+ private const val TAG = "QrCodeGenerator"
+
+ /**
+ * Generate QR code for Wi-Fi connection.
+ * Format: WIFI:S:{SSID};T:WPA;P:{PASSWORD};;
+ *
+ * This format is recognized by most Android/iOS devices for instant Wi-Fi connection.
+ *
+ * @param ssid Wi-Fi network name
+ * @param password Wi-Fi password
+ * @param sizePx Size of the QR code in pixels
+ * @return Bitmap of the QR code, or null on error
+ */
+ fun generateWifiQr(ssid: String, password: String, sizePx: Int): Bitmap? {
+ if (ssid.isBlank() || password.isBlank()) {
+ Log.w(TAG, "SSID or password is blank")
+ return null
+ }
+
+ // Escape special characters
+ val escapedSsid = escapeWifiString(ssid)
+ val escapedPassword = escapeWifiString(password)
+
+ // Format: WIFI:S:{SSID};T:WPA;P:{PASSWORD};;
+ val wifiString = "WIFI:S:$escapedSsid;T:WPA;P:$escapedPassword;;"
+
+ Log.d(TAG, "Generating Wi-Fi QR code for SSID: $ssid")
+
+ return generateQrBitmap(wifiString, sizePx)
+ }
+
+ /**
+ * Generate QR code for URL.
+ *
+ * @param url Website URL (e.g., "http://192.168.49.1:9999")
+ * @param sizePx Size of the QR code in pixels
+ * @return Bitmap of the QR code, or null on error
+ */
+ fun generateUrlQr(url: String, sizePx: Int): Bitmap? {
+ if (url.isBlank()) {
+ Log.w(TAG, "URL is blank")
+ return null
+ }
+
+ Log.d(TAG, "Generating URL QR code: $url")
+
+ return generateQrBitmap(url, sizePx)
+ }
+
+ /**
+ * Generate QR code bitmap from string data.
+ *
+ * @param data String data to encode
+ * @param sizePx Size of the QR code in pixels
+ * @return Bitmap of the QR code, or null on error
+ */
+ private fun generateQrBitmap(data: String, sizePx: Int): Bitmap? {
+ if (data.isBlank() || sizePx <= 0) {
+ Log.w(TAG, "Invalid data or size: data.length=${data.length}, sizePx=$sizePx")
+ return null
+ }
+
+ return try {
+ val matrix = QRCodeWriter().encode(
+ data,
+ BarcodeFormat.QR_CODE,
+ sizePx,
+ sizePx
+ )
+ bitmapFromMatrix(matrix)
+ } catch (e: Exception) {
+ Log.e(TAG, "Error generating QR code", e)
+ null
+ }
+ }
+
+ /**
+ * Convert BitMatrix to Bitmap.
+ * Pattern from VerificationSheet.kt.
+ */
+ private fun bitmapFromMatrix(matrix: BitMatrix): Bitmap {
+ val width = matrix.width
+ val height = matrix.height
+ val bitmap = createBitmap(width, height)
+
+ for (x in 0 until width) {
+ for (y in 0 until height) {
+ bitmap[x, y] = if (matrix[x, y]) {
+ android.graphics.Color.BLACK
+ } else {
+ android.graphics.Color.WHITE
+ }
+ }
+ }
+
+ return bitmap
+ }
+
+ /**
+ * Escape special characters in Wi-Fi SSID/password for QR code format.
+ * Special characters that need escaping: \ ; , " :
+ */
+ private fun escapeWifiString(input: String): String {
+ return input
+ .replace("\\", "\\\\") // Backslash must be escaped first
+ .replace(";", "\\;")
+ .replace(",", "\\,")
+ .replace("\"", "\\\"")
+ .replace(":", "\\:")
+ }
+}
diff --git a/app/src/main/java/com/bitchat/android/net/ArtiTorManager.kt b/app/src/main/java/com/bitchat/android/net/ArtiTorManager.kt
index 8dc0cad8..e3490576 100644
--- a/app/src/main/java/com/bitchat/android/net/ArtiTorManager.kt
+++ b/app/src/main/java/com/bitchat/android/net/ArtiTorManager.kt
@@ -168,6 +168,27 @@ class ArtiTorManager private constructor() {
fun currentSocksAddress(): InetSocketAddress? = socksAddr
+ /**
+ * Wait until the currently selected HTTP route can be used.
+ *
+ * When Tor mode is enabled, [socksAddr] is intentionally published before
+ * bootstrap completes so clients fail closed instead of leaking traffic
+ * directly. Callers that initiate one-shot HTTP work should wait here rather
+ * than repeatedly connecting to a SOCKS port that is not listening yet.
+ */
+ suspend fun awaitSelectedRoute(timeoutMs: Long): Boolean {
+ if (currentSocksAddress() == null || isProxyEnabled()) {
+ return true
+ }
+
+ return withTimeoutOrNull(timeoutMs) {
+ statusFlow.first {
+ currentSocksAddress() == null || isProxyEnabled()
+ }
+ true
+ } ?: false
+ }
+
suspend fun applyMode(application: Application, mode: TorMode) {
applyMutex.withLock {
try {
diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt
index f137ac63..7a64ff50 100644
--- a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt
+++ b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt
@@ -1,40 +1,91 @@
package com.bitchat.android.ui
+import android.content.Intent
+import android.widget.Toast
+import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
-import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bluetooth
+import androidx.compose.material.icons.filled.ChevronRight
+import androidx.compose.material.icons.filled.CloudDownload
+import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Public
-import androidx.compose.material.icons.filled.Warning
import androidx.compose.material.icons.filled.Security
+import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.Speed
-import androidx.compose.material3.*
-import androidx.compose.runtime.*
+import androidx.compose.material.icons.filled.Warning
+import androidx.compose.material.icons.filled.Wifi
+import androidx.compose.material.icons.outlined.Info
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.OutlinedTextFieldDefaults
+import androidx.compose.material3.Slider
+import androidx.compose.material3.SliderDefaults
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Switch
+import androidx.compose.material3.SwitchDefaults
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
-import com.bitchat.android.nostr.NostrProofOfWork
-import com.bitchat.android.nostr.PoWPreferenceManager
-import androidx.compose.ui.res.stringResource
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.viewmodel.compose.viewModel
import com.bitchat.android.R
import com.bitchat.android.core.ui.component.button.CloseButton
import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet
+import com.bitchat.android.hotspot.HotspotActivity
+import com.bitchat.android.net.ArtiTorManager
import com.bitchat.android.net.TorMode
import com.bitchat.android.net.TorPreferenceManager
-import com.bitchat.android.net.ArtiTorManager
+import com.bitchat.android.nostr.NostrProofOfWork
+import com.bitchat.android.nostr.PoWPreferenceManager
+import com.bitchat.android.util.UniversalApkManager
/**
* Feature row for displaying app capabilities
@@ -452,6 +503,358 @@ fun AboutSheet(
}
} else null
)
+
+ HorizontalDivider(
+ modifier = Modifier.padding(start = 56.dp),
+ color = colorScheme.outline.copy(alpha = 0.12f)
+ )
+
+ // === Prepare App for Sharing Section ===
+ val apkViewModel: ApkDownloadViewModel = viewModel()
+ val apkUiState by apkViewModel.state.collectAsStateWithLifecycle()
+ val apkStatus = apkUiState.apkStatus
+ val downloadProgress = apkUiState.downloadProgress
+
+ // Handle one-shot effects (navigation, toasts, share intents)
+ LaunchedEffect(Unit) {
+ apkViewModel.onEvent(ApkUiEvent.CheckStatus)
+ apkViewModel.effect.collect { effect ->
+ when (effect) {
+ is ApkUiEffect.NavigateToHotspot -> {
+ val intent = Intent(context, HotspotActivity::class.java)
+ intent.putExtra(HotspotActivity.EXTRA_APK_PATH, effect.apkPath)
+ context.startActivity(intent)
+ }
+ is ApkUiEffect.ShareApk -> {
+ val intent = Intent(Intent.ACTION_SEND).apply {
+ type = "application/vnd.android.package-archive"
+ putExtra(Intent.EXTRA_STREAM, effect.apkUri)
+ clipData = android.content.ClipData.newRawUri("", effect.apkUri)
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+ val chooser = Intent.createChooser(intent, effect.chooserTitle).apply {
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+ context.startActivity(chooser)
+ }
+ is ApkUiEffect.ShowToast -> {
+ Toast.makeText(context, effect.message, Toast.LENGTH_SHORT).show()
+ }
+ }
+ }
+ }
+
+ // Prepare App for Sharing Row
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(enabled = apkStatus !is ApkPreparationStatus.Downloading) {
+ apkViewModel.onEvent(ApkUiEvent.PrepareRowClicked)
+ }
+ .padding(horizontal = 16.dp, vertical = 14.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = if (apkStatus is ApkPreparationStatus.Ready) {
+ Icons.Default.Share
+ } else {
+ Icons.Default.CloudDownload
+ },
+ contentDescription = null,
+ tint = colorScheme.primary,
+ modifier = Modifier.size(22.dp)
+ )
+
+ Spacer(modifier = Modifier.width(14.dp))
+
+ Column(
+ modifier = Modifier.weight(1f),
+ verticalArrangement = Arrangement.spacedBy(2.dp)
+ ) {
+ Text(
+ text = if (apkStatus is ApkPreparationStatus.Ready) {
+ stringResource(R.string.prepare_apk_ready_title)
+ } else {
+ stringResource(R.string.prepare_apk_title)
+ },
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.Medium,
+ color = colorScheme.onSurface
+ )
+ Text(
+ text = when (val status = apkStatus) {
+ is ApkPreparationStatus.Loading -> stringResource(R.string.checking)
+ is ApkPreparationStatus.NotDownloaded -> stringResource(R.string.prepare_apk_status_not_downloaded)
+ is ApkPreparationStatus.Ready -> {
+ val source = if (status.source == UniversalApkManager.ApkSource.INSTALLED) {
+ stringResource(R.string.prepare_apk_source_installed)
+ } else {
+ stringResource(R.string.prepare_apk_source_github)
+ }
+ stringResource(R.string.prepare_apk_status_ready) +
+ " • ${status.version} • ${status.sizeMB} MB\n$source"
+ }
+ is ApkPreparationStatus.UpdateAvailable -> stringResource(R.string.prepare_apk_status_update_available) + " (${status.newVersion})"
+ is ApkPreparationStatus.Downloading -> stringResource(R.string.prepare_apk_status_downloading, downloadProgress)
+ is ApkPreparationStatus.Resumable -> "Tap to resume • ${status.progressPercent}% downloaded"
+ is ApkPreparationStatus.Error -> status.message
+ },
+ style = MaterialTheme.typography.bodySmall,
+ color = when (apkStatus) {
+ is ApkPreparationStatus.Error -> colorScheme.error
+ is ApkPreparationStatus.Resumable -> colorScheme.primary
+ is ApkPreparationStatus.UpdateAvailable -> colorScheme.primary
+ else -> colorScheme.onSurface.copy(alpha = 0.6f)
+ },
+ lineHeight = 16.sp
+ )
+ }
+
+ // Action buttons
+ when (apkStatus) {
+ is ApkPreparationStatus.Downloading -> {
+ CircularProgressIndicator(
+ modifier = Modifier.size(20.dp),
+ strokeWidth = 2.dp
+ )
+ }
+ is ApkPreparationStatus.Ready -> {
+ if (apkStatus.source == UniversalApkManager.ApkSource.GITHUB) {
+ androidx.compose.material3.IconButton(
+ onClick = { apkViewModel.onEvent(ApkUiEvent.DeleteClicked) },
+ modifier = Modifier.size(32.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Default.Delete,
+ contentDescription = "Delete",
+ tint = colorScheme.error,
+ modifier = Modifier.size(20.dp)
+ )
+ }
+ }
+ }
+ is ApkPreparationStatus.UpdateAvailable -> {
+ androidx.compose.material3.IconButton(
+ onClick = { apkViewModel.onEvent(ApkUiEvent.DeleteClicked) },
+ modifier = Modifier.size(32.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Default.Delete,
+ contentDescription = "Delete",
+ tint = colorScheme.error,
+ modifier = Modifier.size(20.dp)
+ )
+ }
+ }
+ else -> {}
+ }
+ }
+
+ // Prepare Dialog
+ if (apkUiState.showPrepareDialog) {
+ val status = apkStatus
+ val sizeMB: Int? = when (status) {
+ is ApkPreparationStatus.NotDownloaded -> status.sizeMB
+ is ApkPreparationStatus.UpdateAvailable -> status.newSizeMB
+ else -> null
+ }
+ AlertDialog(
+ onDismissRequest = { apkViewModel.onEvent(ApkUiEvent.DismissPrepareDialog) },
+ title = {
+ Text(
+ text = if (status is ApkPreparationStatus.UpdateAvailable) {
+ stringResource(R.string.prepare_apk_update_dialog_title)
+ } else {
+ stringResource(R.string.prepare_apk_dialog_title)
+ },
+ style = MaterialTheme.typography.titleLarge
+ )
+ },
+ text = {
+ Text(
+ text = if (status is ApkPreparationStatus.UpdateAvailable) {
+ stringResource(R.string.prepare_apk_update_dialog_message, status.newVersion, status.currentVersion)
+ } else if (sizeMB != null) {
+ stringResource(R.string.prepare_apk_dialog_message, sizeMB)
+ } else {
+ stringResource(R.string.prepare_apk_dialog_message_unknown_size)
+ },
+ style = MaterialTheme.typography.bodyMedium
+ )
+ },
+ confirmButton = {
+ Button(onClick = {
+ apkViewModel.onEvent(ApkUiEvent.ConfirmDownload)
+ }) {
+ Text(stringResource(R.string.prepare_apk_dialog_confirm))
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = { apkViewModel.onEvent(ApkUiEvent.DismissPrepareDialog) }) {
+ Text(stringResource(R.string.cancel))
+ }
+ },
+ containerColor = colorScheme.surface
+ )
+ }
+
+ // Delete Dialog
+ if (apkUiState.showDeleteDialog) {
+ val sizeMB = (apkStatus as? ApkPreparationStatus.Ready)?.sizeMB ?: 0
+ AlertDialog(
+ onDismissRequest = { apkViewModel.onEvent(ApkUiEvent.DismissDeleteDialog) },
+ title = {
+ Text(
+ text = stringResource(R.string.prepare_apk_delete_confirm),
+ style = MaterialTheme.typography.titleLarge
+ )
+ },
+ text = {
+ Text(
+ text = stringResource(R.string.prepare_apk_delete_message, sizeMB),
+ style = MaterialTheme.typography.bodyMedium
+ )
+ },
+ confirmButton = {
+ Button(
+ onClick = {
+ apkViewModel.onEvent(ApkUiEvent.ConfirmDelete)
+ },
+ colors = androidx.compose.material3.ButtonDefaults.buttonColors(
+ containerColor = colorScheme.error
+ )
+ ) {
+ Text("Delete")
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = { apkViewModel.onEvent(ApkUiEvent.DismissDeleteDialog) }) {
+ Text(stringResource(R.string.cancel))
+ }
+ },
+ containerColor = colorScheme.surface
+ )
+ }
+
+ // Show sharing rows only when APK is ready
+ val canShareAPK = apkStatus is ApkPreparationStatus.Ready ||
+ apkStatus is ApkPreparationStatus.UpdateAvailable
+
+ AnimatedVisibility(
+ visible = canShareAPK,
+ enter = fadeIn() + expandVertically(),
+ exit = fadeOut() + shrinkVertically()
+ ) {
+ Column {
+ HorizontalDivider(
+ modifier = Modifier.padding(start = 56.dp),
+ color = colorScheme.outline.copy(alpha = 0.12f)
+ )
+
+ // === Share via Hotspot Row ===
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable {
+ apkViewModel.onEvent(ApkUiEvent.HotspotShareClicked)
+ }
+ .padding(horizontal = 16.dp, vertical = 14.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = Icons.Default.Wifi,
+ contentDescription = null,
+ tint = colorScheme.primary,
+ modifier = Modifier.size(22.dp)
+ )
+
+ Spacer(modifier = Modifier.width(14.dp))
+
+ Column(
+ modifier = Modifier.weight(1f),
+ verticalArrangement = Arrangement.spacedBy(2.dp)
+ ) {
+ Text(
+ text = stringResource(R.string.hotspot_share_via),
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.Medium,
+ color = colorScheme.onSurface
+ )
+ Text(
+ text = stringResource(R.string.hotspot_share_via_subtitle),
+ style = MaterialTheme.typography.bodySmall,
+ color = colorScheme.onSurface.copy(alpha = 0.6f),
+ lineHeight = 16.sp
+ )
+ }
+
+ Icon(
+ imageVector = Icons.Default.ChevronRight,
+ contentDescription = null,
+ tint = colorScheme.onSurface.copy(alpha = 0.4f),
+ modifier = Modifier.size(20.dp)
+ )
+ }
+
+ HorizontalDivider(
+ modifier = Modifier.padding(start = 56.dp),
+ color = colorScheme.outline.copy(alpha = 0.12f)
+ )
+
+ // === Share via Bluetooth/Email Row (Fallback) ===
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { apkViewModel.onEvent(ApkUiEvent.AppShareClicked) }
+ .padding(horizontal = 16.dp, vertical = 14.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = Icons.Default.Bluetooth,
+ contentDescription = null,
+ tint = colorScheme.primary,
+ modifier = Modifier.size(22.dp)
+ )
+
+ Spacer(modifier = Modifier.width(14.dp))
+
+ Column(
+ modifier = Modifier.weight(1f),
+ verticalArrangement = Arrangement.spacedBy(2.dp)
+ ) {
+ Text(
+ text = stringResource(R.string.hotspot_share_other),
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.Medium,
+ color = colorScheme.onSurface
+ )
+ Text(
+ text = stringResource(R.string.hotspot_share_other_subtitle),
+ style = MaterialTheme.typography.bodySmall,
+ color = colorScheme.onSurface.copy(alpha = 0.6f),
+ lineHeight = 16.sp
+ )
+ }
+
+ Icon(
+ imageVector = Icons.Default.ChevronRight,
+ contentDescription = null,
+ tint = colorScheme.onSurface.copy(alpha = 0.4f),
+ modifier = Modifier.size(20.dp)
+ )
+ }
+
+ // APK Share Dialog
+ ApkShareExplanationDialog(
+ show = apkUiState.showShareApkDialog,
+ onConfirm = {
+ apkViewModel.onEvent(ApkUiEvent.ConfirmAppShare)
+ },
+ onDismiss = { apkViewModel.onEvent(ApkUiEvent.DismissShareDialog) }
+ )
+ }
+ }
+
}
}
@@ -741,3 +1144,91 @@ fun PasswordPromptDialog(
)
}
}
+
+
+/**
+ * Dialog explaining APK sharing feature before sharing
+ */
+@Composable
+private fun ApkShareExplanationDialog(
+ show: Boolean,
+ onConfirm: () -> Unit,
+ onDismiss: () -> Unit
+) {
+ if (show) {
+ val colorScheme = MaterialTheme.colorScheme
+
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ icon = {
+ Icon(
+ imageVector = Icons.Default.Share,
+ contentDescription = null,
+ tint = colorScheme.primary,
+ modifier = Modifier.size(32.dp)
+ )
+ },
+ title = {
+ Text(
+ text = stringResource(R.string.share_apk_title),
+ style = MaterialTheme.typography.titleLarge,
+ color = colorScheme.onSurface
+ )
+ },
+ text = {
+ Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ Text(
+ text = stringResource(R.string.share_apk_explanation),
+ style = MaterialTheme.typography.bodyMedium,
+ color = colorScheme.onSurface
+ )
+
+ // Info box with receiver instructions
+ Surface(
+ color = colorScheme.primaryContainer.copy(alpha = 0.3f),
+ shape = RoundedCornerShape(8.dp),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Row(
+ modifier = Modifier.padding(12.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.Top
+ ) {
+ Icon(
+ imageVector = Icons.Outlined.Info,
+ contentDescription = null,
+ tint = colorScheme.primary,
+ modifier = Modifier.size(20.dp)
+ )
+ Text(
+ text = stringResource(R.string.share_apk_receiver_instructions),
+ style = MaterialTheme.typography.bodySmall,
+ color = colorScheme.onSurface.copy(alpha = 0.8f),
+ lineHeight = 18.sp
+ )
+ }
+ }
+ }
+ },
+ confirmButton = {
+ Button(onClick = onConfirm) {
+ Text(
+ text = stringResource(R.string.share_apk_confirm),
+ style = MaterialTheme.typography.bodyMedium
+ )
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss) {
+ Text(
+ text = stringResource(R.string.cancel),
+ style = MaterialTheme.typography.bodyMedium,
+ color = colorScheme.onSurface
+ )
+ }
+ },
+ containerColor = colorScheme.surface,
+ tonalElevation = 8.dp
+ )
+ }
+}
diff --git a/app/src/main/java/com/bitchat/android/ui/ApkDownloadViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ApkDownloadViewModel.kt
new file mode 100644
index 00000000..0b4e0005
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/ui/ApkDownloadViewModel.kt
@@ -0,0 +1,341 @@
+package com.bitchat.android.ui
+
+import android.app.Application
+import android.util.Log
+import androidx.core.content.FileProvider
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import com.bitchat.android.R
+import com.bitchat.android.util.ApkDownloader
+import com.bitchat.android.util.UniversalApkManager
+import com.bitchat.android.util.WorkManagerApkDownloader
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+// --- State ---
+
+sealed class ApkPreparationStatus {
+ object Loading : ApkPreparationStatus()
+ data class NotDownloaded(val sizeMB: Int?) : ApkPreparationStatus()
+ data class Ready(
+ val version: String,
+ val sizeMB: Int,
+ val source: UniversalApkManager.ApkSource
+ ) : ApkPreparationStatus()
+ data class UpdateAvailable(
+ val currentVersion: String,
+ val newVersion: String,
+ val newSizeMB: Int
+ ) : ApkPreparationStatus()
+ object Downloading : ApkPreparationStatus()
+ data class Resumable(val progressPercent: Int, val message: String) : ApkPreparationStatus()
+ data class Error(val message: String) : ApkPreparationStatus()
+}
+
+data class ApkUiState(
+ val apkStatus: ApkPreparationStatus = ApkPreparationStatus.Loading,
+ val downloadProgress: Int = 0,
+ val showPrepareDialog: Boolean = false,
+ val showDeleteDialog: Boolean = false,
+ val showShareApkDialog: Boolean = false
+)
+
+// --- Events (UI → ViewModel) ---
+
+sealed class ApkUiEvent {
+ object CheckStatus : ApkUiEvent()
+ object PrepareRowClicked : ApkUiEvent()
+ object ConfirmDownload : ApkUiEvent()
+ object DismissPrepareDialog : ApkUiEvent()
+ object DeleteClicked : ApkUiEvent()
+ object ConfirmDelete : ApkUiEvent()
+ object DismissDeleteDialog : ApkUiEvent()
+ object HotspotShareClicked : ApkUiEvent()
+ object AppShareClicked : ApkUiEvent()
+ object ConfirmAppShare : ApkUiEvent()
+ object DismissShareDialog : ApkUiEvent()
+ object CancelDownload : ApkUiEvent()
+}
+
+// --- Effects (ViewModel → UI, one-shot) ---
+
+sealed class ApkUiEffect {
+ data class NavigateToHotspot(val apkPath: String) : ApkUiEffect()
+ data class ShareApk(val apkUri: android.net.Uri, val chooserTitle: String) : ApkUiEffect()
+ data class ShowToast(val message: String) : ApkUiEffect()
+}
+
+/**
+ * ViewModel for APK download/status/share logic following MVI pattern.
+ * UI sends [ApkUiEvent], observes [ApkUiState], and collects [ApkUiEffect].
+ */
+class ApkDownloadViewModel(application: Application) : AndroidViewModel(application) {
+
+ companion object {
+ private const val TAG = "ApkDownloadVM"
+ }
+
+ private val apkManager = UniversalApkManager(application)
+ private val downloader: ApkDownloader = WorkManagerApkDownloader(application)
+
+ private val _state = MutableStateFlow(ApkUiState())
+ val state: StateFlow = _state.asStateFlow()
+
+ private val _effect = Channel(Channel.BUFFERED)
+ val effect = _effect.receiveAsFlow()
+
+ init {
+ observeDownloader()
+ }
+
+ fun onEvent(event: ApkUiEvent) {
+ when (event) {
+ is ApkUiEvent.CheckStatus -> checkStatus()
+ is ApkUiEvent.PrepareRowClicked -> onPrepareRowClicked()
+ is ApkUiEvent.ConfirmDownload -> onConfirmDownload()
+ is ApkUiEvent.DismissPrepareDialog -> _state.update { it.copy(showPrepareDialog = false) }
+ is ApkUiEvent.DeleteClicked -> _state.update { it.copy(showDeleteDialog = true) }
+ is ApkUiEvent.ConfirmDelete -> onConfirmDelete()
+ is ApkUiEvent.DismissDeleteDialog -> _state.update { it.copy(showDeleteDialog = false) }
+ is ApkUiEvent.HotspotShareClicked -> onHotspotShareClicked()
+ is ApkUiEvent.AppShareClicked -> _state.update { it.copy(showShareApkDialog = true) }
+ is ApkUiEvent.ConfirmAppShare -> onConfirmAppShare()
+ is ApkUiEvent.DismissShareDialog -> _state.update { it.copy(showShareApkDialog = false) }
+ is ApkUiEvent.CancelDownload -> onCancelDownload()
+ }
+ }
+
+ private fun onPrepareRowClicked() {
+ when (_state.value.apkStatus) {
+ is ApkPreparationStatus.NotDownloaded,
+ is ApkPreparationStatus.UpdateAvailable,
+ is ApkPreparationStatus.Error -> {
+ _state.update { it.copy(showPrepareDialog = true) }
+ }
+ is ApkPreparationStatus.Resumable -> {
+ startDownload()
+ }
+ else -> {}
+ }
+ }
+
+ private fun onConfirmDownload() {
+ _state.update { it.copy(showPrepareDialog = false) }
+ startDownload()
+ }
+
+ private fun onConfirmDelete() {
+ _state.update { it.copy(showDeleteDialog = false) }
+ downloader.cancelDownload()
+ apkManager.deleteCachedApk()
+ checkStatus()
+ }
+
+ private fun onHotspotShareClicked() {
+ val apkFile = apkManager.getCachedApk()
+ if (apkFile != null) {
+ viewModelScope.launch {
+ _effect.send(ApkUiEffect.NavigateToHotspot(apkFile.absolutePath))
+ }
+ } else {
+ sendToast(getString(R.string.apk_not_ready_please_prepare_it_first))
+ }
+ }
+
+ private fun onConfirmAppShare() {
+ _state.update { it.copy(showShareApkDialog = false) }
+ viewModelScope.launch(Dispatchers.IO) {
+ try {
+ val apkFile = apkManager.getCachedApk()
+ if (apkFile == null || !apkFile.exists()) {
+ sendToast(getString(R.string.apk_not_ready_please_prepare_it_first))
+ return@launch
+ }
+
+ val context = getApplication()
+ val uri = FileProvider.getUriForFile(
+ context,
+ "${context.packageName}.fileprovider",
+ apkFile
+ )
+ _effect.send(
+ ApkUiEffect.ShareApk(
+ apkUri = uri,
+ chooserTitle = getString(R.string.share_apk_chooser_title)
+ )
+ )
+ } catch (e: Exception) {
+ Log.e(TAG, "Error preparing APK share", e)
+ sendToast(getString(R.string.share_apk_error))
+ }
+ }
+ }
+
+ private fun onCancelDownload() {
+ downloader.cancelDownload()
+ checkStatus()
+ }
+
+ private fun startDownload() {
+ val partial = apkManager.getPartialDownloadProgress()
+ _state.update {
+ it.copy(
+ apkStatus = ApkPreparationStatus.Downloading,
+ downloadProgress = partial ?: 0
+ )
+ }
+ downloader.startDownload()
+ }
+
+ private fun checkStatus() {
+ viewModelScope.launch {
+ // WorkManager is the source of truth for active work. A queued or
+ // newly started job legitimately has no partial file yet, so never
+ // infer that it is orphaned from cache contents.
+ if (_state.value.apkStatus is ApkPreparationStatus.Downloading) {
+ return@launch
+ }
+
+ val resolvedStatus = resolveApkStatus()
+ _state.update { current ->
+ if (current.apkStatus is ApkPreparationStatus.Downloading) {
+ current
+ } else {
+ current.copy(apkStatus = resolvedStatus)
+ }
+ }
+ }
+ }
+
+ private fun observeDownloader() {
+ viewModelScope.launch {
+ downloader.downloadState.collect { downloadState ->
+ when (downloadState) {
+ is ApkDownloader.DownloadState.Idle -> {
+ // Don't overwrite — status set by checkStatus()
+ }
+ is ApkDownloader.DownloadState.Downloading -> {
+ _state.update {
+ it.copy(
+ apkStatus = ApkPreparationStatus.Downloading,
+ downloadProgress = downloadState.progressPercent
+ )
+ }
+ }
+ is ApkDownloader.DownloadState.Success -> {
+ val info = apkManager.getCachedApkInfo()
+ _state.update {
+ it.copy(
+ apkStatus = ApkPreparationStatus.Ready(
+ version = downloadState.version,
+ sizeMB = downloadState.sizeMB,
+ source = info?.source ?: UniversalApkManager.ApkSource.GITHUB
+ ),
+ downloadProgress = 100
+ )
+ }
+ }
+ is ApkDownloader.DownloadState.Failed -> {
+ _state.update {
+ if (downloadState.resumablePercent != null) {
+ it.copy(
+ apkStatus = ApkPreparationStatus.Resumable(
+ progressPercent = downloadState.resumablePercent,
+ message = downloadState.message
+ ),
+ downloadProgress = downloadState.resumablePercent
+ )
+ } else {
+ it.copy(apkStatus = ApkPreparationStatus.Error(downloadState.message))
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ private fun sendToast(message: String) {
+ viewModelScope.launch {
+ _effect.send(ApkUiEffect.ShowToast(message))
+ }
+ }
+
+ private fun getString(resId: Int): String {
+ return getApplication().getString(resId)
+ }
+
+ private suspend fun resolveApkStatus(): ApkPreparationStatus = withContext(Dispatchers.IO) {
+ try {
+ val updateStatus = apkManager.checkForUpdate()
+ when (updateStatus) {
+ is UniversalApkManager.UpdateStatus.NotDownloaded -> {
+ val partial = apkManager.getPartialDownloadProgress()
+ if (partial != null) {
+ ApkPreparationStatus.Resumable(
+ progressPercent = partial,
+ message = getString(R.string.prepare_apk_download_interrupted)
+ )
+ } else {
+ ApkPreparationStatus.NotDownloaded(
+ sizeMB = (updateStatus.latestRelease.universalApkSize / 1024 / 1024).toInt()
+ )
+ }
+ }
+ is UniversalApkManager.UpdateStatus.UpToDate -> {
+ val info = apkManager.getCachedApkInfo()
+ if (info != null) {
+ ApkPreparationStatus.Ready(
+ version = info.version,
+ sizeMB = (info.size / 1024 / 1024).toInt(),
+ source = info.source
+ )
+ } else {
+ ApkPreparationStatus.Error("Cached APK info not found")
+ }
+ }
+ is UniversalApkManager.UpdateStatus.UpdateAvailable -> {
+ ApkPreparationStatus.UpdateAvailable(
+ currentVersion = updateStatus.currentVersion,
+ newVersion = updateStatus.latestRelease.versionName,
+ newSizeMB = (updateStatus.latestRelease.universalApkSize / 1024 / 1024).toInt()
+ )
+ }
+ is UniversalApkManager.UpdateStatus.Error -> {
+ // A cached artifact stays shareable even when the update
+ // check fails or the release lags the installed version.
+ val info = apkManager.getCachedApkInfo()
+ if (info != null) {
+ ApkPreparationStatus.Ready(
+ version = info.version,
+ sizeMB = (info.size / 1024 / 1024).toInt(),
+ source = info.source
+ )
+ } else {
+ val partial = apkManager.getPartialDownloadProgress()
+ if (partial != null) {
+ ApkPreparationStatus.Resumable(
+ progressPercent = partial,
+ message = getString(R.string.prepare_apk_download_interrupted)
+ )
+ } else {
+ ApkPreparationStatus.Error(updateStatus.message)
+ }
+ }
+ }
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Error checking APK status", e)
+ ApkPreparationStatus.Error(
+ e.message ?: getString(R.string.prepare_apk_error_github)
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt
index be950316..32427c44 100644
--- a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt
+++ b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt
@@ -1,5 +1,8 @@
package com.bitchat.android.ui.debug
+import android.content.ClipData
+import android.content.ClipboardManager
+import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
@@ -45,6 +48,9 @@ import com.bitchat.android.onboarding.PermissionManager
import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle
+import com.bitchat.android.util.DistributionInfoProvider
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
@Composable
fun MeshTopologySection(
@@ -102,6 +108,95 @@ fun MeshTopologySection(
}
}
+@Composable
+private fun DistributionInfoSection(info: DistributionInfoProvider.DistributionInfo?) {
+ val context = LocalContext.current
+ val colorScheme = MaterialTheme.colorScheme
+
+ Surface(
+ shape = RoundedCornerShape(12.dp),
+ color = colorScheme.surfaceVariant.copy(alpha = 0.2f)
+ ) {
+ Column(
+ Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(10.dp)
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Icon(Icons.Filled.Devices, contentDescription = null, tint = Color(0xFF5856D6))
+ Text(
+ "Distribution info",
+ fontFamily = FontFamily.Monospace,
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Medium
+ )
+ }
+
+ if (info == null) {
+ Text(
+ "Inspecting installed package…",
+ fontFamily = FontFamily.Monospace,
+ fontSize = 11.sp,
+ color = colorScheme.onSurface.copy(alpha = 0.6f)
+ )
+ } else {
+ DistributionInfoRow("Install source", info.installSource)
+ info.installerPackage?.let {
+ DistributionInfoRow("Installer package", it)
+ }
+ DistributionInfoRow("Package format", info.packageFormat)
+ DistributionInfoRow("APK architecture", info.architecture)
+ DistributionInfoRow("Sharing source", info.sharingSource)
+ DistributionInfoRow("Version", "${info.versionName} (${info.versionCode})")
+ DistributionInfoRow("Signing channel", info.signingChannel)
+ DistributionInfoRow(
+ label = "Certificate SHA-256",
+ value = info.certificateSha256 ?: "Unavailable"
+ )
+
+ if (info.certificateSha256 != null) {
+ TextButton(
+ onClick = {
+ val clipboard = context.getSystemService(ClipboardManager::class.java)
+ clipboard?.setPrimaryClip(
+ ClipData.newPlainText(
+ "BitChat signing certificate SHA-256",
+ info.certificateSha256
+ )
+ )
+ Toast.makeText(context, "Certificate fingerprint copied", Toast.LENGTH_SHORT).show()
+ },
+ contentPadding = PaddingValues(horizontal = 0.dp)
+ ) {
+ Text("Copy certificate fingerprint", fontFamily = FontFamily.Monospace)
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun DistributionInfoRow(label: String, value: String) {
+ val colorScheme = MaterialTheme.colorScheme
+ Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
+ Text(
+ label,
+ fontFamily = FontFamily.Monospace,
+ fontSize = 10.sp,
+ color = colorScheme.onSurface.copy(alpha = 0.55f)
+ )
+ Text(
+ value,
+ fontFamily = FontFamily.Monospace,
+ fontSize = 11.sp,
+ color = colorScheme.onSurface.copy(alpha = 0.9f)
+ )
+ }
+}
+
private enum class GraphMode { OVERALL, PER_DEVICE, PER_PEER }
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@@ -129,6 +224,9 @@ fun DebugSettingsSheet(
val gcsMaxBytes by manager.gcsMaxBytes.collectAsState()
val gcsFpr by manager.gcsFprPercent.collectAsState()
val context = LocalContext.current
+ var distributionInfo by remember {
+ mutableStateOf(null)
+ }
val bleEnabled by manager.bleEnabled.collectAsState()
val wifiAwareEnabled by manager.wifiAwareEnabled.collectAsState()
@@ -217,6 +315,14 @@ fun DebugSettingsSheet(
}
}
+ LaunchedEffect(isPresented) {
+ if (isPresented) {
+ distributionInfo = withContext(Dispatchers.IO) {
+ runCatching { DistributionInfoProvider.inspect(context) }.getOrNull()
+ }
+ }
+ }
+
val scope = rememberCoroutineScope()
if (!isPresented) return
@@ -246,6 +352,9 @@ fun DebugSettingsSheet(
color = colorScheme.onSurface.copy(alpha = 0.7f)
)
}
+ item {
+ DistributionInfoSection(distributionInfo)
+ }
// Verbose logging toggle
item {
Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) {
diff --git a/app/src/main/java/com/bitchat/android/util/ApkDownloadWorker.kt b/app/src/main/java/com/bitchat/android/util/ApkDownloadWorker.kt
new file mode 100644
index 00000000..b8285321
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/util/ApkDownloadWorker.kt
@@ -0,0 +1,161 @@
+package com.bitchat.android.util
+
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.content.Context
+import android.content.pm.ServiceInfo
+import android.os.Build
+import android.util.Log
+import androidx.core.app.NotificationCompat
+import androidx.work.CoroutineWorker
+import androidx.work.Data
+import androidx.work.ForegroundInfo
+import androidx.work.WorkManager
+import androidx.work.WorkerParameters
+import com.bitchat.android.R
+
+/**
+ * WorkManager worker that downloads the universal APK in the background.
+ * Survives app backgrounding and process death. Transient network errors are
+ * retried with backoff; partial downloads resume via HTTP Range requests.
+ *
+ * Runs as foreground (dataSync) work when possible so slow transfers (e.g.
+ * over Tor) are not killed by WorkManager's background execution window.
+ */
+class ApkDownloadWorker(
+ appContext: Context,
+ params: WorkerParameters
+) : CoroutineWorker(appContext, params) {
+
+ companion object {
+ const val TAG = "ApkDownloadWorker"
+ const val WORK_NAME = "apk_download"
+
+ // Progress keys
+ const val KEY_PROGRESS = "progress"
+ const val KEY_VERSION = "version"
+ const val KEY_SIZE_MB = "size_mb"
+ const val KEY_ERROR = "error"
+ const val KEY_RESUMABLE_PERCENT = "resumable_percent"
+
+ private const val MAX_RETRIES = 3
+
+ private const val CHANNEL_ID = "apk_download"
+ private const val NOTIFICATION_ID = 4201
+ private const val NOTIFY_STEP_PERCENT = 5
+ }
+
+ private val apkManager = UniversalApkManager(applicationContext)
+ private val notificationManager =
+ applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+
+ private var lastNotifiedProgress = -NOTIFY_STEP_PERCENT
+
+ override suspend fun doWork(): Result {
+ Log.d(TAG, "Starting APK download work")
+
+ // Promote to foreground so long transfers aren't stopped by the
+ // ~10-minute background execution window. Android 12+ can reject the
+ // promotion when the app is backgrounded — continue as regular
+ // background work and rely on Range-resume in that case.
+ try {
+ setForeground(createForegroundInfo(apkManager.getPartialDownloadProgress() ?: 0))
+ } catch (e: Exception) {
+ Log.w(TAG, "Could not promote download to foreground work", e)
+ }
+
+ val result = apkManager.downloadUniversalApk { progress ->
+ setProgressAsync(Data.Builder().putInt(KEY_PROGRESS, progress).build())
+ updateNotification(progress)
+ }
+
+ return if (result.isSuccess) {
+ val info = apkManager.getCachedApkInfo()
+ val outputData = Data.Builder()
+ .putString(KEY_VERSION, info?.version ?: "")
+ .putInt(KEY_SIZE_MB, ((info?.size ?: 0L) / 1024 / 1024).toInt())
+ .build()
+ Result.success(outputData)
+ } else {
+ val error = result.exceptionOrNull()
+
+ // Retry transient network errors with backoff; the partial file
+ // is kept on disk, so the retry resumes where it left off.
+ val isRetryable = when (error) {
+ is GitHubReleaseClient.ReleaseFetchException -> error.retryable
+ is java.io.IOException -> true
+ else -> false
+ }
+ if (isRetryable && runAttemptCount < MAX_RETRIES) {
+ Log.w(TAG, "Transient download error (attempt $runAttemptCount), retrying", error)
+ return Result.retry()
+ }
+
+ val partial = apkManager.getPartialDownloadProgress()
+ val outputData = Data.Builder()
+ .putString(KEY_ERROR, error?.message ?: "Download failed")
+ .putInt(KEY_RESUMABLE_PERCENT, partial ?: -1)
+ .build()
+ Result.failure(outputData)
+ }
+ }
+
+ override suspend fun getForegroundInfo(): ForegroundInfo {
+ return createForegroundInfo(apkManager.getPartialDownloadProgress() ?: 0)
+ }
+
+ private fun createForegroundInfo(progress: Int): ForegroundInfo {
+ ensureChannel()
+ val notification = buildNotification(progress)
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ ForegroundInfo(
+ NOTIFICATION_ID,
+ notification,
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
+ )
+ } else {
+ ForegroundInfo(NOTIFICATION_ID, notification)
+ }
+ }
+
+ private fun buildNotification(progress: Int): android.app.Notification {
+ val cancelIntent = WorkManager.getInstance(applicationContext)
+ .createCancelPendingIntent(id)
+
+ return NotificationCompat.Builder(applicationContext, CHANNEL_ID)
+ .setContentTitle(applicationContext.getString(R.string.apk_download_notification_title))
+ .setSmallIcon(R.drawable.ic_notification)
+ .setOngoing(true)
+ .setOnlyAlertOnce(true)
+ .setProgress(100, progress, progress <= 0)
+ .addAction(
+ android.R.drawable.ic_delete,
+ applicationContext.getString(android.R.string.cancel),
+ cancelIntent
+ )
+ .build()
+ }
+
+ private fun updateNotification(progress: Int) {
+ if (progress - lastNotifiedProgress < NOTIFY_STEP_PERCENT) return
+ lastNotifiedProgress = progress
+ try {
+ notificationManager.notify(NOTIFICATION_ID, buildNotification(progress))
+ } catch (e: Exception) {
+ // Missing POST_NOTIFICATIONS permission just drops the update;
+ // the download itself is unaffected.
+ Log.w(TAG, "Could not update download notification", e)
+ }
+ }
+
+ private fun ensureChannel() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val channel = NotificationChannel(
+ CHANNEL_ID,
+ applicationContext.getString(R.string.apk_download_channel_name),
+ NotificationManager.IMPORTANCE_LOW
+ )
+ notificationManager.createNotificationChannel(channel)
+ }
+ }
+}
diff --git a/app/src/main/java/com/bitchat/android/util/ApkDownloader.kt b/app/src/main/java/com/bitchat/android/util/ApkDownloader.kt
new file mode 100644
index 00000000..3bf234ae
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/util/ApkDownloader.kt
@@ -0,0 +1,36 @@
+package com.bitchat.android.util
+
+import kotlinx.coroutines.flow.Flow
+
+/**
+ * Interface for APK download operations.
+ * Abstracts the download mechanism so it can be swapped
+ * (e.g., WorkManager, ForegroundService, plain coroutine).
+ */
+interface ApkDownloader {
+
+ /**
+ * Current download state as an observable flow.
+ */
+ val downloadState: Flow
+
+ /**
+ * Start or resume a download. If a partial download exists, it resumes automatically.
+ */
+ fun startDownload()
+
+ /**
+ * Cancel an in-progress download. The partial file is kept for future resume.
+ */
+ fun cancelDownload()
+
+ /**
+ * Download state reported by the downloader.
+ */
+ sealed class DownloadState {
+ object Idle : DownloadState()
+ data class Downloading(val progressPercent: Int) : DownloadState()
+ data class Success(val version: String, val sizeMB: Int) : DownloadState()
+ data class Failed(val message: String, val resumablePercent: Int?) : DownloadState()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/bitchat/android/util/DistributionInfoProvider.kt b/app/src/main/java/com/bitchat/android/util/DistributionInfoProvider.kt
new file mode 100644
index 00000000..87fe3fb7
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/util/DistributionInfoProvider.kt
@@ -0,0 +1,191 @@
+package com.bitchat.android.util
+
+import android.content.Context
+import android.content.pm.PackageInfo
+import android.content.pm.PackageManager
+import android.os.Build
+import com.bitchat.android.BuildConfig
+import java.io.File
+import java.security.MessageDigest
+import java.util.zip.ZipFile
+
+/**
+ * Read-only diagnostics describing how the currently running app was packaged
+ * and installed. These values are facts about the installed artifact, not
+ * settings that can be changed at runtime.
+ */
+object DistributionInfoProvider {
+ private val UNIVERSAL_RELEASE_ABIS = setOf(
+ "arm64-v8a",
+ "armeabi-v7a",
+ "x86_64",
+ "x86"
+ )
+
+ fun inspect(context: Context): DistributionInfo {
+ val packageInfo = context.packageManager.getPackageInfo(
+ context.packageName,
+ signingFlags()
+ )
+ val applicationInfo = context.applicationInfo
+ val splitApks = applicationInfo.splitSourceDirs.orEmpty()
+ val installerPackage = installerPackageName(context)
+ val certificateSha256 = signingCertificateSha256(packageInfo)
+ val installedApkCanBeSharedUniversally = splitApks.isEmpty() &&
+ isUniversalApk(File(applicationInfo.sourceDir))
+
+ return DistributionInfo(
+ installSource = installSourceLabel(installerPackage),
+ installerPackage = installerPackage,
+ packageFormat = if (splitApks.isEmpty()) "Standalone APK" else "Split APK set",
+ architecture = architectureLabel(applicationInfo.sourceDir, splitApks),
+ sharingSource = if (installedApkCanBeSharedUniversally) {
+ "Current installed APK"
+ } else {
+ "Verified GitHub universal APK"
+ },
+ versionName = packageInfo.versionName ?: BuildConfig.VERSION_NAME,
+ versionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ packageInfo.longVersionCode
+ } else {
+ @Suppress("DEPRECATION")
+ packageInfo.versionCode.toLong()
+ },
+ signingChannel = signingChannel(installerPackage, certificateSha256),
+ certificateSha256 = certificateSha256
+ )
+ }
+
+ private fun installerPackageName(context: Context): String? {
+ return try {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ context.packageManager
+ .getInstallSourceInfo(context.packageName)
+ .installingPackageName
+ } else {
+ @Suppress("DEPRECATION")
+ context.packageManager.getInstallerPackageName(context.packageName)
+ }
+ } catch (_: Exception) {
+ null
+ }
+ }
+
+ private fun installSourceLabel(installerPackage: String?): String {
+ return when (installerPackage) {
+ "com.android.vending" -> "Google Play"
+ "com.amazon.venezia" -> "Amazon Appstore"
+ "org.fdroid.fdroid" -> "F-Droid"
+ "com.android.packageinstaller",
+ "com.google.android.packageinstaller",
+ "com.android.permissioncontroller" -> "Android package installer"
+ null -> if (BuildConfig.DEBUG) "ADB / local install" else "Unknown / local install"
+ else -> installerPackage
+ }
+ }
+
+ private fun architectureLabel(baseApkPath: String, splitApkPaths: Array): String {
+ val apkPaths = listOf(baseApkPath) + splitApkPaths
+ val packagedAbis = buildSet {
+ apkPaths.forEach { path ->
+ addAll(nativeAbisInApk(File(path)))
+ addAll(abisInSplitName(File(path).name))
+ }
+ }
+
+ return when {
+ packagedAbis.containsAll(UNIVERSAL_RELEASE_ABIS) ->
+ "Universal (${packagedAbis.joinToString()})"
+ packagedAbis.size > 1 -> "Multi-ABI (${packagedAbis.joinToString()})"
+ packagedAbis.size == 1 -> packagedAbis.single()
+ splitApkPaths.isNotEmpty() -> "Device ABI (${Build.SUPPORTED_ABIS.firstOrNull() ?: "unknown"})"
+ else -> "Universal (no native ABI payload)"
+ }
+ }
+
+ /**
+ * An APK with no native payload works across ABIs. When native libraries
+ * are present, require every ABI produced by the release workflow.
+ */
+ fun isUniversalApk(apk: File): Boolean {
+ val packagedAbis = nativeAbisInApk(apk)
+ return packagedAbis.isEmpty() || packagedAbis.containsAll(UNIVERSAL_RELEASE_ABIS)
+ }
+
+ internal fun nativeAbisInApk(apk: File): Set {
+ if (!apk.isFile) return emptySet()
+ return try {
+ ZipFile(apk).use { zip ->
+ buildSet {
+ val entries = zip.entries()
+ while (entries.hasMoreElements()) {
+ val path = entries.nextElement().name
+ if (path.startsWith("lib/")) {
+ path.split('/').getOrNull(1)
+ ?.takeIf { it.isNotBlank() }
+ ?.let(::add)
+ }
+ }
+ }
+ }
+ } catch (_: Exception) {
+ emptySet()
+ }
+ }
+
+ private fun abisInSplitName(fileName: String): Set {
+ val normalizedName = fileName.replace('_', '-')
+ return Build.SUPPORTED_ABIS
+ .filter { abi -> normalizedName.contains(abi.replace('_', '-'), ignoreCase = true) }
+ .toSet()
+ }
+
+ private fun signingChannel(installerPackage: String?, certificateSha256: String?): String {
+ if (BuildConfig.DEBUG) return "Debug"
+ if (installerPackage == "com.android.vending") return "Google Play"
+
+ val pinnedGitHubCert = BuildConfig.GITHUB_RELEASE_CERT_SHA256
+ .replace(":", "")
+ .lowercase()
+ .takeIf { it.matches(Regex("[a-f0-9]{64}")) }
+ return if (certificateSha256 != null && certificateSha256 == pinnedGitHubCert) {
+ "GitHub release"
+ } else {
+ "Release / unknown channel"
+ }
+ }
+
+ private fun signingCertificateSha256(packageInfo: PackageInfo): String? {
+ val signatures = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ packageInfo.signingInfo?.apkContentsSigners
+ } else {
+ @Suppress("DEPRECATION")
+ packageInfo.signatures
+ }
+ val signature = signatures?.firstOrNull() ?: return null
+ return MessageDigest.getInstance("SHA-256")
+ .digest(signature.toByteArray())
+ .joinToString("") { "%02x".format(it) }
+ }
+
+ private fun signingFlags(): Int {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ PackageManager.GET_SIGNING_CERTIFICATES
+ } else {
+ @Suppress("DEPRECATION")
+ PackageManager.GET_SIGNATURES
+ }
+ }
+
+ data class DistributionInfo(
+ val installSource: String,
+ val installerPackage: String?,
+ val packageFormat: String,
+ val architecture: String,
+ val sharingSource: String,
+ val versionName: String,
+ val versionCode: Long,
+ val signingChannel: String,
+ val certificateSha256: String?
+ )
+}
diff --git a/app/src/main/java/com/bitchat/android/util/GitHubReleaseClient.kt b/app/src/main/java/com/bitchat/android/util/GitHubReleaseClient.kt
new file mode 100644
index 00000000..699637bd
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/util/GitHubReleaseClient.kt
@@ -0,0 +1,338 @@
+package com.bitchat.android.util
+
+import android.util.Log
+import com.bitchat.android.net.ArtiTorManager
+import com.bitchat.android.net.OkHttpProvider
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withContext
+import okhttp3.Request
+import org.json.JSONObject
+import java.io.IOException
+import java.util.concurrent.TimeUnit
+
+/**
+ * Client for fetching BitChat release information from GitHub API.
+ */
+object GitHubReleaseClient {
+ private const val TAG = "GitHubAPI"
+ private const val GITHUB_API_URL = "https://api.github.com/repos/permissionlesstech/bitchat-android/releases/latest"
+ private const val USER_AGENT = "BitChat-Android"
+ private const val CACHE_TTL_MILLIS = 10 * 60 * 1000L
+ private const val MAX_FETCH_ATTEMPTS = 3
+ private const val ROUTE_READY_TIMEOUT_MILLIS = 60_000L
+
+ private val fetchMutex = Mutex()
+
+ @Volatile
+ private var cachedRelease: CachedRelease? = null
+
+ private val client
+ get() = OkHttpProvider.httpClient().newBuilder()
+ // GitHub requests may travel through Tor, where a 15-second total
+ // timeout is too aggressive during circuit establishment.
+ .callTimeout(45, TimeUnit.SECONDS)
+ .connectTimeout(20, TimeUnit.SECONDS)
+ .readTimeout(30, TimeUnit.SECONDS)
+ .build()
+
+ /**
+ * Fetch the latest release information from GitHub.
+ * Successful metadata is cached briefly so the status screen and download
+ * worker use the same release snapshot instead of making duplicate calls.
+ */
+ suspend fun fetchLatestRelease(forceRefresh: Boolean = false): Result =
+ withContext(Dispatchers.IO) {
+ fetchMutex.withLock {
+ if (!forceRefresh) {
+ cachedRelease
+ ?.takeIf { System.currentTimeMillis() - it.fetchedAtMillis < CACHE_TTL_MILLIS }
+ ?.let { return@withLock Result.success(it.release) }
+ }
+
+ if (!awaitSelectedNetworkRoute()) {
+ return@withLock Result.failure(
+ ReleaseFetchException(
+ message = "Tor is still connecting. Try again when Tor is ready.",
+ retryable = true
+ )
+ )
+ }
+
+ var lastFailure: Throwable = ReleaseFetchException(
+ "Failed to fetch the latest release from GitHub"
+ )
+
+ repeat(MAX_FETCH_ATTEMPTS) { attempt ->
+ val result = fetchLatestReleaseOnce()
+ result.onSuccess { release ->
+ cachedRelease = CachedRelease(release, System.currentTimeMillis())
+ return@withLock Result.success(release)
+ }
+ lastFailure = result.exceptionOrNull() ?: lastFailure
+
+ if (!isRetryable(lastFailure) || attempt == MAX_FETCH_ATTEMPTS - 1) {
+ return@withLock Result.failure(lastFailure)
+ }
+
+ delay(1_000L shl attempt)
+ }
+
+ Result.failure(lastFailure)
+ }
+ }
+
+ /**
+ * Wait for Tor when it is the selected route. This deliberately does not
+ * fall back to a direct connection because doing so would violate the
+ * user's Tor preference.
+ */
+ suspend fun awaitSelectedNetworkRoute(): Boolean {
+ return ArtiTorManager.getInstance()
+ .awaitSelectedRoute(ROUTE_READY_TIMEOUT_MILLIS)
+ }
+
+ private fun fetchLatestReleaseOnce(): Result {
+ return try {
+ Log.d(TAG, "Fetching latest release from GitHub API")
+ val request = Request.Builder()
+ .url(GITHUB_API_URL)
+ .addHeader("User-Agent", USER_AGENT)
+ .addHeader("Accept", "application/vnd.github+json")
+ .addHeader("X-GitHub-Api-Version", "2022-11-28")
+ .build()
+
+ client.newCall(request).execute().use { response ->
+ if (!response.isSuccessful) {
+ val remaining = response.header("X-RateLimit-Remaining")
+ val resetAt = response.header("X-RateLimit-Reset")
+ val message = when {
+ response.code == 403 && remaining == "0" ->
+ "GitHub API rate limit exceeded. Try again after reset time $resetAt."
+ response.code == 429 ->
+ "GitHub API rate limit exceeded. Please try again later."
+ else ->
+ "GitHub release request failed: HTTP ${response.code} ${response.message}"
+ }
+ Log.e(TAG, message)
+ return Result.failure(
+ ReleaseFetchException(
+ message = message,
+ httpCode = response.code,
+ retryable = response.code == 403 ||
+ response.code == 408 ||
+ response.code == 429 ||
+ response.code >= 500
+ )
+ )
+ }
+
+ val body = response.body?.string()
+ if (body.isNullOrBlank()) {
+ return Result.failure(
+ ReleaseFetchException(
+ message = "GitHub returned an empty response",
+ retryable = true
+ )
+ )
+ }
+
+ val release = parseRelease(body)
+ ?: return Result.failure(
+ ReleaseFetchException(
+ message = "GitHub's latest release has no universal APK asset",
+ retryable = false
+ )
+ )
+ Result.success(release)
+ }
+ } catch (e: IOException) {
+ Log.e(TAG, "Network error fetching release", e)
+ Result.failure(
+ ReleaseFetchException(
+ "Could not reach GitHub${e.message?.let { ": $it" } ?: ""}",
+ cause = e
+ )
+ )
+ } catch (e: Exception) {
+ Log.e(TAG, "Error fetching release", e)
+ Result.failure(ReleaseFetchException("Invalid GitHub release response", cause = e))
+ }
+ }
+
+ private fun isRetryable(error: Throwable): Boolean {
+ return error !is ReleaseFetchException || error.retryable
+ }
+
+ /**
+ * Parse GitHub API JSON response into Release object.
+ */
+ internal fun parseRelease(jsonString: String): Release? {
+ try {
+ val json = JSONObject(jsonString)
+ val tagName = json.optString("tag_name", "")
+ val versionName = tagName.removePrefix("v") // Remove "v" prefix if present
+
+ if (versionName.isBlank()) {
+ Log.e(TAG, "No version tag found in release")
+ return null
+ }
+
+ Log.d(TAG, "Found release: $versionName")
+
+ // Parse assets array to find universal APK
+ val assets = json.optJSONArray("assets")
+ if (assets == null || assets.length() == 0) {
+ Log.e(TAG, "No assets found in release")
+ return null
+ }
+
+ // Look for universal APK (usually named "app-universal-release.apk")
+ for (i in 0 until assets.length()) {
+ val asset = assets.getJSONObject(i)
+ val name = asset.optString("name", "")
+
+ if (name.contains("universal", ignoreCase = true) && name.endsWith(".apk")) {
+ val downloadUrl = asset.optString("browser_download_url", "")
+ val size = asset.optLong("size", 0L)
+
+ if (downloadUrl.isBlank()) {
+ Log.e(TAG, "Universal APK found but no download URL")
+ continue
+ }
+
+ // Prefer GitHub's asset digest when available, then fall
+ // back to release notes used by older releases.
+ val body = json.optString("body", "")
+ val assetDigest = asset.optString("digest", "")
+ .takeIf { it.startsWith("sha256:", ignoreCase = true) }
+ ?.substringAfter(":")
+ ?.takeIf { it.matches(Regex("[a-fA-F0-9]{64}")) }
+ ?.lowercase()
+ val sha256 = assetDigest ?: extractSha256FromBody(body, name)
+
+ Log.d(TAG, "Found universal APK: $name (${size / 1024 / 1024}MB)")
+
+ return Release(
+ tagName = tagName,
+ versionName = versionName,
+ universalApkUrl = downloadUrl,
+ universalApkSha256 = sha256,
+ universalApkSize = size,
+ universalApkName = name
+ )
+ }
+ }
+
+ Log.e(TAG, "No universal APK found in release assets")
+ return null
+
+ } catch (e: Exception) {
+ Log.e(TAG, "Error parsing release JSON", e)
+ return null
+ }
+ }
+
+ /**
+ * Extract SHA256 checksum from release body/notes.
+ * Looks for patterns like:
+ * - sha256:abc123...
+ * - SHA256: abc123...
+ * - app-universal-release.apk: abc123...
+ */
+ private fun extractSha256FromBody(body: String, apkName: String): String? {
+ if (body.isBlank()) return null
+
+ try {
+ // Pattern 1: Look for "sha256:" followed by hash
+ val sha256Pattern = Regex("""sha256:\s*([a-fA-F0-9]{64})""", RegexOption.IGNORE_CASE)
+ sha256Pattern.find(body)?.let { match ->
+ return match.groupValues[1].lowercase()
+ }
+
+ // Pattern 2: Look for APK name followed by hash
+ val apkPattern = Regex("""${Regex.escape(apkName)}.*?([a-fA-F0-9]{64})""", RegexOption.IGNORE_CASE)
+ apkPattern.find(body)?.let { match ->
+ return match.groupValues[1].lowercase()
+ }
+
+ Log.w(TAG, "Could not extract SHA256 from release body")
+ return null
+
+ } catch (e: Exception) {
+ Log.w(TAG, "Error extracting SHA256", e)
+ return null
+ }
+ }
+
+ /**
+ * Check if a newer version is available.
+ * @param currentVersion Current installed/cached version
+ * @param latestRelease Latest release from GitHub
+ * @return true if latestRelease is newer
+ */
+ fun isNewerVersion(currentVersion: String, latestRelease: Release): Boolean {
+ return isNewerVersion(currentVersion, latestRelease.versionName)
+ }
+
+ internal fun isNewerVersion(currentVersion: String, candidateVersion: String): Boolean {
+ return try {
+ // Simple version comparison (assumes semantic versioning)
+ // Remove any non-numeric prefixes
+ val current = currentVersion.removePrefix("v").trim()
+ val latest = candidateVersion.removePrefix("v").trim()
+
+ if (current == latest) {
+ return false
+ }
+
+ // Split by dots and compare each part
+ val currentParts = current.split(".").mapNotNull { it.toIntOrNull() }
+ val latestParts = latest.split(".").mapNotNull { it.toIntOrNull() }
+
+ val maxLength = maxOf(currentParts.size, latestParts.size)
+
+ for (i in 0 until maxLength) {
+ val currentPart = currentParts.getOrNull(i) ?: 0
+ val latestPart = latestParts.getOrNull(i) ?: 0
+
+ if (latestPart > currentPart) {
+ return true
+ } else if (latestPart < currentPart) {
+ return false
+ }
+ }
+
+ false
+ } catch (e: Exception) {
+ Log.e(TAG, "Error comparing versions", e)
+ false
+ }
+ }
+
+ /**
+ * Release information from GitHub.
+ */
+ data class Release(
+ val tagName: String,
+ val versionName: String,
+ val universalApkUrl: String,
+ val universalApkSha256: String?,
+ val universalApkSize: Long,
+ val universalApkName: String
+ )
+
+ class ReleaseFetchException(
+ message: String,
+ val httpCode: Int? = null,
+ val retryable: Boolean = true,
+ cause: Throwable? = null
+ ) : IOException(message, cause)
+
+ private data class CachedRelease(
+ val release: Release,
+ val fetchedAtMillis: Long
+ )
+}
diff --git a/app/src/main/java/com/bitchat/android/util/UniversalApkManager.kt b/app/src/main/java/com/bitchat/android/util/UniversalApkManager.kt
new file mode 100644
index 00000000..ab1e97f1
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/util/UniversalApkManager.kt
@@ -0,0 +1,825 @@
+package com.bitchat.android.util
+
+import android.content.Context
+import android.content.pm.PackageManager
+import android.os.Build
+import android.util.Log
+import com.bitchat.android.BuildConfig
+import com.bitchat.android.net.OkHttpProvider
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.coroutines.withContext
+import okhttp3.Call
+import okhttp3.Callback
+import okhttp3.Request
+import okhttp3.Response
+import org.json.JSONObject
+import java.io.File
+import java.io.FileOutputStream
+import java.io.IOException
+import java.nio.file.AtomicMoveNotSupportedException
+import java.nio.file.Files
+import java.nio.file.StandardCopyOption
+import java.security.MessageDigest
+
+/**
+ * Manages downloading, caching, and verifying the universal APK for offline sharing.
+ */
+class UniversalApkManager(private val context: Context) {
+
+ companion object {
+ private const val TAG = "UniversalApk"
+ private const val CACHE_DIR_NAME = "universal_apk"
+ private const val METADATA_FILE_NAME = "universal_apk_info.json"
+ private const val PROGRESS_FILE_NAME = "download_progress.json"
+ private const val APK_FILE_PREFIX = "bitchat-universal-"
+
+ // Download buffer size (128KB)
+ private const val BUFFER_SIZE = 128 * 1024
+ }
+
+ private val cacheDir: File
+ get() = File(context.cacheDir, CACHE_DIR_NAME).also { it.mkdirs() }
+
+ private val metadataFile: File get() = File(cacheDir, METADATA_FILE_NAME)
+ private val progressFile: File get() = File(cacheDir, PROGRESS_FILE_NAME)
+
+ // Download client: inherits Tor proxy settings but with no call timeout
+ // for large file downloads that can take minutes
+ private val downloadClient
+ get() = OkHttpProvider.httpClient().newBuilder()
+ .callTimeout(0, java.util.concurrent.TimeUnit.SECONDS)
+ .readTimeout(60, java.util.concurrent.TimeUnit.SECONDS)
+ .build()
+
+ /**
+ * Get information about the cached universal APK, if it exists.
+ */
+ fun getCachedApkInfo(): ApkInfo? {
+ return try {
+ if (!metadataFile.exists()) {
+ return null
+ }
+
+ val json = JSONObject(metadataFile.readText())
+ val version = json.optString("version", "")
+ val checksum = json.optString("checksum", "")
+ val downloadDate = json.optLong("downloadDate", 0L)
+ val size = json.optLong("size", 0L)
+ val fileName = json.optString("fileName", "")
+ val source = runCatching {
+ ApkSource.valueOf(json.optString("source", ApkSource.GITHUB.name))
+ }.getOrDefault(ApkSource.GITHUB)
+
+ if (version.isBlank() || fileName.isBlank()) {
+ return null
+ }
+
+ val apkFile = File(cacheDir, fileName)
+ if (!apkFile.exists()) {
+ Log.w(TAG, "Metadata exists but APK file not found: ${apkFile.path}")
+ return null
+ }
+
+ ApkInfo(
+ version = version,
+ checksum = checksum,
+ downloadDate = downloadDate,
+ size = size,
+ file = apkFile,
+ source = source
+ )
+ } catch (e: Exception) {
+ Log.e(TAG, "Error reading cached APK info", e)
+ null
+ }
+ }
+
+ /**
+ * Get the cached APK file, if it exists.
+ */
+ fun getCachedApk(): File? {
+ return getCachedApkInfo()?.file
+ }
+
+ /**
+ * Check if a partial (resumable) download exists.
+ * Returns the progress percentage (0-100) or null if no partial download.
+ */
+ fun getPartialDownloadProgress(): Int? {
+ val tempFile = File(cacheDir, "download_temp.apk")
+ val resumeInfo = loadResumeInfo()
+ if (tempFile.exists() && resumeInfo != null) {
+ val expectedSize = resumeInfo.optLong("expectedSize", 0L)
+ if (expectedSize > 0) {
+ return ((tempFile.length() * 100) / expectedSize).toInt().coerceIn(0, 99)
+ }
+ }
+ return null
+ }
+
+ /**
+ * Check for updates from GitHub.
+ * @return UpdateStatus indicating if update is available, current version, etc.
+ */
+ suspend fun checkForUpdate(): UpdateStatus = withContext(Dispatchers.IO) {
+ try {
+ // A genuinely universal standalone APK is already an installable
+ // sharing artifact. Architecture-specific standalone APKs and split
+ // installs still need the universal GitHub artifact.
+ val installedApkInfo = cacheInstalledApkIfPreferred()
+ if (installedApkInfo != null) {
+ return@withContext UpdateStatus.UpToDate(installedApkInfo.version)
+ }
+
+ val cachedInfo = getCachedApkInfo()
+ val latestRelease = GitHubReleaseClient.fetchLatestRelease().getOrElse { error ->
+ return@withContext UpdateStatus.Error(
+ error.message ?: "Failed to fetch latest release from GitHub"
+ )
+ }
+ // The GitHub release may briefly lag behind the installed version
+ // (upstream bumps versionName in main before tagging the release).
+ // An older release is still a genuine, signed, universal artifact —
+ // recipients with a newer install can't be downgraded by Android
+ // anyway — so share it rather than disabling the feature.
+ if (isOlderThanInstalledVersion(latestRelease.versionName)) {
+ Log.i(
+ TAG,
+ "GitHub universal APK ${latestRelease.versionName} is older than installed " +
+ "app ${installedVersionName()}; sharing it until the matching release ships"
+ )
+ }
+
+ if (cachedInfo == null) {
+ // No cached APK
+ return@withContext UpdateStatus.NotDownloaded(latestRelease)
+ }
+
+ // Compare versions
+ val isNewer = GitHubReleaseClient.isNewerVersion(cachedInfo.version, latestRelease)
+
+ if (isNewer) {
+ UpdateStatus.UpdateAvailable(
+ currentVersion = cachedInfo.version,
+ latestRelease = latestRelease
+ )
+ } else {
+ UpdateStatus.UpToDate(cachedInfo.version)
+ }
+
+ } catch (e: Exception) {
+ Log.e(TAG, "Error checking for update", e)
+ UpdateStatus.Error(e.message ?: "Unknown error")
+ }
+ }
+
+ /**
+ * Check if there's enough disk space to download the APK.
+ * Requires 1.5x the file size for safety margin (temp + final file).
+ * @throws IOException if insufficient space
+ */
+ private fun checkDiskSpace(requiredSize: Long) {
+ val availableSpace = cacheDir.usableSpace
+ val requiredWithMargin = (requiredSize * 1.5).toLong()
+
+ if (availableSpace < requiredWithMargin) {
+ val requiredMB = requiredWithMargin / 1024 / 1024
+ val availableMB = availableSpace / 1024 / 1024
+ val error = "Insufficient storage: need ${requiredMB}MB, have ${availableMB}MB"
+ Log.e(TAG, error)
+ throw IOException(error)
+ }
+ }
+
+ /**
+ * Download the universal APK from GitHub with resume support.
+ * @param progressCallback Called with progress percentage (0-100)
+ * @return Result with File on success, or error message
+ */
+ suspend fun downloadUniversalApk(
+ progressCallback: ((Int) -> Unit)? = null
+ ): Result = withContext(Dispatchers.IO) {
+ try {
+ Log.d(TAG, "Starting universal APK download")
+
+ // Fetch latest release info
+ // Reuses the short-lived release metadata cache populated by the
+ // status check. If this worker is running after process death, the
+ // client performs a retried network fetch instead.
+ val release = GitHubReleaseClient.fetchLatestRelease().getOrElse { error ->
+ return@withContext Result.failure(error)
+ }
+
+ if (!GitHubReleaseClient.awaitSelectedNetworkRoute()) {
+ return@withContext Result.failure(
+ IOException("Tor is still connecting. Try the download again when Tor is ready.")
+ )
+ }
+
+ val url = release.universalApkUrl
+ val expectedSize = release.universalApkSize
+
+ Log.d(TAG, "Downloading from: $url")
+ Log.d(TAG, "Expected size: ${expectedSize / 1024 / 1024}MB")
+
+ val tempFile = File(cacheDir, "download_temp.apk")
+
+ // Check for resumable download
+ var existingBytes = 0L
+ if (tempFile.exists()) {
+ val resumeInfo = loadResumeInfo()
+ if (resumeInfo != null &&
+ resumeInfo.optString("url") == url &&
+ resumeInfo.optString("versionName") == release.versionName
+ ) {
+ existingBytes = tempFile.length()
+ Log.d(TAG, "Resuming download from $existingBytes bytes")
+ } else {
+ Log.d(TAG, "Stale temp file found, starting fresh")
+ tempFile.delete()
+ progressFile.delete()
+ }
+ }
+
+ // Bytes already in the temp file have already consumed storage, so
+ // a resume only needs room for the remaining tail. Promotion is a
+ // rename and needs no extra space.
+ checkDiskSpace((expectedSize - existingBytes).coerceAtLeast(0))
+
+ // A temp file that already holds the full asset means the process
+ // died between download and verification. Requesting
+ // "Range: bytes=-" for it would get HTTP 416 forever, so skip
+ // the network and let checksum/signature verification decide its fate.
+ if (expectedSize > 0 && existingBytes >= expectedSize) {
+ Log.d(TAG, "Temp file already complete ($existingBytes bytes), skipping to verification")
+ } else {
+ val requestBuilder = Request.Builder()
+ .url(url)
+ .addHeader("User-Agent", "BitChat-Android")
+
+ if (existingBytes > 0) {
+ requestBuilder.addHeader("Range", "bytes=$existingBytes-")
+ Log.d(TAG, "Added Range header: bytes=$existingBytes-")
+ }
+
+ val request = requestBuilder.build()
+ downloadToTempFile(
+ call = downloadClient.newCall(request),
+ tempFile = tempFile,
+ url = url,
+ expectedSize = expectedSize,
+ versionName = release.versionName,
+ existingBytes = existingBytes,
+ progressCallback = progressCallback
+ )
+ }
+
+ // Verify checksum if available
+ if (release.universalApkSha256 != null) {
+ Log.d(TAG, "Verifying checksum...")
+ val isValid = verifyChecksum(tempFile, release.universalApkSha256)
+ if (!isValid) {
+ tempFile.delete()
+ progressFile.delete()
+ return@withContext Result.failure(
+ Exception("Checksum verification failed. Downloaded file may be corrupted.")
+ )
+ }
+ Log.d(TAG, "Checksum verified successfully")
+ } else {
+ Log.w(TAG, "No checksum available for verification")
+ }
+
+ // Verify the downloaded APK against trusted signing certificates.
+ Log.d(TAG, "Verifying APK signature...")
+ if (!verifyApkSignature(tempFile)) {
+ tempFile.delete()
+ progressFile.delete()
+ return@withContext Result.failure(
+ Exception("APK signature verification failed. The downloaded APK is not signed by a trusted BitChat release key.")
+ )
+ }
+ Log.d(TAG, "Signature verified successfully")
+
+ if (!DistributionInfoProvider.isUniversalApk(tempFile)) {
+ tempFile.delete()
+ progressFile.delete()
+ return@withContext Result.failure(
+ Exception(
+ "GitHub asset is architecture-specific, not universal. " +
+ "Release packaging must be corrected."
+ )
+ )
+ }
+
+ // Move to final location without deleting the currently usable APK
+ // first. Old versions are removed only after the replacement and
+ // metadata have both been committed.
+ val finalFileName = "$APK_FILE_PREFIX${release.versionName}.apk"
+ val finalFile = File(cacheDir, finalFileName)
+ replaceFileSafely(tempFile, finalFile)
+
+ // Clean up resume metadata on success
+ progressFile.delete()
+
+ // Save metadata
+ saveMetadata(
+ version = release.versionName,
+ checksum = release.universalApkSha256 ?: "",
+ size = finalFile.length(),
+ fileName = finalFileName,
+ source = ApkSource.GITHUB
+ )
+ cleanupOldApks(except = finalFile)
+
+ Log.d(TAG, "Universal APK downloaded successfully: ${finalFile.path}")
+ Result.success(finalFile)
+
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: IOException) {
+ Log.e(TAG, "Network error downloading APK", e)
+ Result.failure(e)
+ } catch (e: Exception) {
+ Log.e(TAG, "Error downloading APK", e)
+ Result.failure(e)
+ }
+ }
+
+ /**
+ * Streams an HTTP response into [tempFile] while keeping the coroutine
+ * suspended for the lifetime of the response body. Cancelling the worker
+ * therefore cancels the OkHttp call and promptly unblocks a pending read.
+ */
+ private suspend fun downloadToTempFile(
+ call: Call,
+ tempFile: File,
+ url: String,
+ expectedSize: Long,
+ versionName: String,
+ existingBytes: Long,
+ progressCallback: ((Int) -> Unit)?
+ ) = suspendCancellableCoroutine { continuation ->
+ fun completeSuccessfully() {
+ continuation.resumeWith(Result.success(Unit))
+ }
+
+ fun completeWithError(error: Throwable) {
+ continuation.resumeWith(Result.failure(error))
+ }
+
+ continuation.invokeOnCancellation {
+ call.cancel()
+ }
+
+ try {
+ call.enqueue(object : Callback {
+ override fun onFailure(call: Call, e: IOException) {
+ completeWithError(e)
+ }
+
+ override fun onResponse(call: Call, response: Response) {
+ try {
+ response.use {
+ if (response.code == 416) {
+ // Our offset is no longer valid for this asset; discard
+ // the partial state so the retry starts from scratch.
+ Log.w(TAG, "Server rejected resume range, restarting download")
+ tempFile.delete()
+ progressFile.delete()
+ throw IOException(
+ "Resume rejected by server. Download will restart."
+ )
+ }
+ if (!response.isSuccessful && response.code != 206) {
+ throw IOException(
+ "Download failed: ${response.code} ${response.message}"
+ )
+ }
+
+ val body = response.body
+ ?: throw IOException("Empty response body")
+
+ // Handle resume: 206 = partial content (append), 200 = full
+ // content (overwrite).
+ val append = response.code == 206
+ val resumedBytes = if (!append && existingBytes > 0) {
+ Log.d(
+ TAG,
+ "Server didn't honor Range request, starting from scratch"
+ )
+ 0L
+ } else {
+ existingBytes
+ }
+
+ saveResumeInfo(url, expectedSize, versionName)
+
+ if (resumedBytes > 0 && expectedSize > 0) {
+ val initialProgress =
+ ((resumedBytes * 100) / expectedSize).toInt()
+ progressCallback?.invoke(initialProgress)
+ }
+
+ body.byteStream().use { input ->
+ FileOutputStream(tempFile, append).use { output ->
+ val buffer = ByteArray(BUFFER_SIZE)
+ var bytesRead: Int
+ var totalBytesRead = resumedBytes
+ var lastProgress = if (expectedSize > 0) {
+ ((resumedBytes * 100) / expectedSize).toInt()
+ } else {
+ 0
+ }
+
+ while (input.read(buffer).also { bytesRead = it } != -1) {
+ output.write(buffer, 0, bytesRead)
+ totalBytesRead += bytesRead
+
+ if (expectedSize > 0) {
+ val progress =
+ ((totalBytesRead * 100) / expectedSize).toInt()
+ if (progress != lastProgress) {
+ lastProgress = progress
+ progressCallback?.invoke(progress)
+ }
+ }
+ }
+
+ Log.d(
+ TAG,
+ "Download complete: ${totalBytesRead / 1024 / 1024}MB"
+ )
+ }
+ }
+ }
+ completeSuccessfully()
+ } catch (e: Exception) {
+ completeWithError(e)
+ }
+ }
+ })
+ } catch (e: Exception) {
+ completeWithError(e)
+ }
+ }
+
+ /**
+ * Cache the APK this process was installed from only when it is both
+ * standalone and universal. A base APK from a split install is incomplete,
+ * while an ABI-specific APK would unnecessarily limit recipients.
+ */
+ private fun cacheInstalledApkIfPreferred(): ApkInfo? {
+ return try {
+ val applicationInfo = context.applicationInfo
+ if (!applicationInfo.splitSourceDirs.isNullOrEmpty()) {
+ return null
+ }
+
+ val installedApk = File(applicationInfo.sourceDir)
+ if (!installedApk.isFile || installedApk.length() <= 0L) {
+ return null
+ }
+ if (!DistributionInfoProvider.isUniversalApk(installedApk)) {
+ Log.d(TAG, "Installed APK is architecture-specific; using GitHub universal APK")
+ discardArchitectureLimitedInstalledCache()
+ return null
+ }
+
+ val installedVersion = installedVersionName()
+ val cachedInfo = getCachedApkInfo()
+
+ // Keep an already cached artifact if it is the same version or
+ // newer. Otherwise prefer the running build so sharing cannot
+ // silently downgrade recipients to an older GitHub release.
+ if (cachedInfo != null &&
+ !GitHubReleaseClient.isNewerVersion(cachedInfo.version, installedVersion)
+ ) {
+ return cachedInfo
+ }
+
+ checkDiskSpace(installedApk.length())
+ val safeVersion = installedVersion.replace(Regex("[^A-Za-z0-9._-]"), "_")
+ val finalFileName = "$APK_FILE_PREFIX$safeVersion.apk"
+ val finalFile = File(cacheDir, finalFileName)
+ val pendingFile = File(cacheDir, "$finalFileName.new")
+
+ installedApk.inputStream().use { input ->
+ FileOutputStream(pendingFile).use { output ->
+ input.copyTo(output, BUFFER_SIZE)
+ }
+ }
+ replaceFileSafely(pendingFile, finalFile)
+
+ val checksum = calculateChecksum(finalFile)
+ saveMetadata(
+ version = installedVersion,
+ checksum = checksum,
+ size = finalFile.length(),
+ fileName = finalFileName,
+ source = ApkSource.INSTALLED
+ )
+ cleanupOldApks(except = finalFile)
+
+ Log.d(TAG, "Cached running standalone APK for offline sharing")
+ getCachedApkInfo()
+ } catch (e: Exception) {
+ Log.w(TAG, "Running APK cannot be used as a standalone sharing artifact", e)
+ null
+ }
+ }
+
+ private fun discardArchitectureLimitedInstalledCache() {
+ val cachedInfo = getCachedApkInfo() ?: return
+ if (cachedInfo.source != ApkSource.INSTALLED ||
+ DistributionInfoProvider.isUniversalApk(cachedInfo.file)
+ ) {
+ return
+ }
+
+ cachedInfo.file.delete()
+ metadataFile.delete()
+ Log.d(TAG, "Removed architecture-specific installed APK from universal sharing cache")
+ }
+
+ private fun installedVersionName(): String {
+ return context.packageManager
+ .getPackageInfo(context.packageName, 0)
+ .versionName
+ ?.takeIf { it.isNotBlank() }
+ ?: BuildConfig.VERSION_NAME
+ }
+
+ private fun isOlderThanInstalledVersion(candidateVersion: String): Boolean {
+ return GitHubReleaseClient.isNewerVersion(candidateVersion, installedVersionName())
+ }
+
+ /**
+ * Verify the downloaded APK against either the running app's signing lineage
+ * or the pinned GitHub release certificate. The latter supports Play installs
+ * when GitHub distribution uses a separate, explicitly trusted release key.
+ * Debug builds without a configured pin accept any signed (never unsigned) APK.
+ */
+ private fun verifyApkSignature(apkFile: File): Boolean {
+ return try {
+ val packageInfo = context.packageManager.getPackageArchiveInfo(apkFile.absolutePath, signingFlags())
+ ?: run {
+ Log.e(TAG, "Could not parse APK for signature verification")
+ return false
+ }
+ val apkCerts = signatureDigests(packageInfo)
+ if (apkCerts.isEmpty()) {
+ Log.e(TAG, "No signatures found in downloaded APK")
+ return false
+ }
+
+ val ownCerts = signatureDigests(
+ context.packageManager.getPackageInfo(context.packageName, signingFlags())
+ )
+ val pinnedReleaseCert = normalizeCertificateDigest(
+ BuildConfig.GITHUB_RELEASE_CERT_SHA256
+ )
+ val trustedCerts = ownCerts + listOfNotNull(pinnedReleaseCert)
+
+ // Debug builds may use a different local signing key, but still
+ // require the downloaded artifact itself to be signed. Production
+ // builds must match either this installation's signing lineage or
+ // the explicitly pinned GitHub release certificate.
+ if (BuildConfig.DEBUG && pinnedReleaseCert == null) {
+ Log.w(TAG, "Debug build has no pinned release certificate; accepting signed APK")
+ return true
+ }
+
+ if (trustedCerts.isEmpty()) {
+ Log.e(TAG, "No trusted APK signing certificates are configured")
+ return false
+ }
+
+ val matches = apkCerts.intersect(trustedCerts).isNotEmpty()
+ if (!matches) {
+ Log.e(TAG, "Signature mismatch!")
+ Log.e(TAG, "Trusted cert(s): $trustedCerts")
+ Log.e(TAG, "APK cert(s): $apkCerts")
+ }
+ matches
+ } catch (e: Exception) {
+ Log.e(TAG, "Error verifying APK signature", e)
+ false
+ }
+ }
+
+ private fun signingFlags(): Int {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ PackageManager.GET_SIGNING_CERTIFICATES
+ } else {
+ @Suppress("DEPRECATION")
+ PackageManager.GET_SIGNATURES
+ }
+ }
+
+ private fun signatureDigests(packageInfo: android.content.pm.PackageInfo): Set {
+ val signatures = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ val signingInfo = packageInfo.signingInfo ?: return emptySet()
+ if (signingInfo.hasMultipleSigners()) {
+ signingInfo.apkContentsSigners
+ } else {
+ signingInfo.signingCertificateHistory
+ }
+ } else {
+ @Suppress("DEPRECATION")
+ packageInfo.signatures
+ }
+ if (signatures.isNullOrEmpty()) return emptySet()
+
+ val digest = MessageDigest.getInstance("SHA-256")
+ return signatures.map { sig ->
+ digest.digest(sig.toByteArray()).joinToString("") { "%02x".format(it) }
+ }.toSet()
+ }
+
+ private fun normalizeCertificateDigest(value: String): String? {
+ return value
+ .replace(":", "")
+ .trim()
+ .lowercase()
+ .takeIf { it.matches(Regex("[a-f0-9]{64}")) }
+ }
+
+ /**
+ * Verify the SHA256 checksum of a file.
+ */
+ suspend fun verifyChecksum(file: File, expectedSha256: String): Boolean = withContext(Dispatchers.IO) {
+ try {
+ val checksum = calculateChecksum(file)
+ val matches = checksum.equals(expectedSha256, ignoreCase = true)
+
+ if (!matches) {
+ Log.e(TAG, "Checksum mismatch!")
+ Log.e(TAG, "Expected: $expectedSha256")
+ Log.e(TAG, "Actual: $checksum")
+ }
+
+ matches
+ } catch (e: Exception) {
+ Log.e(TAG, "Error verifying checksum", e)
+ false
+ }
+ }
+
+ private fun calculateChecksum(file: File): String {
+ val digest = MessageDigest.getInstance("SHA-256")
+ file.inputStream().use { input ->
+ val buffer = ByteArray(BUFFER_SIZE)
+ var bytesRead: Int
+ while (input.read(buffer).also { bytesRead = it } != -1) {
+ digest.update(buffer, 0, bytesRead)
+ }
+ }
+ return digest.digest().joinToString("") { "%02x".format(it) }
+ }
+
+ /**
+ * Delete the cached universal APK.
+ */
+ fun deleteCachedApk(): Boolean {
+ return try {
+ val info = getCachedApkInfo()
+ if (info != null) {
+ info.file.delete()
+ metadataFile.delete()
+ progressFile.delete()
+ Log.d(TAG, "Deleted cached APK: ${info.version}")
+ true
+ } else {
+ Log.w(TAG, "No cached APK to delete")
+ false
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Error deleting cached APK", e)
+ false
+ }
+ }
+
+ /**
+ * Clean up old APK files (keep only the current one).
+ */
+ private fun cleanupOldApks(except: File) {
+ try {
+ cacheDir.listFiles()?.forEach { file ->
+ if (file != except &&
+ file.name.startsWith(APK_FILE_PREFIX) &&
+ file.name.endsWith(".apk")
+ ) {
+ file.delete()
+ Log.d(TAG, "Cleaned up old APK: ${file.name}")
+ }
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Error cleaning up old APKs", e)
+ }
+ }
+
+ /**
+ * Save metadata about the downloaded APK.
+ */
+ private fun saveMetadata(
+ version: String,
+ checksum: String,
+ size: Long,
+ fileName: String,
+ source: ApkSource
+ ) {
+ val json = JSONObject().apply {
+ put("version", version)
+ put("checksum", checksum)
+ put("downloadDate", System.currentTimeMillis())
+ put("size", size)
+ put("fileName", fileName)
+ put("source", source.name)
+ }
+
+ val pendingMetadata = File(cacheDir, "$METADATA_FILE_NAME.new")
+ pendingMetadata.writeText(json.toString())
+ replaceFileSafely(pendingMetadata, metadataFile)
+ Log.d(TAG, "Saved metadata: $version")
+ }
+
+ private fun saveResumeInfo(url: String, expectedSize: Long, versionName: String) {
+ try {
+ val json = JSONObject().apply {
+ put("url", url)
+ put("expectedSize", expectedSize)
+ put("versionName", versionName)
+ }
+ progressFile.writeText(json.toString())
+ } catch (e: Exception) {
+ Log.e(TAG, "Error saving resume info", e)
+ }
+ }
+
+ private fun loadResumeInfo(): JSONObject? {
+ return try {
+ if (progressFile.exists()) {
+ JSONObject(progressFile.readText())
+ } else null
+ } catch (e: Exception) {
+ Log.e(TAG, "Error loading resume info", e)
+ null
+ }
+ }
+
+ /**
+ * Commit [source] to [target] without removing a valid target first.
+ * Both files live in the same cache directory, so this is a rename, not a
+ * copy — no extra disk space is needed and ATOMIC_MOVE either fully
+ * succeeds or leaves both files intact.
+ */
+ private fun replaceFileSafely(source: File, target: File) {
+ try {
+ Files.move(
+ source.toPath(),
+ target.toPath(),
+ StandardCopyOption.ATOMIC_MOVE,
+ StandardCopyOption.REPLACE_EXISTING
+ )
+ } catch (_: AtomicMoveNotSupportedException) {
+ Files.move(
+ source.toPath(),
+ target.toPath(),
+ StandardCopyOption.REPLACE_EXISTING
+ )
+ }
+ }
+
+ /**
+ * Information about a cached APK.
+ */
+ data class ApkInfo(
+ val version: String,
+ val checksum: String,
+ val downloadDate: Long,
+ val size: Long,
+ val file: File,
+ val source: ApkSource
+ )
+
+ enum class ApkSource {
+ INSTALLED,
+ GITHUB
+ }
+
+ /**
+ * Update check status.
+ */
+ sealed class UpdateStatus {
+ data class NotDownloaded(val latestRelease: GitHubReleaseClient.Release) : UpdateStatus()
+ data class UpToDate(val currentVersion: String) : UpdateStatus()
+ data class UpdateAvailable(
+ val currentVersion: String,
+ val latestRelease: GitHubReleaseClient.Release
+ ) : UpdateStatus()
+ data class Error(val message: String) : UpdateStatus()
+ }
+}
diff --git a/app/src/main/java/com/bitchat/android/util/WorkManagerApkDownloader.kt b/app/src/main/java/com/bitchat/android/util/WorkManagerApkDownloader.kt
new file mode 100644
index 00000000..feb31172
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/util/WorkManagerApkDownloader.kt
@@ -0,0 +1,93 @@
+package com.bitchat.android.util
+
+import android.content.Context
+import androidx.work.Constraints
+import androidx.work.BackoffPolicy
+import com.bitchat.android.R
+import androidx.work.ExistingWorkPolicy
+import androidx.work.NetworkType
+import androidx.work.OneTimeWorkRequestBuilder
+import androidx.work.WorkInfo
+import androidx.work.WorkManager
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import java.util.concurrent.TimeUnit
+
+/**
+ * WorkManager-backed implementation of [ApkDownloader].
+ * Downloads survive app backgrounding, process death, and device reboots.
+ */
+class WorkManagerApkDownloader(context: Context) : ApkDownloader {
+
+ private val appContext = context.applicationContext
+ private val workManager = WorkManager.getInstance(appContext)
+ private val apkManager = UniversalApkManager(appContext)
+
+ override val downloadState: Flow =
+ workManager.getWorkInfosForUniqueWorkFlow(ApkDownloadWorker.WORK_NAME)
+ .map { workInfos -> mapWorkInfoToState(workInfos.firstOrNull()) }
+
+ override fun startDownload() {
+ val constraints = Constraints.Builder()
+ .setRequiredNetworkType(NetworkType.CONNECTED)
+ .build()
+
+ val request = OneTimeWorkRequestBuilder()
+ .setConstraints(constraints)
+ .setBackoffCriteria(
+ BackoffPolicy.EXPONENTIAL,
+ 15,
+ TimeUnit.SECONDS
+ )
+ .addTag(ApkDownloadWorker.TAG)
+ .build()
+
+ workManager.enqueueUniqueWork(
+ ApkDownloadWorker.WORK_NAME,
+ ExistingWorkPolicy.KEEP,
+ request
+ )
+ }
+
+ override fun cancelDownload() {
+ workManager.cancelUniqueWork(ApkDownloadWorker.WORK_NAME)
+ }
+
+ private fun mapWorkInfoToState(workInfo: WorkInfo?): ApkDownloader.DownloadState {
+ if (workInfo == null) return ApkDownloader.DownloadState.Idle
+
+ return when (workInfo.state) {
+ WorkInfo.State.ENQUEUED,
+ WorkInfo.State.BLOCKED -> {
+ // Waiting for constraints (network). Show existing partial progress if any.
+ val partial = apkManager.getPartialDownloadProgress()
+ ApkDownloader.DownloadState.Downloading(partial ?: 0)
+ }
+ WorkInfo.State.RUNNING -> {
+ val progress = workInfo.progress.getInt(ApkDownloadWorker.KEY_PROGRESS, 0)
+ ApkDownloader.DownloadState.Downloading(progress)
+ }
+ WorkInfo.State.SUCCEEDED -> {
+ val version = workInfo.outputData.getString(ApkDownloadWorker.KEY_VERSION) ?: ""
+ val sizeMB = workInfo.outputData.getInt(ApkDownloadWorker.KEY_SIZE_MB, 0)
+ ApkDownloader.DownloadState.Success(version, sizeMB)
+ }
+ WorkInfo.State.FAILED -> {
+ val error = workInfo.outputData.getString(ApkDownloadWorker.KEY_ERROR) ?: "Download failed"
+ val resumable = workInfo.outputData.getInt(ApkDownloadWorker.KEY_RESUMABLE_PERCENT, -1)
+ ApkDownloader.DownloadState.Failed(error, if (resumable >= 0) resumable else null)
+ }
+ WorkInfo.State.CANCELLED -> {
+ val partial = apkManager.getPartialDownloadProgress()
+ if (partial != null) {
+ ApkDownloader.DownloadState.Failed(
+ appContext.getString(R.string.prepare_apk_download_cancelled),
+ partial
+ )
+ } else {
+ ApkDownloader.DownloadState.Idle
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index b8086f0a..bd316e59 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -147,6 +147,71 @@
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
+ App Ready for Offline Sharing
+ Download universal APK for offline sharing
+ Not ready • Tap to download
+ Ready to share
+ Sharing source: this installed APK
+ Sharing source: verified GitHub universal APK
+ 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.
+ The release size is temporarily unavailable. BitChat will retry the GitHub request before downloading.
+ 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.
+ Download interrupted
+ Download cancelled
+ Downloading universal APK
+ APK downloads
+
+
+ Share via Hotspot
+ Create Wi-Fi hotspot to share offline
+ Share via Quick Share
+ Use standard Android sharing
+
+
+ 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
@@ -449,6 +514,8 @@
Join
Cancel
Tor not available in this build
+ Checking...
+ APK not ready. Please prepare it first.
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 @@
+
+
diff --git a/app/src/test/kotlin/com/bitchat/android/util/DistributionInfoProviderTest.kt b/app/src/test/kotlin/com/bitchat/android/util/DistributionInfoProviderTest.kt
new file mode 100644
index 00000000..c30054fa
--- /dev/null
+++ b/app/src/test/kotlin/com/bitchat/android/util/DistributionInfoProviderTest.kt
@@ -0,0 +1,57 @@
+package com.bitchat.android.util
+
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import java.io.File
+import java.util.zip.ZipEntry
+import java.util.zip.ZipOutputStream
+
+@RunWith(RobolectricTestRunner::class)
+class DistributionInfoProviderTest {
+
+ @get:Rule
+ val temporaryFolder = TemporaryFolder()
+
+ @Test
+ fun `arm64-only APK is not universal`() {
+ val apk = createApk("lib/arm64-v8a/libbitchat.so")
+
+ assertFalse(DistributionInfoProvider.isUniversalApk(apk))
+ }
+
+ @Test
+ fun `APK containing every release ABI is universal`() {
+ val apk = createApk(
+ "lib/arm64-v8a/libbitchat.so",
+ "lib/armeabi-v7a/libbitchat.so",
+ "lib/x86_64/libbitchat.so",
+ "lib/x86/libbitchat.so"
+ )
+
+ assertTrue(DistributionInfoProvider.isUniversalApk(apk))
+ }
+
+ @Test
+ fun `APK without native libraries is architecture independent`() {
+ val apk = createApk("classes.dex")
+
+ assertTrue(DistributionInfoProvider.isUniversalApk(apk))
+ }
+
+ private fun createApk(vararg entries: String): File {
+ val apk = temporaryFolder.newFile("test-${System.nanoTime()}.apk")
+ ZipOutputStream(apk.outputStream()).use { zip ->
+ entries.forEach { path ->
+ zip.putNextEntry(ZipEntry(path))
+ zip.write(byteArrayOf(1))
+ zip.closeEntry()
+ }
+ }
+ return apk
+ }
+}
diff --git a/app/src/test/kotlin/com/bitchat/android/util/GitHubReleaseClientTest.kt b/app/src/test/kotlin/com/bitchat/android/util/GitHubReleaseClientTest.kt
new file mode 100644
index 00000000..e51990d9
--- /dev/null
+++ b/app/src/test/kotlin/com/bitchat/android/util/GitHubReleaseClientTest.kt
@@ -0,0 +1,99 @@
+package com.bitchat.android.util
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+class GitHubReleaseClientTest {
+
+ @Test
+ fun `parses universal apk and GitHub asset digest`() {
+ val digest = "a".repeat(64)
+ val release = GitHubReleaseClient.parseRelease(
+ """
+ {
+ "tag_name": "v1.7.6",
+ "body": "",
+ "assets": [
+ {
+ "name": "bitchat-android-universal.apk",
+ "browser_download_url": "https://example.test/bitchat.apk",
+ "size": 49283072,
+ "digest": "sha256:$digest"
+ }
+ ]
+ }
+ """.trimIndent()
+ )
+
+ requireNotNull(release)
+ assertEquals("1.7.6", release.versionName)
+ assertEquals(49_283_072L, release.universalApkSize)
+ assertEquals(digest, release.universalApkSha256)
+ }
+
+ @Test
+ fun `falls back to checksum in release notes`() {
+ val digest = "b".repeat(64)
+ val release = GitHubReleaseClient.parseRelease(
+ """
+ {
+ "tag_name": "1.7.6",
+ "body": "bitchat-android-universal.apk: $digest",
+ "assets": [
+ {
+ "name": "bitchat-android-universal.apk",
+ "browser_download_url": "https://example.test/bitchat.apk",
+ "size": 10
+ }
+ ]
+ }
+ """.trimIndent()
+ )
+
+ assertEquals(digest, requireNotNull(release).universalApkSha256)
+ }
+
+ @Test
+ fun `rejects releases without a universal apk`() {
+ val release = GitHubReleaseClient.parseRelease(
+ """
+ {
+ "tag_name": "v1.7.6",
+ "assets": [
+ {
+ "name": "bitchat-android-arm64.apk",
+ "browser_download_url": "https://example.test/arm64.apk",
+ "size": 10
+ }
+ ]
+ }
+ """.trimIndent()
+ )
+
+ assertNull(release)
+ }
+
+ @Test
+ fun `compares release versions`() {
+ val release = GitHubReleaseClient.Release(
+ tagName = "v1.7.6",
+ versionName = "1.7.6",
+ universalApkUrl = "https://example.test/bitchat.apk",
+ universalApkSha256 = null,
+ universalApkSize = 10,
+ universalApkName = "bitchat-android-universal.apk"
+ )
+
+ assertTrue(GitHubReleaseClient.isNewerVersion("1.7.5", release))
+ assertFalse(GitHubReleaseClient.isNewerVersion("1.7.6", release))
+ assertFalse(GitHubReleaseClient.isNewerVersion("1.8.0", release))
+ assertTrue(GitHubReleaseClient.isNewerVersion("1.7.4", "1.7.5"))
+ assertFalse(GitHubReleaseClient.isNewerVersion("1.7.5", "1.7.4"))
+ }
+}
diff --git a/gradle.properties b/gradle.properties
index c5ca74c1..e3461bd0 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -22,7 +22,13 @@ android.nonTransitiveRClass=false
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
+# Public SHA-256 fingerprint of the certificate used by the existing GitHub
+# universal APK releases. This is not a secret; it lets the app reject an APK
+# signed by an unexpected publisher.
+BITCHAT_GITHUB_RELEASE_CERT_SHA256=3b03fa66a5451321100792f5b55a7b4966d5c8dc10c6daa40aa95ea489531bca
+
# JVM heap size configuration to prevent OutOfMemoryError
-org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError
+org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError
+
# Enabled parallel sync for Gradle 9.4+
org.gradle.tooling.parallel=true
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 4a8ad015..95e83d07 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -42,6 +42,12 @@ tor-android-binary = "0.4.4.6"
# Google Play Services
gms-location = "21.4.0"
+# WorkManager
+work-runtime = "2.10.1"
+
+# NanoHTTPD (hotspot APK sharing)
+nanohttpd = "2.3.1"
+
# Security
security-crypto = "1.1.0"
@@ -111,6 +117,12 @@ tor-android-binary = { module = "org.torproject:tor-android-binary", version.ref
# Google Play Services
gms-location = { module = "com.google.android.gms:play-services-location", version.ref = "gms-location" }
+# WorkManager
+androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "work-runtime" }
+
+# NanoHTTPD
+nanohttpd = { module = "org.nanohttpd:nanohttpd", version.ref = "nanohttpd" }
+
# Security
androidx-security-crypto = { module = "androidx.security:security-crypto", version.ref = "security-crypto" }