diff --git a/.gitignore b/.gitignore
index 64ac199e..45ed4c3c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -63,3 +63,6 @@ google-services.json
# Arti build artifacts (cloned repo and Rust build cache)
tools/arti-build/.arti-source/
tools/arti-build/target/
+
+# JVM heap dumps (a Gradle daemon OOM drops these in the repo root)
+*.hprof
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/nostr/LocationNotesManager.kt b/app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt
index dc1a8e85..8873c271 100644
--- a/app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt
+++ b/app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt
@@ -13,7 +13,7 @@ import kotlinx.coroutines.flow.asStateFlow
*/
@MainThread
class LocationNotesManager private constructor() {
-
+
companion object {
private const val TAG = "LocationNotesManager"
private const val MAX_NOTES_IN_MEMORY = 500
@@ -27,7 +27,7 @@ class LocationNotesManager private constructor() {
}
}
}
-
+
/**
* Note data class matching iOS implementation
*/
@@ -94,6 +94,8 @@ class LocationNotesManager private constructor() {
// Coroutine scope for background operations
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
+ private var subscribeRetryJob: Job? = null
+ private var initialLoadJob: Job? = null
/**
* Initialize dependencies
@@ -289,6 +291,11 @@ class LocationNotesManager private constructor() {
* Subscribe to location notes for current geohash
*/
private fun subscribeAll() {
+ subscribeRetryJob?.cancel()
+ subscribeRetryJob = null
+ initialLoadJob?.cancel()
+ initialLoadJob = null
+
val currentGeohash = _geohash.value
if (currentGeohash == null) {
Log.w(TAG, "Cannot subscribe - no geohash set")
@@ -301,7 +308,7 @@ class LocationNotesManager private constructor() {
Log.e(TAG, "Cannot subscribe - subscribe function not initialized; will retry shortly")
_state.value = State.LOADING
// Retry a few times in case initialization is racing the sheet open
- scope.launch {
+ subscribeRetryJob = scope.launch {
var attempts = 0
while (attempts < 10 && subscribeFunc == null) {
delay(300)
@@ -342,9 +349,9 @@ class LocationNotesManager private constructor() {
}
// Mark initial load complete after brief delay to allow relay responses
- scope.launch {
+ initialLoadJob = scope.launch {
delay(2000) // Wait 2 seconds for initial batch
- if (!_initialLoadComplete.value!!) {
+ if (_geohash.value == currentGeohash && !_initialLoadComplete.value) {
_initialLoadComplete.value = true
_state.value = State.READY
Log.d(TAG, "Initial load complete for geohash: $currentGeohash (${noteIDs.size} notes)")
@@ -441,6 +448,11 @@ class LocationNotesManager private constructor() {
* Cancel subscription and clear state
*/
fun cancel() {
+ subscribeRetryJob?.cancel()
+ subscribeRetryJob = null
+ initialLoadJob?.cancel()
+ initialLoadJob = null
+
if (subscriptionIDs.isNotEmpty()) {
subscriptionIDs.values.forEach { subId ->
try {
@@ -453,17 +465,26 @@ class LocationNotesManager private constructor() {
subscribedGeohashes = emptySet()
_state.value = State.IDLE
}
-
+
/**
- * Cleanup resources
+ * End the nearby-notes session and discard location-correlated UI state.
+ * Unlike [cancel], this also clears the target so a later activation can
+ * safely subscribe to the same building geohash again.
*/
- fun cleanup() {
+ fun stop() {
cancel()
- scope.cancel()
_notes.value = emptyList()
noteIDs.clear()
_geohash.value = null
_initialLoadComplete.value = false
_errorMessage.value = null
}
+
+ /**
+ * Cleanup resources
+ */
+ fun cleanup() {
+ stop()
+ scope.cancel()
+ }
}
diff --git a/app/src/main/java/com/bitchat/android/nostr/NearbyNotesController.kt b/app/src/main/java/com/bitchat/android/nostr/NearbyNotesController.kt
new file mode 100644
index 00000000..a5f1a8ec
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/nostr/NearbyNotesController.kt
@@ -0,0 +1,130 @@
+package com.bitchat.android.nostr
+
+import androidx.annotation.MainThread
+import com.bitchat.android.geohash.GeohashChannel
+import com.bitchat.android.geohash.GeohashChannelLevel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+/**
+ * Session-scoped consent gate for nearby location notes.
+ *
+ * Merely rendering the mesh timeline must not open a building-precision Nostr
+ * subscription. A subscription is eligible only after an explicit reveal and
+ * while the app is foregrounded and at least one nearby-notes surface is active.
+ */
+@MainThread
+class NearbyNotesController internal constructor(
+ private val subscribe: (String) -> Unit,
+ private val unsubscribe: () -> Unit,
+) {
+ private val _revealed = MutableStateFlow(false)
+ val revealed: StateFlow = _revealed.asStateFlow()
+
+ private var activeHolders = 0
+ private var locationEnabled = false
+ private var locationAuthorized = false
+ private var appForeground = false
+ private var buildingGeohash: String? = null
+ private var subscribedGeohash: String? = null
+
+ /**
+ * Unlocks nearby notes for this process session. Deactivation deliberately
+ * does not reset consent, matching the iOS privacy model.
+ */
+ fun reveal() {
+ if (_revealed.value) return
+ _revealed.value = true
+ reconcileSubscription()
+ }
+
+ /** Holds the subscription while a nearby-notes surface is visible. */
+ fun activate() {
+ activeHolders += 1
+ reconcileSubscription()
+ }
+
+ /** Releases a matching [activate] hold and unsubscribes after the last one. */
+ fun deactivate() {
+ activeHolders = (activeHolders - 1).coerceAtLeast(0)
+ reconcileSubscription()
+ }
+
+ /** Closes the live subscription whenever the process leaves the foreground. */
+ fun updateAppForeground(isForeground: Boolean) {
+ appForeground = isForeground
+ reconcileSubscription()
+ }
+
+ /**
+ * Updates the privacy-sensitive inputs independently of view activation.
+ * Permission revocation, location disable, or loss of the building cell
+ * immediately closes any live subscription.
+ */
+ fun updateAvailability(
+ locationEnabled: Boolean,
+ locationAuthorized: Boolean,
+ buildingGeohash: String?,
+ ) {
+ this.locationEnabled = locationEnabled
+ this.locationAuthorized = locationAuthorized
+ this.buildingGeohash = buildingGeohash
+ ?.trim()
+ ?.lowercase()
+ ?.takeIf { it.isNotEmpty() }
+ reconcileSubscription()
+ }
+
+ fun offersRevealHint(): Boolean =
+ !_revealed.value &&
+ locationEnabled &&
+ locationAuthorized &&
+ buildingGeohash != null
+
+ private fun reconcileSubscription() {
+ val target = buildingGeohash.takeIf {
+ activeHolders > 0 &&
+ appForeground &&
+ _revealed.value &&
+ locationEnabled &&
+ locationAuthorized
+ }
+
+ if (subscribedGeohash != null && subscribedGeohash != target) {
+ unsubscribe()
+ subscribedGeohash = null
+ }
+
+ if (target != null && subscribedGeohash == null) {
+ subscribe(target)
+ subscribedGeohash = target
+ }
+ }
+
+ companion object {
+ val shared: NearbyNotesController by lazy {
+ val manager = LocationNotesManager.getInstance()
+ NearbyNotesController(
+ subscribe = manager::setGeohash,
+ unsubscribe = manager::stop,
+ )
+ }
+ }
+}
+
+/**
+ * Building precision is location-notes precision and remains private before a
+ * reveal. Explicit bookmarks remain eligible because saving one is itself an
+ * intentional location act.
+ */
+internal fun geohashesForSampling(
+ availableChannels: List,
+ bookmarks: Collection,
+ notesRevealed: Boolean,
+): List = buildSet {
+ availableChannels
+ .filter { notesRevealed || it.level != GeohashChannelLevel.BUILDING }
+ .mapTo(this) { it.geohash }
+ addAll(bookmarks)
+}.toList()
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 821ccabd..34418056 100644
--- a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt
+++ b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt
@@ -1,21 +1,48 @@
package com.bitchat.android.ui
+import android.content.Intent
+import android.widget.Toast
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.animation.core.FastOutSlowInEasing
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.shrinkVertically
+import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bluetooth
import androidx.compose.material.icons.filled.Security
import androidx.compose.material.icons.filled.Speed
-import androidx.compose.animation.animateColorAsState
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.interaction.MutableInteractionSource
-import androidx.compose.animation.core.FastOutSlowInEasing
-import androidx.compose.animation.core.animateFloatAsState
-import androidx.compose.animation.core.tween
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.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.Share
+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.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@@ -23,22 +50,27 @@ 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.LocalSheetDismiss
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.ui.theme.BitchatMotion
import com.bitchat.android.ui.theme.LocalBitchatPalette
+import com.bitchat.android.util.UniversalApkManager
/**
* Theme selection chip with Apple-like styling
@@ -397,6 +429,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) }
+ )
+ }
+ }
+
}
}
@@ -676,3 +1060,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/AnimatedCount.kt b/app/src/main/java/com/bitchat/android/ui/AnimatedCount.kt
index 41650b25..ecf5eb69 100644
--- a/app/src/main/java/com/bitchat/android/ui/AnimatedCount.kt
+++ b/app/src/main/java/com/bitchat/android/ui/AnimatedCount.kt
@@ -12,6 +12,7 @@ import androidx.compose.animation.togetherWith
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
@@ -75,8 +76,8 @@ fun AnimatedCount(
* Cross-fades a label whose text embeds a count, e.g. `People (7)` or `3 people`.
*
* Used where the number is not isolated in its own composable and cannot be rolled on its own.
- * The transition is keyed on [count] rather than on [text] so that a label changing for some
- * other reason (a locale switch, say) does not animate.
+ * The transition is keyed on [count] rather than on [text], so a label changing for some other
+ * reason — a locale switch, say — does not animate.
*/
@Composable
fun AnimatedCountLabel(
@@ -98,9 +99,13 @@ fun AnimatedCountLabel(
},
modifier = modifier,
label = "animatedCountLabel"
- ) { _ ->
+ ) { state ->
+ // Captured per state, so the outgoing copy keeps rendering the label it entered with.
+ // Reading `text` directly would show the *new* label on both sides of the cross-fade,
+ // turning the transition into a flicker between two identical strings.
+ val stateText = remember(state) { text }
Text(
- text = text,
+ text = stateText,
style = style,
color = color,
fontSize = fontSize,
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/ChatScreen.kt b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt
index 2d721bba..ba3c627c 100644
--- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt
+++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt
@@ -17,19 +17,35 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.Alignment
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.IconButton
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.zIndex
+import androidx.lifecycle.DefaultLifecycleObserver
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.bitchat.android.R
+import com.bitchat.android.geohash.ChannelID
+import com.bitchat.android.geohash.GeohashChannelLevel
+import com.bitchat.android.geohash.LocationChannelManager
import com.bitchat.android.model.BitchatMessage
+import com.bitchat.android.nostr.LocationNotesManager
+import com.bitchat.android.nostr.NearbyNotesController
import com.bitchat.android.ui.media.FullScreenImageViewer
import com.bitchat.android.ui.theme.BitchatMotion
import com.bitchat.android.ui.theme.LocalBitchatPalette
@@ -94,6 +110,67 @@ fun ChatScreen(viewModel: ChatViewModel) {
// Get location channel info for timeline switching
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
+ val context = LocalContext.current
+ val locationManager = remember { LocationChannelManager.getInstance(context) }
+ val nearbyNotesController = remember { NearbyNotesController.shared }
+ val nearbyNotesRevealed by nearbyNotesController.revealed.collectAsStateWithLifecycle()
+ val locationPermissionState by locationManager.permissionState.collectAsStateWithLifecycle()
+ val locationEnabled by locationManager.effectiveLocationEnabled.collectAsStateWithLifecycle(false)
+ val availableLocationChannels by locationManager.availableChannels.collectAsStateWithLifecycle()
+ val nearbyNotes by remember { LocationNotesManager.getInstance() }
+ .notes
+ .collectAsStateWithLifecycle()
+ val buildingGeohash = availableLocationChannels
+ .firstOrNull { it.level == GeohashChannelLevel.BUILDING }
+ ?.geohash
+ val isMeshTimeline =
+ currentChannel == null &&
+ selectedLocationChannel is ChannelID.Mesh &&
+ selectedPrivatePeer == null &&
+ privateChatSheetPeer == null
+
+ val processLifecycleOwner = remember { ProcessLifecycleOwner.get() }
+ DisposableEffect(processLifecycleOwner, nearbyNotesController) {
+ val lifecycle = processLifecycleOwner.lifecycle
+ val observer = object : DefaultLifecycleObserver {
+ override fun onStart(owner: LifecycleOwner) {
+ nearbyNotesController.updateAppForeground(true)
+ }
+
+ override fun onStop(owner: LifecycleOwner) {
+ nearbyNotesController.updateAppForeground(false)
+ }
+ }
+
+ lifecycle.addObserver(observer)
+ nearbyNotesController.updateAppForeground(
+ lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED),
+ )
+
+ onDispose {
+ lifecycle.removeObserver(observer)
+ nearbyNotesController.updateAppForeground(false)
+ }
+ }
+
+ DisposableEffect(
+ isMeshTimeline,
+ locationEnabled,
+ locationPermissionState,
+ buildingGeohash,
+ nearbyNotesController,
+ ) {
+ nearbyNotesController.updateAvailability(
+ locationEnabled = locationEnabled,
+ locationAuthorized =
+ locationPermissionState == LocationChannelManager.PermissionState.AUTHORIZED,
+ buildingGeohash = buildingGeohash,
+ )
+ if (isMeshTimeline) nearbyNotesController.activate()
+ onDispose {
+ if (isMeshTimeline) nearbyNotesController.deactivate()
+ }
+ }
// Determine what messages to show based on current context (unified timelines)
// Legacy private chat timeline removed - private chats now exclusively use PrivateChatSheet
@@ -140,13 +217,21 @@ fun ChatScreen(viewModel: ChatViewModel) {
) {
Box(modifier = Modifier.weight(1f)) {
// Messages area - takes up available space, will compress when keyboard appears
+ // Nearby-notes strip and the reveal hint both live in this Box alongside the
+ // list, rather than in a Column above it, because the conversation has to scroll
+ // underneath the translucent bars. Their heights are reserved as list padding.
+ var notesStripHeight by remember { mutableStateOf(0.dp) }
+ val showNotesStrip =
+ isMeshTimeline && nearbyNotesRevealed && nearbyNotes.isNotEmpty()
+
MessagesList(
messages = displayMessages,
currentUserNickname = nickname,
meshService = viewModel.meshServiceFacade,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
- top = statusBarHeight + headerHeight,
+ top = statusBarHeight + headerHeight +
+ (if (showNotesStrip) notesStripHeight else 0.dp),
bottom = composerHeight
),
forceScrollToBottom = forceScrollToBottom,
@@ -154,26 +239,29 @@ fun ChatScreen(viewModel: ChatViewModel) {
onNicknameClick = { fullSenderName ->
// Single click - mention user in text input
val currentText = messageText.text
-
+
// Extract base nickname and hash suffix from full sender name
val (baseName, hashSuffix) = splitSuffix(fullSenderName)
-
+
// Check if we're in a geohash channel to include hash suffix
val selectedLocationChannel = viewModel.selectedLocationChannel.value
- val mentionText = if (selectedLocationChannel is com.bitchat.android.geohash.ChannelID.Location && hashSuffix.isNotEmpty()) {
+ val mentionText = if (
+ selectedLocationChannel is ChannelID.Location &&
+ hashSuffix.isNotEmpty()
+ ) {
// In geohash chat - include the hash suffix from the full display name
"@$baseName$hashSuffix"
} else {
// Regular chat - just the base nickname
"@$baseName"
}
-
+
val newText = when {
currentText.isEmpty() -> "$mentionText "
currentText.endsWith(" ") -> "$currentText$mentionText "
else -> "$currentText $mentionText "
}
-
+
messageText = TextFieldValue(
text = newText,
selection = TextRange(newText.length)
@@ -196,6 +284,35 @@ fun ChatScreen(viewModel: ChatViewModel) {
showFullScreenImageViewer = true
}
)
+
+ if (
+ displayMessages.isEmpty() &&
+ isMeshTimeline &&
+ !nearbyNotesRevealed &&
+ locationEnabled &&
+ locationPermissionState ==
+ LocationChannelManager.PermissionState.AUTHORIZED &&
+ buildingGeohash != null
+ ) {
+ NearbyNotesRevealHint(
+ onClick = nearbyNotesController::reveal,
+ modifier = Modifier.align(Alignment.Center),
+ )
+ }
+
+ if (showNotesStrip) {
+ NearbyNotesStrip(
+ noteCount = nearbyNotes.size,
+ onClick = { showLocationNotesSheet = true },
+ modifier = Modifier
+ .align(Alignment.TopCenter)
+ .padding(top = statusBarHeight + headerHeight)
+ .onSizeChanged { size ->
+ notesStripHeight = with(density) { size.height.toDp() }
+ },
+ )
+ }
+
// Input area - overlays the bottom of the conversation
// Bridge file share from lower-level input to ViewModel
androidx.compose.runtime.LaunchedEffect(Unit) {
@@ -272,7 +389,10 @@ fun ChatScreen(viewModel: ChatViewModel) {
onShowAppInfo = { viewModel.showAppInfo() },
onPanicClear = { viewModel.panicClearAllData() },
onLocationChannelsClick = { showLocationChannelsSheet = true },
- onLocationNotesClick = { showLocationNotesSheet = true }
+ onLocationNotesClick = {
+ nearbyNotesController.reveal()
+ showLocationNotesSheet = true
+ }
)
// Scroll-to-bottom floating button
@@ -392,6 +512,68 @@ fun ChatScreen(viewModel: ChatViewModel) {
}
}
+@Composable
+private fun NearbyNotesRevealHint(
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val actionLabel = stringResource(R.string.nearby_notes_reveal)
+ TextButton(
+ onClick = onClick,
+ modifier = modifier
+ .fillMaxWidth()
+ .heightIn(min = 48.dp)
+ .padding(horizontal = 24.dp)
+ .semantics { contentDescription = actionLabel },
+ ) {
+ Text(
+ text = "📍 $actionLabel",
+ modifier = Modifier.clearAndSetSemantics { },
+ color = MaterialTheme.colorScheme.primary,
+ fontFamily = FontFamily.Monospace,
+ fontSize = 12.sp,
+ )
+ }
+}
+
+@Composable
+private fun NearbyNotesStrip(
+ noteCount: Int,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Surface(
+ onClick = onClick,
+ modifier = modifier.fillMaxWidth(),
+ color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f),
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(min = 48.dp)
+ .padding(horizontal = 12.dp, vertical = 7.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = "📍 " + if (noteCount == 1) {
+ stringResource(R.string.nearby_notes_one)
+ } else {
+ stringResource(R.string.nearby_notes_many, noteCount)
+ },
+ modifier = Modifier.weight(1f),
+ color = MaterialTheme.colorScheme.primary,
+ fontFamily = FontFamily.Monospace,
+ fontSize = 12.sp,
+ )
+ Text(
+ text = "›",
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ fontSize = 18.sp,
+ )
+ }
+ }
+}
+
@Composable
fun ChatInputSection(
messageText: TextFieldValue,
diff --git a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt
index 5c4c230e..740673be 100644
--- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt
+++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt
@@ -48,6 +48,9 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
+import com.bitchat.android.nostr.NearbyNotesController
+import com.bitchat.android.nostr.geohashesForSampling
+import com.bitchat.android.ui.theme.BASE_FONT_SIZE
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.R
import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet
@@ -104,6 +107,7 @@ fun LocationChannelsSheet(
val permissionState by locationManager.permissionState.collectAsStateWithLifecycle()
val availableChannels by locationManager.availableChannels.collectAsStateWithLifecycle()
+ val notesRevealed by NearbyNotesController.shared.revealed.collectAsStateWithLifecycle()
val selectedChannel by locationManager.selectedChannel.collectAsStateWithLifecycle()
val locationNames by locationManager.locationNames.collectAsStateWithLifecycle()
val locationServicesEnabled by locationManager.effectiveLocationEnabled.collectAsStateWithLifecycle()
@@ -527,9 +531,14 @@ fun LocationChannelsSheet(
onDispose { locationManager.endLiveRefresh() }
}
- LaunchedEffect(isPresented, availableChannels, bookmarks) {
+ // Sampling management: update sampling when channels/bookmarks change
+ LaunchedEffect(isPresented, availableChannels, bookmarks, notesRevealed) {
if (isPresented) {
- val geohashes = (availableChannels.map { it.geohash } + bookmarks).toSet().toList()
+ val geohashes = geohashesForSampling(
+ availableChannels = availableChannels,
+ bookmarks = bookmarks,
+ notesRevealed = notesRevealed,
+ )
viewModel.beginGeohashSampling(geohashes)
} else {
viewModel.endGeohashSampling()
diff --git a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt
index 4543d2af..7292df59 100644
--- a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt
+++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt
@@ -32,6 +32,7 @@ import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle
import com.bitchat.android.geohash.GeohashChannelLevel
import com.bitchat.android.geohash.LocationChannelManager
import com.bitchat.android.nostr.LocationNotesManager
+import com.bitchat.android.nostr.NearbyNotesController
import java.text.SimpleDateFormat
import java.util.*
import java.util.Calendar
@@ -58,12 +59,15 @@ fun LocationNotesSheet(
// Managers
val notesManager = remember { LocationNotesManager.getInstance() }
val locationManager = remember { LocationChannelManager.getInstance(context) }
+ val nearbyNotesController = remember { NearbyNotesController.shared }
// State
val notes by notesManager.notes.collectAsStateWithLifecycle()
val state by notesManager.state.collectAsStateWithLifecycle(LocationNotesManager.State.IDLE)
val errorMessage by notesManager.errorMessage.collectAsStateWithLifecycle()
val initialLoadComplete by notesManager.initialLoadComplete.collectAsStateWithLifecycle(false)
+ val permissionState by locationManager.permissionState.collectAsStateWithLifecycle()
+ val locationEnabled by locationManager.effectiveLocationEnabled.collectAsStateWithLifecycle(false)
// SIMPLIFIED: Get count directly from notes list (no separate counter needed)
val count = notes.size
@@ -94,15 +98,24 @@ fun LocationNotesSheet(
locationManager.refreshChannels()
}
- // Effect to set geohash when sheet opens
- LaunchedEffect(geohash) {
- notesManager.setGeohash(geohash)
- }
-
- // Cleanup when sheet closes
- DisposableEffect(Unit) {
+ // Opening the notes sheet is an explicit reveal. The balanced hold lets
+ // the mesh timeline keep the shared subscription alive after dismissal.
+ DisposableEffect(
+ geohash,
+ locationEnabled,
+ permissionState,
+ nearbyNotesController,
+ ) {
+ nearbyNotesController.updateAvailability(
+ locationEnabled = locationEnabled,
+ locationAuthorized =
+ permissionState == LocationChannelManager.PermissionState.AUTHORIZED,
+ buildingGeohash = geohash,
+ )
+ nearbyNotesController.activate()
+ nearbyNotesController.reveal()
onDispose {
- notesManager.cancel()
+ nearbyNotesController.deactivate()
}
}
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 4f4f53da..afee16ec 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.material.icons.Icons
import androidx.compose.material.icons.filled.Bluetooth
import androidx.compose.material.icons.filled.Wifi
@@ -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-ar/strings.xml b/app/src/main/res/values-ar/strings.xml
index 00892344..f78c1a95 100644
--- a/app/src/main/res/values-ar/strings.xml
+++ b/app/src/main/res/values-ar/strings.xml
@@ -286,7 +286,6 @@
اختيار
اكتب رسالة…
إشارة
- (~%1$s)
v%1$s
image/*
صورة
@@ -300,11 +299,6 @@
تشغيل
إيقاف مؤقت
- تعدين PoW
- PoW مفعّل
- برهان العمل
- يتم التعدين…
- pow: %1$dbit
مطلوب لاكتشاف مستخدمي bitchat عبر البلوتوث
@@ -394,4 +388,7 @@
You verified %1$s
verified %1$s
فتح قسم حول
+ تحقّق من الملاحظات المتروكة هنا
+ تُركت ملاحظة واحدة هنا — انقر للقراءة
+ تُركت %d ملاحظات هنا — انقر للقراءة
diff --git a/app/src/main/res/values-bn/strings.xml b/app/src/main/res/values-bn/strings.xml
index 45750704..30abcfbd 100644
--- a/app/src/main/res/values-bn/strings.xml
+++ b/app/src/main/res/values-bn/strings.xml
@@ -286,7 +286,6 @@
নির্বাচন করুন
বার্তা টাইপ করুন …
উল্লেখ
- (~%1$s)
v%1$s
image/*
ছবি
@@ -300,11 +299,6 @@
প্লে করুন
বিরাম
- মাইনিং PoW
- PoW সক্রিয়
- প্রুফ অফ ওয়ার্ক
- মাইনিং …
- pow: %1$dbit
ব্লুটুথের মাধ্যমে bitchat ব্যবহারকারী আবিষ্কার করতে প্রয়োজন
@@ -381,4 +375,7 @@
You verified %1$s
verified %1$s
পরিচিতি খুলুন
+ এখানে রাখা নোট আছে কি না দেখুন
+ এখানে 1টি নোট রাখা আছে — পড়তে ট্যাপ করুন
+ এখানে %dটি নোট রাখা আছে — পড়তে ট্যাপ করুন
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index dd2675fe..d8338a41 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -286,7 +286,6 @@
auswählen
Nachricht eingeben …
erwähnen
- (~%1$s)
v%1$s
image/*
Bild
@@ -300,11 +299,6 @@
Abspielen
Pause
- Mining‑PoW
- PoW aktiviert
- Proof of Work
- Mining …
- pow: %1$dbit
Erforderlich, um bitchat‑Benutzer über Bluetooth zu entdecken
@@ -395,4 +389,7 @@
Du hast %1$s verifiziert
verifiziert %1$s
Info öffnen
+ nachsehen, ob hier notizen hinterlassen wurden
+ 1 notiz hier hinterlassen — tippen zum lesen
+ %d notizen hier hinterlassen — tippen zum lesen
diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml
index 76871eeb..eefb901b 100644
--- a/app/src/main/res/values-es/strings.xml
+++ b/app/src/main/res/values-es/strings.xml
@@ -286,7 +286,6 @@
seleccionar
Escribe un mensaje…
mencionar
- (~%1$s)
v%1$s
image/*
Imagen
@@ -300,11 +299,6 @@
Reproducir
Pausar
- Minando PoW
- PoW habilitado
- Prueba de trabajo
- Minando…
- pow: %1$dbit
Requerido para descubrir usuarios bitchat a través de Bluetooth
@@ -394,4 +388,7 @@
Verificaste a %1$s
verificado %1$s
Abrir Acerca de
+ buscar notas dejadas aquí
+ 1 nota dejada aquí — toca para leer
+ %d notas dejadas aquí — toca para leer
diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml
index 0bd3529f..8f1d0bf0 100644
--- a/app/src/main/res/values-fa/strings.xml
+++ b/app/src/main/res/values-fa/strings.xml
@@ -286,7 +286,6 @@
انتخاب
نوشتن پیام …
ذکر
- (~%1$s)
v%1$s
image/*
تصویر
@@ -300,11 +299,6 @@
پخش
توقف
- استخراج PoW
- PoW فعال است
- گواه کار
- در حال استخراج …
- pow: %1$dbit
برای یافتن کاربران bitchat از طریق بلوتوث لازم است
@@ -381,4 +375,7 @@
You verified %1$s
verified %1$s
باز کردن درباره
+ یادداشتهای باقیمانده در اینجا را بررسی کنید
+ ۱ یادداشت اینجا باقی مانده — برای خواندن ضربه بزنید
+ %d یادداشت اینجا باقی مانده — برای خواندن ضربه بزنید
diff --git a/app/src/main/res/values-fil/strings.xml b/app/src/main/res/values-fil/strings.xml
index 288068bc..89724eb6 100644
--- a/app/src/main/res/values-fil/strings.xml
+++ b/app/src/main/res/values-fil/strings.xml
@@ -283,7 +283,6 @@
@%1$s
banggit
%1$d / %2$d
- (~%1$s)
@%1$s
v%1$s
#
@@ -393,4 +392,7 @@
You verified %1$s
verified %1$s
Buksan ang Tungkol
+ tingnan kung may mga note na naiwan dito
+ 1 note ang naiwan dito — i-tap para basahin
+ %d note ang naiwan dito — i-tap para basahin
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml
index 4d2a0d52..66f653ce 100644
--- a/app/src/main/res/values-fr/strings.xml
+++ b/app/src/main/res/values-fr/strings.xml
@@ -284,7 +284,6 @@
@%1$s
mention
%1$d / %2$d
- (~%1$s)
@%1$s
v%1$s
#
@@ -407,4 +406,7 @@
Vous avez vérifié %1$s
vérifié %1$s
Ouvrir À propos
+ vérifier s\'il y a des notes laissées ici
+ 1 note laissée ici — appuyez pour lire
+ %d notes laissées ici — appuyez pour lire
diff --git a/app/src/main/res/values-he/strings.xml b/app/src/main/res/values-he/strings.xml
index a78d83b6..3793942b 100644
--- a/app/src/main/res/values-he/strings.xml
+++ b/app/src/main/res/values-he/strings.xml
@@ -54,4 +54,7 @@
You verified %1$s
verified %1$s
פתיחת אודות
+ בדיקה אם הושארו כאן פתקים
+ פתק אחד הושאר כאן — הקש לקריאה
+ %d פתקים הושארו כאן — הקש לקריאה
diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml
index 5074fa44..87948601 100644
--- a/app/src/main/res/values-hi/strings.xml
+++ b/app/src/main/res/values-hi/strings.xml
@@ -286,7 +286,6 @@
चयन करें
संदेश लिखें…
उल्लेख
- (~%1$s)
v%1$s
image/*
छवि
@@ -300,11 +299,6 @@
चलाएँ
रोकें
- PoW माइनिंग
- PoW सक्षम
- प्रूफ़ ऑफ़ वर्क
- माइनिंग…
- pow: %1$dbit
ब्लूटूथ के माध्यम से bitchat उपयोगकर्ताओं की खोज के लिए आवश्यक
@@ -394,4 +388,7 @@
You verified %1$s
verified %1$s
परिचय खोलें
+ देखें कि यहाँ नोट छोड़े गए हैं या नहीं
+ यहाँ 1 नोट छोड़ा गया है — पढ़ने के लिए टैप करें
+ यहाँ %d नोट छोड़े गए हैं — पढ़ने के लिए टैप करें
diff --git a/app/src/main/res/values-id/strings.xml b/app/src/main/res/values-id/strings.xml
index cae8b84a..1a11487c 100644
--- a/app/src/main/res/values-id/strings.xml
+++ b/app/src/main/res/values-id/strings.xml
@@ -286,7 +286,6 @@
pilih
Ketik pesan …
sebut
- (~%1$s)
v%1$s
image/*
Gambar
@@ -300,11 +299,6 @@
Putar
Jeda
- Mining PoW
- PoW diaktifkan
- Proof of Work
- Mining …
- pow: %1$dbit
Diperlukan untuk menemukan pengguna bitchat melalui Bluetooth
@@ -394,4 +388,7 @@
You verified %1$s
verified %1$s
Buka Tentang
+ periksa catatan yang ditinggalkan di sini
+ 1 catatan ditinggalkan di sini — ketuk untuk membaca
+ %d catatan ditinggalkan di sini — ketuk untuk membaca
diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml
index 78cc8b84..36feb8cf 100644
--- a/app/src/main/res/values-it/strings.xml
+++ b/app/src/main/res/values-it/strings.xml
@@ -286,7 +286,6 @@
seleziona
scrivi un messaggio…
menzione
- (~%1$s)
v%1$s
image/*
Immagine
@@ -300,11 +299,6 @@
Riproduci
Pausa
- Mining PoW
- PoW abilitato
- Proof of Work
- mining…
- pow: %1$dbit
Necessario per scoprire utenti bitchat tramite Bluetooth
@@ -427,4 +421,7 @@
Hai verificato %1$s
verificato %1$s
Apri Informazioni
+ controlla se ci sono note lasciate qui
+ 1 nota lasciata qui — tocca per leggere
+ %d note lasciate qui — tocca per leggere
diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml
index c5b7a5c6..9b2844c2 100644
--- a/app/src/main/res/values-ja/strings.xml
+++ b/app/src/main/res/values-ja/strings.xml
@@ -286,7 +286,6 @@
選択
メッセージを入力…
メンション
- (~%1$s)
v%1$s
image/*
画像
@@ -300,11 +299,6 @@
再生
一時停止
- PoW をマイニング中
- PoW 有効
- Proof of Work
- マイニング中…
- pow: %1$dbit
Bluetooth で bitchat ユーザーを検出するために必要です
@@ -394,4 +388,7 @@
%1$s を検証しました
%1$s を検証しました
このアプリについてを開く
+ ここに残されたメモを確認
+ ここに1件のメモがあります — タップして読む
+ ここに%d件のメモがあります — タップして読む
diff --git a/app/src/main/res/values-ka/strings.xml b/app/src/main/res/values-ka/strings.xml
index 2c682575..d1d19dad 100644
--- a/app/src/main/res/values-ka/strings.xml
+++ b/app/src/main/res/values-ka/strings.xml
@@ -286,7 +286,6 @@
არჩევა
შეიყვანეთ შეტყობინება…
ხსენება
- (~%1$s)
v%1$s
image/*
სურათი
@@ -300,11 +299,6 @@
დაკვრა
პაუზა
- PoW მაინინგი
- PoW ჩართულია
- Proof of Work
- მაინინგი…
- pow: %1$dbit
საჭიროა bitchat მომხმარებლების Bluetooth-ით აღმოსაჩენად
@@ -381,4 +375,7 @@
You verified %1$s
verified %1$s
აპის შესახებ გახსნა
+ აქ დატოვებული ჩანაწერების შემოწმება
+ აქ 1 ჩანაწერია დატოვებული — წასაკითხად შეეხეთ
+ აქ %d ჩანაწერია დატოვებული — წასაკითხად შეეხეთ
diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml
index 4cda1af6..d72df76f 100644
--- a/app/src/main/res/values-ko/strings.xml
+++ b/app/src/main/res/values-ko/strings.xml
@@ -286,7 +286,6 @@
선택
메시지 입력 …
멘션
- (~%1$s)
v%1$s
image/*
이미지
@@ -300,11 +299,6 @@
재생
일시정지
- PoW 채굴
- PoW 사용
- 작업증명
- 채굴 중 …
- pow: %1$dbit
블루투스를 통해 bitchat 사용자를 발견하는 데 필요
@@ -394,4 +388,7 @@
You verified %1$s
verified %1$s
정보 열기
+ 여기 남겨진 쪽지 확인
+ 여기 남겨진 쪽지 1개 — 탭하여 읽기
+ 여기 남겨진 쪽지 %d개 — 탭하여 읽기
diff --git a/app/src/main/res/values-mg/strings.xml b/app/src/main/res/values-mg/strings.xml
index 6cff0fe3..a15540b1 100644
--- a/app/src/main/res/values-mg/strings.xml
+++ b/app/src/main/res/values-mg/strings.xml
@@ -292,7 +292,6 @@
@%1$s
hanonona
%1$d / %2$d
- (~%1$s)
@%1$s
v%1$s
#
@@ -326,11 +325,6 @@
Hilalao
Hijanona
- Mihaingam-poana PoW
- Alefa ny PoW
- Porofo Asa
- mihaingana...
- pow: %1$dbit
Ilaina mba hahitana mpampiasa bitchat amin\'ny alalan\'ny Bluetooth
@@ -407,4 +401,7 @@
You verified %1$s
verified %1$s
Sokafy ny momba
+ hizaha raha misy naoty navela teto
+ naoty 1 no navela teto — tsindrio raha hamaky
+ naoty %d no navela teto — tsindrio raha hamaky
diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml
index 9b6f2bb8..add7b467 100644
--- a/app/src/main/res/values-ms/strings.xml
+++ b/app/src/main/res/values-ms/strings.xml
@@ -41,4 +41,7 @@
You verified %1$s
verified %1$s
Buka Perihal
+ semak nota yang ditinggalkan di sini
+ 1 nota ditinggalkan di sini — ketik untuk baca
+ %d nota ditinggalkan di sini — ketik untuk baca
diff --git a/app/src/main/res/values-ne/strings.xml b/app/src/main/res/values-ne/strings.xml
index f80354db..b9df0f99 100644
--- a/app/src/main/res/values-ne/strings.xml
+++ b/app/src/main/res/values-ne/strings.xml
@@ -283,7 +283,6 @@
@%1$s
उल्लेख
%1$d / %2$d
- (~%1$s)
@%1$s
v%1$s
#
@@ -393,4 +392,7 @@
You verified %1$s
verified %1$s
परिचय खोल्नुहोस्
+ यहाँ छोडिएका नोटहरू छन् कि हेर्नुहोस्
+ यहाँ 1 नोट छोडिएको छ — पढ्न ट्याप गर्नुहोस्
+ यहाँ %d नोटहरू छोडिएका छन् — पढ्न ट्याप गर्नुहोस्
diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml
index 7ce6dbc9..8328b8ff 100644
--- a/app/src/main/res/values-nl/strings.xml
+++ b/app/src/main/res/values-nl/strings.xml
@@ -286,7 +286,6 @@
selecteren
typ een bericht…
vermelden
- (~%1$s)
v%1$s
image/*
Afbeelding
@@ -300,11 +299,6 @@
Afspelen
Pauzeren
- Mining-PoW
- PoW ingeschakeld
- Proof of Work
- minen…
- pow: %1$dbit
Vereist om bitchat-gebruikers via Bluetooth te ontdekken
@@ -425,4 +419,7 @@
You verified %1$s
verified %1$s
Info openen
+ kijk of hier notities zijn achtergelaten
+ 1 notitie hier achtergelaten — tik om te lezen
+ %d notities hier achtergelaten — tik om te lezen
diff --git a/app/src/main/res/values-pa-rPK/strings.xml b/app/src/main/res/values-pa-rPK/strings.xml
index 46024ab5..4079133a 100644
--- a/app/src/main/res/values-pa-rPK/strings.xml
+++ b/app/src/main/res/values-pa-rPK/strings.xml
@@ -286,7 +286,6 @@
چُنو
پیغام لکھو …
ذکر
- (~%1$s)
v%1$s
image/*
تصویر
@@ -300,11 +299,6 @@
چلاؤ
روکੋ
- PoW مائننگ
- PoW چالو
- Proof of Work
- مائننگ …
- pow: %1$dbit
بلوٹوتھ راہین bitchat یوزر لبھن لئی ضروری
@@ -381,4 +375,7 @@
You verified %1$s
verified %1$s
ایپ بارے کھولو
+ ایتھے چھڈے نوٹس ویکھو
+ ایتھے 1 نوٹ چھڈیا گیا — پڑھݨ لئی ٹیپ کرو
+ ایتھے %d نوٹس چھڈے گئے — پڑھݨ لئی ٹیپ کرو
diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml
index 1579f9b3..f1a67349 100644
--- a/app/src/main/res/values-pl/strings.xml
+++ b/app/src/main/res/values-pl/strings.xml
@@ -54,4 +54,7 @@
You verified %1$s
verified %1$s
Otwórz informacje
+ sprawdź, czy zostawiono tutaj notatki
+ 1 notatka zostawiona tutaj — stuknij, aby przeczytać
+ %d notatek zostawionych tutaj — stuknij, aby przeczytać
diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml
index 96043e4f..2bf02cb5 100644
--- a/app/src/main/res/values-pt-rBR/strings.xml
+++ b/app/src/main/res/values-pt-rBR/strings.xml
@@ -286,7 +286,6 @@
selecionar
Digite uma mensagem…
mencionar
- (~%1$s)
v%1$s
image/*
Imagem
@@ -300,11 +299,6 @@
Reproduzir
Pausar
- Minerando PoW
- PoW ativado
- Prova de Trabalho
- Minerando…
- pow: %1$dbit
Necessário para descobrir usuários do bitchat via Bluetooth
@@ -393,4 +387,7 @@
Verificado
Você verificou %1$s
verificou %1$s
+ ver se há notas deixadas aqui
+ 1 nota deixada aqui — toque para ler
+ %d notas deixadas aqui — toque para ler
diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml
index 184e4b2a..e45c26a6 100644
--- a/app/src/main/res/values-pt/strings.xml
+++ b/app/src/main/res/values-pt/strings.xml
@@ -286,7 +286,6 @@
selecionar
Digite uma mensagem…
mencionar
- (~%1$s)
v%1$s
image/*
Imagem
@@ -300,11 +299,6 @@
Reproduzir
Pausar
- A minerar PoW
- PoW ativado
- Prova de Trabalho
- A minerar…
- pow: %1$dbit
Necessário para descobrir utilizadores bitchat via Bluetooth
@@ -394,4 +388,7 @@
Você verificou %1$s
verificou %1$s
Abrir Sobre
+ ver se há notas deixadas aqui
+ 1 nota deixada aqui — toque para ler
+ %d notas deixadas aqui — toque para ler
diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml
index 4a60c472..1230f4be 100644
--- a/app/src/main/res/values-ru/strings.xml
+++ b/app/src/main/res/values-ru/strings.xml
@@ -267,7 +267,6 @@
@%1$s
упоминание
%1$d / %2$d
- (~%1$s)
@%1$s
v%1$s
#
@@ -383,4 +382,7 @@
Вы проверили %1$s
проверен %1$s
Открыть раздел «О приложении»
+ проверить, есть ли здесь заметки
+ здесь оставлена 1 заметка — нажмите, чтобы прочитать
+ здесь оставлено заметок: %d — нажмите, чтобы прочитать
diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml
index 9eabab8a..17cc2963 100644
--- a/app/src/main/res/values-sv/strings.xml
+++ b/app/src/main/res/values-sv/strings.xml
@@ -267,7 +267,6 @@
@%1$s
nämn
%1$d / %2$d
- (~%1$s)
@%1$s
v%1$s
#
@@ -381,4 +380,7 @@
You verified %1$s
verified %1$s
Öppna Om
+ kolla om anteckningar lämnats här
+ 1 anteckning lämnad här — tryck för att läsa
+ %d anteckningar lämnade här — tryck för att läsa
diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml
index a47d4dbd..05bd4750 100644
--- a/app/src/main/res/values-ta/strings.xml
+++ b/app/src/main/res/values-ta/strings.xml
@@ -41,4 +41,7 @@
You verified %1$s
verified %1$s
அறிமுகத்தைத் திற
+ இங்கே விடப்பட்ட குறிப்புகள் உள்ளதா எனப் பார்க்கவும்
+ இங்கே 1 குறிப்பு விடப்பட்டுள்ளது — படிக்க தட்டவும்
+ இங்கே %d குறிப்புகள் விடப்பட்டுள்ளன — படிக்க தட்டவும்
diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml
index ddb21b5e..53dc6610 100644
--- a/app/src/main/res/values-th/strings.xml
+++ b/app/src/main/res/values-th/strings.xml
@@ -286,7 +286,6 @@
เลือก
พิมพ์ข้อความ…
กล่าวถึง
- (~%1$s)
v%1$s
image/*
รูปภาพ
@@ -300,11 +299,6 @@
เล่น
หยุดชั่วคราว
- กำลังขุด PoW
- เปิดใช้ PoW
- Proof of Work
- กำลังขุด…
- pow: %1$dbit
จำเป็นสำหรับการค้นหาผู้ใช้ bitchat ผ่านบลูทูธ
@@ -381,4 +375,7 @@
You verified %1$s
verified %1$s
เปิดเกี่ยวกับ
+ ดูว่ามีโน้ตทิ้งไว้ที่นี่หรือไม่
+ มี 1 โน้ตทิ้งไว้ที่นี่ — แตะเพื่ออ่าน
+ มี %d โน้ตทิ้งไว้ที่นี่ — แตะเพื่ออ่าน
diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml
index 7d64afdf..e7eb1b09 100644
--- a/app/src/main/res/values-tr/strings.xml
+++ b/app/src/main/res/values-tr/strings.xml
@@ -267,7 +267,6 @@
@%1$s
bahset
%1$d / %2$d
- (~%1$s)
@%1$s
v%1$s
#
@@ -381,4 +380,7 @@
You verified %1$s
verified %1$s
Hakkında’yı aç
+ buraya bırakılan notlara bak
+ buraya 1 not bırakıldı — okumak için dokun
+ buraya %d not bırakıldı — okumak için dokun
diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml
index 9ce840ac..9ca2d2d0 100644
--- a/app/src/main/res/values-uk/strings.xml
+++ b/app/src/main/res/values-uk/strings.xml
@@ -41,4 +41,7 @@
You verified %1$s
verified %1$s
Відкрити розділ «Про застосунок»
+ перевірити, чи залишено тут нотатки
+ тут залишено 1 нотатку — торкніться, щоб прочитати
+ тут залишено %d нотаток — торкніться, щоб прочитати
diff --git a/app/src/main/res/values-ur/strings.xml b/app/src/main/res/values-ur/strings.xml
index 60fc378f..6bc67120 100644
--- a/app/src/main/res/values-ur/strings.xml
+++ b/app/src/main/res/values-ur/strings.xml
@@ -286,7 +286,6 @@
منتخب کریں
پیغام لکھیں…
ذکر
- (~%1$s)
v%1$s
image/*
تصویر
@@ -300,11 +299,6 @@
چلائیں
روکیں
- PoW مائننگ
- PoW فعال
- پروف آف ورک
- مائننگ…
- pow: %1$dbit
بلوٹوتھ کے ذریعے bitchat صارفین کی دریافت کے لیے ضروری
@@ -394,4 +388,7 @@
You verified %1$s
verified %1$s
تعارف کھولیں
+ دیکھیں کہ یہاں نوٹ چھوڑے گئے ہیں یا نہیں
+ یہاں 1 نوٹ چھوڑا گیا ہے — پڑھنے کے لیے تھپتھپائیں
+ یہاں %d نوٹ چھوڑے گئے ہیں — پڑھنے کے لیے تھپتھپائیں
diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml
index 7858dd49..7e1c9ef3 100644
--- a/app/src/main/res/values-vi/strings.xml
+++ b/app/src/main/res/values-vi/strings.xml
@@ -286,7 +286,6 @@
chọn
Nhập tin nhắn …
nhắc đến
- (~%1$s)
v%1$s
image/*
Hình ảnh
@@ -300,11 +299,6 @@
Phát
Tạm dừng
- Đang mining PoW
- PoW đã bật
- Proof of Work
- Đang mining …
- pow: %1$dbit
Cần thiết để khám phá người dùng bitchat qua Bluetooth
@@ -381,4 +375,7 @@
You verified %1$s
verified %1$s
Mở phần Giới thiệu
+ kiểm tra ghi chú để lại ở đây
+ có 1 ghi chú để lại ở đây — chạm để đọc
+ có %d ghi chú để lại ở đây — chạm để đọc
diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml
index 69bff70c..438131b0 100644
--- a/app/src/main/res/values-zh-rCN/strings.xml
+++ b/app/src/main/res/values-zh-rCN/strings.xml
@@ -53,5 +53,7 @@
已验证
你已验证 %1$s
已验证 %1$s
+ 查看这里留下的留言
+ 这里留有 1 条留言 — 点按阅读
+ 这里留有 %d 条留言 — 点按阅读
-
diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml
index 1dcf6b27..328696a0 100644
--- a/app/src/main/res/values-zh-rTW/strings.xml
+++ b/app/src/main/res/values-zh-rTW/strings.xml
@@ -53,5 +53,7 @@
已验证
你已验证 %1$s
已验证 %1$s
+ 查看這裡留下的留言
+ 這裡留有 1 則留言 — 點按閱讀
+ 這裡留有 %d 則留言 — 點按閱讀
-
diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml
index 21ce5d51..a5758b89 100644
--- a/app/src/main/res/values-zh/strings.xml
+++ b/app/src/main/res/values-zh/strings.xml
@@ -283,7 +283,6 @@
@%1$s
提及
%1$d / %2$d
- (~%1$s)
@%1$s
v%1$s
#
@@ -406,4 +405,7 @@
你已验证 %1$s
已验证 %1$s
打开“关于”
+ 查看这里留下的留言
+ 这里留有 1 条留言 — 点按阅读
+ 这里留有 %d 条留言 — 点按阅读
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 05d99721..275c5f79 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -177,6 +177,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
@@ -207,11 +272,6 @@
Link
Record voice note
Pick media
- Mining PoW
- PoW Enabled
- Proof of Work
- Mining…
- PoW: %1$dbit
Offline Mesh Chat
Online Geohash Channels
End-to-End Encryption
@@ -279,6 +339,10 @@
Region
+ check for notes left here
+ 1 note left here — tap to read
+
+ %d notes left here — tap to read
- #%1$s ± 1 • %2$d note
- #%1$s ± 1 • %2$d notes
@@ -426,7 +490,6 @@
@%1$s
Mention
%1$d / %2$d
- (~%1$s)
@%1$s
v%1$s
#
@@ -489,6 +552,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/nostr/NearbyNotesControllerTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NearbyNotesControllerTest.kt
new file mode 100644
index 00000000..6bd86042
--- /dev/null
+++ b/app/src/test/kotlin/com/bitchat/android/nostr/NearbyNotesControllerTest.kt
@@ -0,0 +1,159 @@
+package com.bitchat.android.nostr
+
+import com.bitchat.android.geohash.GeohashChannel
+import com.bitchat.android.geohash.GeohashChannelLevel
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class NearbyNotesControllerTest {
+ private val subscriptions = mutableListOf()
+ private var unsubscribeCount = 0
+
+ private fun controller() = NearbyNotesController(
+ subscribe = subscriptions::add,
+ unsubscribe = { unsubscribeCount += 1 },
+ )
+
+ private fun foregroundController() = controller().also {
+ it.updateAppForeground(true)
+ }
+
+ @Test
+ fun `active mesh timeline does not subscribe before explicit reveal`() {
+ val controller = foregroundController()
+
+ controller.updateAvailability(
+ locationEnabled = true,
+ locationAuthorized = true,
+ buildingGeohash = "u4pruydq",
+ )
+ controller.activate()
+
+ assertTrue(controller.offersRevealHint())
+ assertTrue(subscriptions.isEmpty())
+
+ controller.reveal()
+
+ assertFalse(controller.offersRevealHint())
+ assertEquals(listOf("u4pruydq"), subscriptions)
+ }
+
+ @Test
+ fun `reveal remains dormant until a nearby notes surface is active`() {
+ val controller = foregroundController()
+ controller.updateAvailability(true, true, "u4pruydq")
+
+ controller.reveal()
+
+ assertTrue(subscriptions.isEmpty())
+
+ controller.activate()
+
+ assertEquals(listOf("u4pruydq"), subscriptions)
+ }
+
+ @Test
+ fun `last deactivate unsubscribes exactly once`() {
+ val controller = foregroundController()
+ controller.updateAvailability(true, true, "u4pruydq")
+ controller.reveal()
+ controller.activate()
+ controller.activate()
+
+ controller.deactivate()
+ assertEquals(0, unsubscribeCount)
+
+ controller.deactivate()
+ controller.deactivate()
+
+ assertEquals(1, unsubscribeCount)
+ }
+
+ @Test
+ fun `backgrounding closes the subscription and foregrounding restores it`() {
+ val controller = foregroundController()
+ controller.updateAvailability(true, true, "u4pruydq")
+ controller.activate()
+ controller.reveal()
+
+ controller.updateAppForeground(false)
+
+ assertEquals(1, unsubscribeCount)
+ assertTrue(controller.revealed.value)
+
+ controller.updateAppForeground(false)
+ assertEquals(1, unsubscribeCount)
+
+ controller.updateAppForeground(true)
+ assertEquals(listOf("u4pruydq", "u4pruydq"), subscriptions)
+ }
+
+ @Test
+ fun `disable and permission revocation close the live subscription`() {
+ val controller = foregroundController()
+ controller.updateAvailability(true, true, "u4pruydq")
+ controller.activate()
+ controller.reveal()
+
+ controller.updateAvailability(false, true, "u4pruydq")
+ assertEquals(1, unsubscribeCount)
+
+ controller.updateAvailability(true, true, "u4pruydq")
+ assertEquals(listOf("u4pruydq", "u4pruydq"), subscriptions)
+
+ controller.updateAvailability(true, false, "u4pruydq")
+ assertEquals(2, unsubscribeCount)
+ }
+
+ @Test
+ fun `moving building cells releases old subscription before retargeting`() {
+ val events = mutableListOf()
+ val controller = NearbyNotesController(
+ subscribe = { events += "subscribe:$it" },
+ unsubscribe = { events += "unsubscribe" },
+ )
+ controller.updateAppForeground(true)
+ controller.updateAvailability(true, true, "u4pruydq")
+ controller.activate()
+ controller.reveal()
+
+ controller.updateAvailability(true, true, "u4pruydr")
+
+ assertEquals(
+ listOf(
+ "subscribe:u4pruydq",
+ "unsubscribe",
+ "subscribe:u4pruydr",
+ ),
+ events,
+ )
+ }
+
+ @Test
+ fun `building sampling is excluded until reveal while bookmarks remain eligible`() {
+ val channels = listOf(
+ GeohashChannel(GeohashChannelLevel.BUILDING, "u4pruydq"),
+ GeohashChannel(GeohashChannelLevel.BLOCK, "u4pruyd"),
+ GeohashChannel(GeohashChannelLevel.CITY, "u4pru"),
+ )
+
+ assertEquals(
+ listOf("u4pruyd", "u4pru", "saved123"),
+ geohashesForSampling(
+ availableChannels = channels,
+ bookmarks = listOf("saved123"),
+ notesRevealed = false,
+ ),
+ )
+ assertEquals(
+ listOf("u4pruydq", "u4pruyd", "u4pru", "saved123"),
+ geohashesForSampling(
+ availableChannels = channels,
+ bookmarks = listOf("saved123"),
+ notesRevealed = true,
+ ),
+ )
+ }
+}
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" }