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

+
    +
  1. Tap the download button above
  2. +
  3. Wait for the download to complete
  4. +
  5. Open the downloaded APK file
  6. +
  7. If prompted, enable "Install from unknown sources" for your browser
  8. +
  9. Follow the installation prompts
  10. +
+
+ +
+ ⚠️ Note: + If you already have BitChat installed, you may need to uninstall it first before installing this version. Make sure to backup your data if needed. +
+
+ + + """.trimIndent() + } + + /** + * Start the server. + */ + fun startServer() { + try { + start(NanoHTTPD.SOCKET_READ_TIMEOUT, false) + Log.d(TAG, "Web server started on port $port") + } catch (e: Exception) { + Log.e(TAG, "Failed to start web server", e) + throw e + } + } + + /** + * Stop the server. + */ + fun stopServer() { + try { + stop() + Log.d(TAG, "Web server stopped") + } catch (e: Exception) { + Log.e(TAG, "Error stopping web server", e) + } + } +} diff --git a/app/src/main/java/com/bitchat/android/hotspot/HotspotActivity.kt b/app/src/main/java/com/bitchat/android/hotspot/HotspotActivity.kt new file mode 100644 index 00000000..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" }