Merge remote-tracking branch 'origin/main' into opus/redesign-proposal

# Conflicts:
#	app/src/main/java/com/bitchat/android/ui/AboutSheet.kt
#	app/src/main/java/com/bitchat/android/ui/ChatScreen.kt
#	app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt
#	app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt
This commit is contained in:
callebtc 2026-07-27 04:50:47 +02:00
commit 0cb28a4f3a
64 changed files with 5339 additions and 174 deletions

3
.gitignore vendored
View File

@ -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

View File

@ -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)

View File

@ -22,13 +22,18 @@
<!-- Notification permissions -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- WiFi / WiFi Aware permissions -->
<!-- WiFi / WiFi Aware permissions (also used for hotspot APK sharing) -->
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<!-- Android 13+ runtime permission for WiFi operations (including Aware) -->
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" />
<!-- Android 13+ runtime permission for WiFi operations (including Aware and WiFi P2P) -->
<uses-permission
android:name="android.permission.NEARBY_WIFI_DEVICES"
android:usesPermissionFlags="neverForLocation" />
<!-- Android 17+ gates local network access; WiFi Aware peers over link-local IPv6 -->
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
<!-- Keep hotspot alive while sharing the APK -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- Signature permission for internal UI shutdown broadcasts -->
<uses-permission android:name="com.bitchat.android.permission.FORCE_FINISH" />
<!-- Foreground service and boot permissions for long-running background mesh -->
@ -142,5 +147,20 @@
<action android:name="android.intent.action.LOCKED_BOOT_COMPLETED" />
</intent-filter>
</receiver>
<!-- Hotspot Activity for offline APK sharing -->
<activity
android:name=".hotspot.HotspotActivity"
android:exported="false"
android:label="Share BitChat"
android:theme="@style/Theme.BitchatAndroid"
android:launchMode="singleTop" />
<!-- Declare the foreground service type for WorkManager's foreground
service so the APK download worker can run as dataSync work -->
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="dataSync"
tools:node="merge" />
</application>
</manifest>

View File

@ -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 """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Download BitChat</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
color: #333;
}
.container {
background: white;
border-radius: 20px;
padding: 40px 30px;
max-width: 500px;
width: 100%;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
text-align: center;
}
.logo {
font-size: 64px;
margin-bottom: 20px;
}
h1 {
font-size: 32px;
margin-bottom: 10px;
color: #667eea;
}
.subtitle {
font-size: 16px;
color: #666;
margin-bottom: 30px;
}
.info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
margin-bottom: 30px;
}
.info-box {
background: #f5f7fa;
padding: 15px;
border-radius: 10px;
}
.info-label {
font-size: 12px;
color: #888;
text-transform: uppercase;
font-weight: 600;
margin-bottom: 5px;
}
.info-value {
font-size: 18px;
font-weight: bold;
color: #333;
}
.download-button {
display: inline-block;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 18px 40px;
border-radius: 50px;
text-decoration: none;
font-size: 18px;
font-weight: 600;
margin-bottom: 30px;
transition: transform 0.2s, box-shadow 0.2s;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
}
.download-button:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.5);
}
.download-button:active {
transform: translateY(0);
}
.instructions {
text-align: left;
background: #f5f7fa;
padding: 20px;
border-radius: 10px;
margin-top: 20px;
}
.instructions h3 {
font-size: 16px;
margin-bottom: 15px;
color: #667eea;
}
.instructions ol {
margin-left: 20px;
}
.instructions li {
margin-bottom: 10px;
line-height: 1.6;
font-size: 14px;
color: #555;
}
.warning {
background: #fff3cd;
border: 1px solid #ffc107;
padding: 15px;
border-radius: 10px;
margin-top: 20px;
font-size: 13px;
color: #856404;
text-align: left;
}
.warning strong {
display: block;
margin-bottom: 5px;
}
</style>
</head>
<body>
<div class="container">
<div class="logo">🔒</div>
<h1>BitChat</h1>
<p class="subtitle">Secure Mesh Messaging</p>
<div class="info-grid">
<div class="info-box">
<div class="info-label">Version</div>
<div class="info-value">$appVersion</div>
</div>
<div class="info-box">
<div class="info-label">Size</div>
<div class="info-value">${apkSizeMb} MB</div>
</div>
</div>
<a href="/bitchat.apk" class="download-button">
📥 Download BitChat
</a>
<div class="instructions">
<h3>📱 Installation Instructions</h3>
<ol>
<li>Tap the download button above</li>
<li>Wait for the download to complete</li>
<li>Open the downloaded APK file</li>
<li>If prompted, enable "Install from unknown sources" for your browser</li>
<li>Follow the installation prompts</li>
</ol>
</div>
<div class="warning">
<strong> Note:</strong>
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.
</div>
</div>
</body>
</html>
""".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)
}
}
}

View File

@ -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")
}
}
}

View File

@ -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)
}
}

View File

@ -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>(HotspotState.Intro)
val state: StateFlow<HotspotState> = _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()
}
}

View File

@ -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(":", "\\:")
}
}

View File

@ -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 {

View File

@ -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()
}
}

View File

@ -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<Boolean> = _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<GeohashChannel>,
bookmarks: Collection<String>,
notesRevealed: Boolean,
): List<String> = buildSet {
availableChannels
.filter { notesRevealed || it.level != GeohashChannelLevel.BUILDING }
.mapTo(this) { it.geohash }
addAll(bookmarks)
}.toList()

View File

@ -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
)
}
}

View File

@ -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,

View File

@ -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<ApkUiState> = _state.asStateFlow()
private val _effect = Channel<ApkUiEffect>(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<Application>()
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<Application>().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)
)
}
}
}

View File

@ -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,

View File

@ -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()

View File

@ -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()
}
}

View File

@ -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<DistributionInfoProvider.DistributionInfo?>(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)) {

View File

@ -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)
}
}
}

View File

@ -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<DownloadState>
/**
* 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()
}
}

View File

@ -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<out String>): 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<String> {
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<String> {
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?
)
}

View File

@ -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<Release> =
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<Release> {
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
)
}

View File

@ -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<File> = 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=<size>-" 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<String> {
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()
}
}

View File

@ -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<ApkDownloader.DownloadState> =
workManager.getWorkInfosForUniqueWorkFlow(ApkDownloadWorker.WORK_NAME)
.map { workInfos -> mapWorkInfoToState(workInfos.firstOrNull()) }
override fun startDownload() {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val request = OneTimeWorkRequestBuilder<ApkDownloadWorker>()
.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
}
}
}
}
}

View File

@ -286,7 +286,6 @@
<string name="select">اختيار</string>
<string name="type_a_message_placeholder">اكتب رسالة…</string>
<string name="mention">إشارة</string>
<string name="pow_time_estimate">(~%1$s)</string>
<string name="version_prefix">v%1$s</string>
<string name="image_star">image/*</string>
<string name="media_type_image">صورة</string>
@ -300,11 +299,6 @@
<!-- سهولة استخدام مشغّل الصوت -->
<string name="cd_play_voice">تشغيل</string>
<string name="cd_pause_voice">إيقاف مؤقت</string>
<string name="cd_mining_pow">تعدين PoW</string>
<string name="cd_pow_enabled">PoW مفعّل</string>
<string name="cd_proof_of_work">برهان العمل</string>
<string name="pow_mining_ellipsis">يتم التعدين…</string>
<string name="pow_label_format">pow: %1$dbit</string>
<!-- أوصاف الأذونات -->
<string name="perm_nearby_devices_desc">مطلوب لاكتشاف مستخدمي bitchat عبر البلوتوث</string>
@ -394,4 +388,7 @@
<string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
<string name="cd_open_about">فتح قسم حول</string>
<string name="nearby_notes_reveal">تحقّق من الملاحظات المتروكة هنا</string>
<string name="nearby_notes_one">تُركت ملاحظة واحدة هنا — انقر للقراءة</string>
<string name="nearby_notes_many">تُركت %d ملاحظات هنا — انقر للقراءة</string>
</resources>

View File

@ -286,7 +286,6 @@
<string name="select">নির্বাচন করুন</string>
<string name="type_a_message_placeholder">বার্তা টাইপ করুন …</string>
<string name="mention">উল্লেখ</string>
<string name="pow_time_estimate">(~%1$s)</string>
<string name="version_prefix">v%1$s</string>
<string name="image_star">image/*</string>
<string name="media_type_image">ছবি</string>
@ -300,11 +299,6 @@
<!-- ভয়েস প্লেয়ার অ্যাক্সেসিবিলিটি -->
<string name="cd_play_voice">প্লে করুন</string>
<string name="cd_pause_voice">বিরাম</string>
<string name="cd_mining_pow">মাইনিং PoW</string>
<string name="cd_pow_enabled">PoW সক্রিয়</string>
<string name="cd_proof_of_work">প্রুফ অফ ওয়ার্ক</string>
<string name="pow_mining_ellipsis">মাইনিং …</string>
<string name="pow_label_format">pow: %1$dbit</string>
<!-- অনুমতি বিবরণ -->
<string name="perm_nearby_devices_desc">ব্লুটুথের মাধ্যমে bitchat ব্যবহারকারী আবিষ্কার করতে প্রয়োজন</string>
@ -381,4 +375,7 @@
<string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
<string name="cd_open_about">পরিচিতি খুলুন</string>
<string name="nearby_notes_reveal">এখানে রাখা নোট আছে কি না দেখুন</string>
<string name="nearby_notes_one">এখানে 1টি নোট রাখা আছে — পড়তে ট্যাপ করুন</string>
<string name="nearby_notes_many">এখানে %dটি নোট রাখা আছে — পড়তে ট্যাপ করুন</string>
</resources>

View File

@ -286,7 +286,6 @@
<string name="select">auswählen</string>
<string name="type_a_message_placeholder">Nachricht eingeben …</string>
<string name="mention">erwähnen</string>
<string name="pow_time_estimate">(~%1$s)</string>
<string name="version_prefix">v%1$s</string>
<string name="image_star">image/*</string>
<string name="media_type_image">Bild</string>
@ -300,11 +299,6 @@
<!-- Sprachplayer Barrierefreiheit -->
<string name="cd_play_voice">Abspielen</string>
<string name="cd_pause_voice">Pause</string>
<string name="cd_mining_pow">MiningPoW</string>
<string name="cd_pow_enabled">PoW aktiviert</string>
<string name="cd_proof_of_work">Proof of Work</string>
<string name="pow_mining_ellipsis">Mining …</string>
<string name="pow_label_format">pow: %1$dbit</string>
<!-- Berechtigungsbeschreibungen -->
<string name="perm_nearby_devices_desc">Erforderlich, um bitchatBenutzer über Bluetooth zu entdecken</string>
@ -395,4 +389,7 @@
<string name="verify_success_body">Du hast %1$s verifiziert</string>
<string name="verify_success_system_message">verifiziert %1$s</string>
<string name="cd_open_about">Info öffnen</string>
<string name="nearby_notes_reveal">nachsehen, ob hier notizen hinterlassen wurden</string>
<string name="nearby_notes_one">1 notiz hier hinterlassen — tippen zum lesen</string>
<string name="nearby_notes_many">%d notizen hier hinterlassen — tippen zum lesen</string>
</resources>

View File

@ -286,7 +286,6 @@
<string name="select">seleccionar</string>
<string name="type_a_message_placeholder">Escribe un mensaje…</string>
<string name="mention">mencionar</string>
<string name="pow_time_estimate">(~%1$s)</string>
<string name="version_prefix">v%1$s</string>
<string name="image_star">image/*</string>
<string name="media_type_image">Imagen</string>
@ -300,11 +299,6 @@
<!-- Accesibilidad del reproductor de voz -->
<string name="cd_play_voice">Reproducir</string>
<string name="cd_pause_voice">Pausar</string>
<string name="cd_mining_pow">Minando PoW</string>
<string name="cd_pow_enabled">PoW habilitado</string>
<string name="cd_proof_of_work">Prueba de trabajo</string>
<string name="pow_mining_ellipsis">Minando…</string>
<string name="pow_label_format">pow: %1$dbit</string>
<!-- Descripciones de permisos -->
<string name="perm_nearby_devices_desc">Requerido para descubrir usuarios bitchat a través de Bluetooth</string>
@ -394,4 +388,7 @@
<string name="verify_success_body">Verificaste a %1$s</string>
<string name="verify_success_system_message">verificado %1$s</string>
<string name="cd_open_about">Abrir Acerca de</string>
<string name="nearby_notes_reveal">buscar notas dejadas aquí</string>
<string name="nearby_notes_one">1 nota dejada aquí — toca para leer</string>
<string name="nearby_notes_many">%d notas dejadas aquí — toca para leer</string>
</resources>

View File

@ -286,7 +286,6 @@
<string name="select">انتخاب</string>
<string name="type_a_message_placeholder">نوشتن پیام …</string>
<string name="mention">ذکر</string>
<string name="pow_time_estimate">(~%1$s)</string>
<string name="version_prefix">v%1$s</string>
<string name="image_star">image/*</string>
<string name="media_type_image">تصویر</string>
@ -300,11 +299,6 @@
<!-- دسترسی‌پذیری پخش‌کنندهٔ صوت -->
<string name="cd_play_voice">پخش</string>
<string name="cd_pause_voice">توقف</string>
<string name="cd_mining_pow">استخراج PoW</string>
<string name="cd_pow_enabled">PoW فعال است</string>
<string name="cd_proof_of_work">گواه کار</string>
<string name="pow_mining_ellipsis">در حال استخراج …</string>
<string name="pow_label_format">pow: %1$dbit</string>
<!-- توضیحات مجوز -->
<string name="perm_nearby_devices_desc">برای یافتن کاربران bitchat از طریق بلوتوث لازم است</string>
@ -381,4 +375,7 @@
<string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
<string name="cd_open_about">باز کردن درباره</string>
<string name="nearby_notes_reveal">یادداشت‌های باقی‌مانده در اینجا را بررسی کنید</string>
<string name="nearby_notes_one">۱ یادداشت اینجا باقی مانده — برای خواندن ضربه بزنید</string>
<string name="nearby_notes_many">%d یادداشت اینجا باقی مانده — برای خواندن ضربه بزنید</string>
</resources>

View File

@ -283,7 +283,6 @@
<string name="mention_suggestion_at">@%1$s</string>
<string name="mention">banggit</string>
<string name="image_counter">%1$d / %2$d</string>
<string name="pow_time_estimate">(~%1$s)</string>
<string name="at_nickname">@%1$s</string>
<string name="version_prefix">v%1$s</string>
<string name="hash_symbol">#</string>
@ -393,4 +392,7 @@
<string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
<string name="cd_open_about">Buksan ang Tungkol</string>
<string name="nearby_notes_reveal">tingnan kung may mga note na naiwan dito</string>
<string name="nearby_notes_one">1 note ang naiwan dito — i-tap para basahin</string>
<string name="nearby_notes_many">%d note ang naiwan dito — i-tap para basahin</string>
</resources>

View File

@ -284,7 +284,6 @@
<string name="mention_suggestion_at">@%1$s</string>
<string name="mention">mention</string>
<string name="image_counter">%1$d / %2$d</string>
<string name="pow_time_estimate">(~%1$s)</string>
<string name="at_nickname">@%1$s</string>
<string name="version_prefix">v%1$s</string>
<string name="hash_symbol">#</string>
@ -407,4 +406,7 @@
<string name="verify_success_body">Vous avez vérifié %1$s</string>
<string name="verify_success_system_message">vérifié %1$s</string>
<string name="cd_open_about">Ouvrir À propos</string>
<string name="nearby_notes_reveal">vérifier s\'il y a des notes laissées ici</string>
<string name="nearby_notes_one">1 note laissée ici — appuyez pour lire</string>
<string name="nearby_notes_many">%d notes laissées ici — appuyez pour lire</string>
</resources>

View File

@ -54,4 +54,7 @@
<string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
<string name="cd_open_about">פתיחת אודות</string>
<string name="nearby_notes_reveal">בדיקה אם הושארו כאן פתקים</string>
<string name="nearby_notes_one">פתק אחד הושאר כאן — הקש לקריאה</string>
<string name="nearby_notes_many">%d פתקים הושארו כאן — הקש לקריאה</string>
</resources>

View File

@ -286,7 +286,6 @@
<string name="select">चयन करें</string>
<string name="type_a_message_placeholder">संदेश लिखें…</string>
<string name="mention">उल्लेख</string>
<string name="pow_time_estimate">(~%1$s)</string>
<string name="version_prefix">v%1$s</string>
<string name="image_star">image/*</string>
<string name="media_type_image">छवि</string>
@ -300,11 +299,6 @@
<!-- वॉइस प्लेयर एक्सेसिबिलिटी -->
<string name="cd_play_voice">चलाएँ</string>
<string name="cd_pause_voice">रोकें</string>
<string name="cd_mining_pow">PoW माइनिंग</string>
<string name="cd_pow_enabled">PoW सक्षम</string>
<string name="cd_proof_of_work">प्रूफ़ ऑफ़ वर्क</string>
<string name="pow_mining_ellipsis">माइनिंग…</string>
<string name="pow_label_format">pow: %1$dbit</string>
<!-- अनुमति विवरण -->
<string name="perm_nearby_devices_desc">ब्लूटूथ के माध्यम से bitchat उपयोगकर्ताओं की खोज के लिए आवश्यक</string>
@ -394,4 +388,7 @@
<string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
<string name="cd_open_about">परिचय खोलें</string>
<string name="nearby_notes_reveal">देखें कि यहाँ नोट छोड़े गए हैं या नहीं</string>
<string name="nearby_notes_one">यहाँ 1 नोट छोड़ा गया है — पढ़ने के लिए टैप करें</string>
<string name="nearby_notes_many">यहाँ %d नोट छोड़े गए हैं — पढ़ने के लिए टैप करें</string>
</resources>

View File

@ -286,7 +286,6 @@
<string name="select">pilih</string>
<string name="type_a_message_placeholder">Ketik pesan …</string>
<string name="mention">sebut</string>
<string name="pow_time_estimate">(~%1$s)</string>
<string name="version_prefix">v%1$s</string>
<string name="image_star">image/*</string>
<string name="media_type_image">Gambar</string>
@ -300,11 +299,6 @@
<!-- Aksesibilitas pemutar suara -->
<string name="cd_play_voice">Putar</string>
<string name="cd_pause_voice">Jeda</string>
<string name="cd_mining_pow">Mining PoW</string>
<string name="cd_pow_enabled">PoW diaktifkan</string>
<string name="cd_proof_of_work">Proof of Work</string>
<string name="pow_mining_ellipsis">Mining …</string>
<string name="pow_label_format">pow: %1$dbit</string>
<!-- Deskripsi izin -->
<string name="perm_nearby_devices_desc">Diperlukan untuk menemukan pengguna bitchat melalui Bluetooth</string>
@ -394,4 +388,7 @@
<string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
<string name="cd_open_about">Buka Tentang</string>
<string name="nearby_notes_reveal">periksa catatan yang ditinggalkan di sini</string>
<string name="nearby_notes_one">1 catatan ditinggalkan di sini — ketuk untuk membaca</string>
<string name="nearby_notes_many">%d catatan ditinggalkan di sini — ketuk untuk membaca</string>
</resources>

View File

@ -286,7 +286,6 @@
<string name="select">seleziona</string>
<string name="type_a_message_placeholder">scrivi un messaggio…</string>
<string name="mention">menzione</string>
<string name="pow_time_estimate">(~%1$s)</string>
<string name="version_prefix">v%1$s</string>
<string name="image_star">image/*</string>
<string name="media_type_image">Immagine</string>
@ -300,11 +299,6 @@
<!-- Accessibilità lettore vocale -->
<string name="cd_play_voice">Riproduci</string>
<string name="cd_pause_voice">Pausa</string>
<string name="cd_mining_pow">Mining PoW</string>
<string name="cd_pow_enabled">PoW abilitato</string>
<string name="cd_proof_of_work">Proof of Work</string>
<string name="pow_mining_ellipsis">mining…</string>
<string name="pow_label_format">pow: %1$dbit</string>
<!-- Descrizioni permessi -->
<string name="perm_nearby_devices_desc">Necessario per scoprire utenti bitchat tramite Bluetooth</string>
@ -427,4 +421,7 @@
<string name="verify_success_body">Hai verificato %1$s</string>
<string name="verify_success_system_message">verificato %1$s</string>
<string name="cd_open_about">Apri Informazioni</string>
<string name="nearby_notes_reveal">controlla se ci sono note lasciate qui</string>
<string name="nearby_notes_one">1 nota lasciata qui — tocca per leggere</string>
<string name="nearby_notes_many">%d note lasciate qui — tocca per leggere</string>
</resources>

View File

@ -286,7 +286,6 @@
<string name="select">選択</string>
<string name="type_a_message_placeholder">メッセージを入力…</string>
<string name="mention">メンション</string>
<string name="pow_time_estimate">(~%1$s)</string>
<string name="version_prefix">v%1$s</string>
<string name="image_star">image/*</string>
<string name="media_type_image">画像</string>
@ -300,11 +299,6 @@
<!-- ボイスプレイヤー アクセシビリティ -->
<string name="cd_play_voice">再生</string>
<string name="cd_pause_voice">一時停止</string>
<string name="cd_mining_pow">PoW をマイニング中</string>
<string name="cd_pow_enabled">PoW 有効</string>
<string name="cd_proof_of_work">Proof of Work</string>
<string name="pow_mining_ellipsis">マイニング中…</string>
<string name="pow_label_format">pow: %1$dbit</string>
<!-- 権限の説明 -->
<string name="perm_nearby_devices_desc">Bluetooth で bitchat ユーザーを検出するために必要です</string>
@ -394,4 +388,7 @@
<string name="verify_success_body">%1$s を検証しました</string>
<string name="verify_success_system_message">%1$s を検証しました</string>
<string name="cd_open_about">このアプリについてを開く</string>
<string name="nearby_notes_reveal">ここに残されたメモを確認</string>
<string name="nearby_notes_one">ここに1件のメモがあります — タップして読む</string>
<string name="nearby_notes_many">ここに%d件のメモがあります — タップして読む</string>
</resources>

View File

@ -286,7 +286,6 @@
<string name="select">არჩევა</string>
<string name="type_a_message_placeholder">შეიყვანეთ შეტყობინება…</string>
<string name="mention">ხსენება</string>
<string name="pow_time_estimate">(~%1$s)</string>
<string name="version_prefix">v%1$s</string>
<string name="image_star">image/*</string>
<string name="media_type_image">სურათი</string>
@ -300,11 +299,6 @@
<!-- ხმოვანი დამკვრელის ხელმისაწვდომობა -->
<string name="cd_play_voice">დაკვრა</string>
<string name="cd_pause_voice">პაუზა</string>
<string name="cd_mining_pow">PoW მაინინგი</string>
<string name="cd_pow_enabled">PoW ჩართულია</string>
<string name="cd_proof_of_work">Proof of Work</string>
<string name="pow_mining_ellipsis">მაინინგი…</string>
<string name="pow_label_format">pow: %1$dbit</string>
<!-- ნებართვების აღწერები -->
<string name="perm_nearby_devices_desc">საჭიროა bitchat მომხმარებლების Bluetooth-ით აღმოსაჩენად</string>
@ -381,4 +375,7 @@
<string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
<string name="cd_open_about">აპის შესახებ გახსნა</string>
<string name="nearby_notes_reveal">აქ დატოვებული ჩანაწერების შემოწმება</string>
<string name="nearby_notes_one">აქ 1 ჩანაწერია დატოვებული — წასაკითხად შეეხეთ</string>
<string name="nearby_notes_many">აქ %d ჩანაწერია დატოვებული — წასაკითხად შეეხეთ</string>
</resources>

View File

@ -286,7 +286,6 @@
<string name="select">선택</string>
<string name="type_a_message_placeholder">메시지 입력 …</string>
<string name="mention">멘션</string>
<string name="pow_time_estimate">(~%1$s)</string>
<string name="version_prefix">v%1$s</string>
<string name="image_star">image/*</string>
<string name="media_type_image">이미지</string>
@ -300,11 +299,6 @@
<!-- 음성 플레이어 접근성 -->
<string name="cd_play_voice">재생</string>
<string name="cd_pause_voice">일시정지</string>
<string name="cd_mining_pow">PoW 채굴</string>
<string name="cd_pow_enabled">PoW 사용</string>
<string name="cd_proof_of_work">작업증명</string>
<string name="pow_mining_ellipsis">채굴 중 …</string>
<string name="pow_label_format">pow: %1$dbit</string>
<!-- 권한 설명 -->
<string name="perm_nearby_devices_desc">블루투스를 통해 bitchat 사용자를 발견하는 데 필요</string>
@ -394,4 +388,7 @@
<string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
<string name="cd_open_about">정보 열기</string>
<string name="nearby_notes_reveal">여기 남겨진 쪽지 확인</string>
<string name="nearby_notes_one">여기 남겨진 쪽지 1개 — 탭하여 읽기</string>
<string name="nearby_notes_many">여기 남겨진 쪽지 %d개 — 탭하여 읽기</string>
</resources>

View File

@ -292,7 +292,6 @@
<string name="mention_suggestion_at">@%1$s</string>
<string name="mention">hanonona</string>
<string name="image_counter">%1$d / %2$d</string>
<string name="pow_time_estimate">(~%1$s)</string>
<string name="at_nickname">@%1$s</string>
<string name="version_prefix">v%1$s</string>
<string name="hash_symbol">#</string>
@ -326,11 +325,6 @@
<!-- Fahafaha-miditra mpilalao feo -->
<string name="cd_play_voice">Hilalao</string>
<string name="cd_pause_voice">Hijanona</string>
<string name="cd_mining_pow">Mihaingam-poana PoW</string>
<string name="cd_pow_enabled">Alefa ny PoW</string>
<string name="cd_proof_of_work">Porofo Asa</string>
<string name="pow_mining_ellipsis">mihaingana...</string>
<string name="pow_label_format">pow: %1$dbit</string>
<!-- Famaritana alalana -->
<string name="perm_nearby_devices_desc">Ilaina mba hahitana mpampiasa bitchat amin\'ny alalan\'ny Bluetooth</string>
@ -407,4 +401,7 @@
<string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
<string name="cd_open_about">Sokafy ny momba</string>
<string name="nearby_notes_reveal">hizaha raha misy naoty navela teto</string>
<string name="nearby_notes_one">naoty 1 no navela teto — tsindrio raha hamaky</string>
<string name="nearby_notes_many">naoty %d no navela teto — tsindrio raha hamaky</string>
</resources>

View File

@ -41,4 +41,7 @@
<string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
<string name="cd_open_about">Buka Perihal</string>
<string name="nearby_notes_reveal">semak nota yang ditinggalkan di sini</string>
<string name="nearby_notes_one">1 nota ditinggalkan di sini — ketik untuk baca</string>
<string name="nearby_notes_many">%d nota ditinggalkan di sini — ketik untuk baca</string>
</resources>

View File

@ -283,7 +283,6 @@
<string name="mention_suggestion_at">@%1$s</string>
<string name="mention">उल्लेख</string>
<string name="image_counter">%1$d / %2$d</string>
<string name="pow_time_estimate">(~%1$s)</string>
<string name="at_nickname">@%1$s</string>
<string name="version_prefix">v%1$s</string>
<string name="hash_symbol">#</string>
@ -393,4 +392,7 @@
<string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
<string name="cd_open_about">परिचय खोल्नुहोस्</string>
<string name="nearby_notes_reveal">यहाँ छोडिएका नोटहरू छन् कि हेर्नुहोस्</string>
<string name="nearby_notes_one">यहाँ 1 नोट छोडिएको छ — पढ्न ट्याप गर्नुहोस्</string>
<string name="nearby_notes_many">यहाँ %d नोटहरू छोडिएका छन् — पढ्न ट्याप गर्नुहोस्</string>
</resources>

View File

@ -286,7 +286,6 @@
<string name="select">selecteren</string>
<string name="type_a_message_placeholder">typ een bericht…</string>
<string name="mention">vermelden</string>
<string name="pow_time_estimate">(~%1$s)</string>
<string name="version_prefix">v%1$s</string>
<string name="image_star">image/*</string>
<string name="media_type_image">Afbeelding</string>
@ -300,11 +299,6 @@
<!-- Spraakspeler toegankelijkheid -->
<string name="cd_play_voice">Afspelen</string>
<string name="cd_pause_voice">Pauzeren</string>
<string name="cd_mining_pow">Mining-PoW</string>
<string name="cd_pow_enabled">PoW ingeschakeld</string>
<string name="cd_proof_of_work">Proof of Work</string>
<string name="pow_mining_ellipsis">minen…</string>
<string name="pow_label_format">pow: %1$dbit</string>
<!-- Toestemmingsbeschrijvingen -->
<string name="perm_nearby_devices_desc">Vereist om bitchat-gebruikers via Bluetooth te ontdekken</string>
@ -425,4 +419,7 @@
<string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
<string name="cd_open_about">Info openen</string>
<string name="nearby_notes_reveal">kijk of hier notities zijn achtergelaten</string>
<string name="nearby_notes_one">1 notitie hier achtergelaten — tik om te lezen</string>
<string name="nearby_notes_many">%d notities hier achtergelaten — tik om te lezen</string>
</resources>

View File

@ -286,7 +286,6 @@
<string name="select">چُنو</string>
<string name="type_a_message_placeholder">پیغام لکھو …</string>
<string name="mention">ذکر</string>
<string name="pow_time_estimate">(~%1$s)</string>
<string name="version_prefix">v%1$s</string>
<string name="image_star">image/*</string>
<string name="media_type_image">تصویر</string>
@ -300,11 +299,6 @@
<!-- وائس پلیئر رسائی پذیری -->
<string name="cd_play_voice">چلاؤ</string>
<string name="cd_pause_voice">روکੋ</string>
<string name="cd_mining_pow">PoW مائننگ</string>
<string name="cd_pow_enabled">PoW چالو</string>
<string name="cd_proof_of_work">Proof of Work</string>
<string name="pow_mining_ellipsis">مائننگ …</string>
<string name="pow_label_format">pow: %1$dbit</string>
<!-- اجازت دیاں وضاحتاں -->
<string name="perm_nearby_devices_desc">بلوٹوتھ راہین bitchat یوزر لبھن لئی ضروری</string>
@ -381,4 +375,7 @@
<string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
<string name="cd_open_about">ایپ بارے کھولو</string>
<string name="nearby_notes_reveal">ایتھے چھڈے نوٹس ویکھو</string>
<string name="nearby_notes_one">ایتھے 1 نوٹ چھڈیا گیا — پڑھݨ لئی ٹیپ کرو</string>
<string name="nearby_notes_many">ایتھے %d نوٹس چھڈے گئے — پڑھݨ لئی ٹیپ کرو</string>
</resources>

View File

@ -54,4 +54,7 @@
<string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
<string name="cd_open_about">Otwórz informacje</string>
<string name="nearby_notes_reveal">sprawdź, czy zostawiono tutaj notatki</string>
<string name="nearby_notes_one">1 notatka zostawiona tutaj — stuknij, aby przeczytać</string>
<string name="nearby_notes_many">%d notatek zostawionych tutaj — stuknij, aby przeczytać</string>
</resources>

View File

@ -286,7 +286,6 @@
<string name="select">selecionar</string>
<string name="type_a_message_placeholder">Digite uma mensagem…</string>
<string name="mention">mencionar</string>
<string name="pow_time_estimate">(~%1$s)</string>
<string name="version_prefix">v%1$s</string>
<string name="image_star">image/*</string>
<string name="media_type_image">Imagem</string>
@ -300,11 +299,6 @@
<!-- Acessibilidade do player de voz -->
<string name="cd_play_voice">Reproduzir</string>
<string name="cd_pause_voice">Pausar</string>
<string name="cd_mining_pow">Minerando PoW</string>
<string name="cd_pow_enabled">PoW ativado</string>
<string name="cd_proof_of_work">Prova de Trabalho</string>
<string name="pow_mining_ellipsis">Minerando…</string>
<string name="pow_label_format">pow: %1$dbit</string>
<!-- Descrições de permissões -->
<string name="perm_nearby_devices_desc">Necessário para descobrir usuários do bitchat via Bluetooth</string>
@ -393,4 +387,7 @@
<string name="verify_success_title">Verificado</string>
<string name="verify_success_body">Você verificou %1$s</string>
<string name="verify_success_system_message">verificou %1$s</string>
<string name="nearby_notes_reveal">ver se há notas deixadas aqui</string>
<string name="nearby_notes_one">1 nota deixada aqui — toque para ler</string>
<string name="nearby_notes_many">%d notas deixadas aqui — toque para ler</string>
</resources>

View File

@ -286,7 +286,6 @@
<string name="select">selecionar</string>
<string name="type_a_message_placeholder">Digite uma mensagem…</string>
<string name="mention">mencionar</string>
<string name="pow_time_estimate">(~%1$s)</string>
<string name="version_prefix">v%1$s</string>
<string name="image_star">image/*</string>
<string name="media_type_image">Imagem</string>
@ -300,11 +299,6 @@
<!-- Acessibilidade do reprodutor de voz -->
<string name="cd_play_voice">Reproduzir</string>
<string name="cd_pause_voice">Pausar</string>
<string name="cd_mining_pow">A minerar PoW</string>
<string name="cd_pow_enabled">PoW ativado</string>
<string name="cd_proof_of_work">Prova de Trabalho</string>
<string name="pow_mining_ellipsis">A minerar…</string>
<string name="pow_label_format">pow: %1$dbit</string>
<!-- Descrições de permissões -->
<string name="perm_nearby_devices_desc">Necessário para descobrir utilizadores bitchat via Bluetooth</string>
@ -394,4 +388,7 @@
<string name="verify_success_body">Você verificou %1$s</string>
<string name="verify_success_system_message">verificou %1$s</string>
<string name="cd_open_about">Abrir Sobre</string>
<string name="nearby_notes_reveal">ver se há notas deixadas aqui</string>
<string name="nearby_notes_one">1 nota deixada aqui — toque para ler</string>
<string name="nearby_notes_many">%d notas deixadas aqui — toque para ler</string>
</resources>

View File

@ -267,7 +267,6 @@
<string name="mention_suggestion_at">@%1$s</string>
<string name="mention">упоминание</string>
<string name="image_counter">%1$d / %2$d</string>
<string name="pow_time_estimate">(~%1$s)</string>
<string name="at_nickname">@%1$s</string>
<string name="version_prefix">v%1$s</string>
<string name="hash_symbol">#</string>
@ -383,4 +382,7 @@
<string name="verify_success_body">Вы проверили %1$s</string>
<string name="verify_success_system_message">проверен %1$s</string>
<string name="cd_open_about">Открыть раздел «О приложении»</string>
<string name="nearby_notes_reveal">проверить, есть ли здесь заметки</string>
<string name="nearby_notes_one">здесь оставлена 1 заметка — нажмите, чтобы прочитать</string>
<string name="nearby_notes_many">здесь оставлено заметок: %d — нажмите, чтобы прочитать</string>
</resources>

View File

@ -267,7 +267,6 @@
<string name="mention_suggestion_at">@%1$s</string>
<string name="mention">nämn</string>
<string name="image_counter">%1$d / %2$d</string>
<string name="pow_time_estimate">(~%1$s)</string>
<string name="at_nickname">@%1$s</string>
<string name="version_prefix">v%1$s</string>
<string name="hash_symbol">#</string>
@ -381,4 +380,7 @@
<string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
<string name="cd_open_about">Öppna Om</string>
<string name="nearby_notes_reveal">kolla om anteckningar lämnats här</string>
<string name="nearby_notes_one">1 anteckning lämnad här — tryck för att läsa</string>
<string name="nearby_notes_many">%d anteckningar lämnade här — tryck för att läsa</string>
</resources>

View File

@ -41,4 +41,7 @@
<string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
<string name="cd_open_about">அறிமுகத்தைத் திற</string>
<string name="nearby_notes_reveal">இங்கே விடப்பட்ட குறிப்புகள் உள்ளதா எனப் பார்க்கவும்</string>
<string name="nearby_notes_one">இங்கே 1 குறிப்பு விடப்பட்டுள்ளது — படிக்க தட்டவும்</string>
<string name="nearby_notes_many">இங்கே %d குறிப்புகள் விடப்பட்டுள்ளன — படிக்க தட்டவும்</string>
</resources>

View File

@ -286,7 +286,6 @@
<string name="select">เลือก</string>
<string name="type_a_message_placeholder">พิมพ์ข้อความ…</string>
<string name="mention">กล่าวถึง</string>
<string name="pow_time_estimate">(~%1$s)</string>
<string name="version_prefix">v%1$s</string>
<string name="image_star">image/*</string>
<string name="media_type_image">รูปภาพ</string>
@ -300,11 +299,6 @@
<!-- การช่วยสำหรับการเข้าถึงของเครื่องเล่นเสียง -->
<string name="cd_play_voice">เล่น</string>
<string name="cd_pause_voice">หยุดชั่วคราว</string>
<string name="cd_mining_pow">กำลังขุด PoW</string>
<string name="cd_pow_enabled">เปิดใช้ PoW</string>
<string name="cd_proof_of_work">Proof of Work</string>
<string name="pow_mining_ellipsis">กำลังขุด…</string>
<string name="pow_label_format">pow: %1$dbit</string>
<!-- คำอธิบายสิทธิ์ -->
<string name="perm_nearby_devices_desc">จำเป็นสำหรับการค้นหาผู้ใช้ bitchat ผ่านบลูทูธ</string>
@ -381,4 +375,7 @@
<string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
<string name="cd_open_about">เปิดเกี่ยวกับ</string>
<string name="nearby_notes_reveal">ดูว่ามีโน้ตทิ้งไว้ที่นี่หรือไม่</string>
<string name="nearby_notes_one">มี 1 โน้ตทิ้งไว้ที่นี่ — แตะเพื่ออ่าน</string>
<string name="nearby_notes_many">มี %d โน้ตทิ้งไว้ที่นี่ — แตะเพื่ออ่าน</string>
</resources>

View File

@ -267,7 +267,6 @@
<string name="mention_suggestion_at">@%1$s</string>
<string name="mention">bahset</string>
<string name="image_counter">%1$d / %2$d</string>
<string name="pow_time_estimate">(~%1$s)</string>
<string name="at_nickname">@%1$s</string>
<string name="version_prefix">v%1$s</string>
<string name="hash_symbol">#</string>
@ -381,4 +380,7 @@
<string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
<string name="cd_open_about">Hakkındayı</string>
<string name="nearby_notes_reveal">buraya bırakılan notlara bak</string>
<string name="nearby_notes_one">buraya 1 not bırakıldı — okumak için dokun</string>
<string name="nearby_notes_many">buraya %d not bırakıldı — okumak için dokun</string>
</resources>

View File

@ -41,4 +41,7 @@
<string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
<string name="cd_open_about">Відкрити розділ «Про застосунок»</string>
<string name="nearby_notes_reveal">перевірити, чи залишено тут нотатки</string>
<string name="nearby_notes_one">тут залишено 1 нотатку — торкніться, щоб прочитати</string>
<string name="nearby_notes_many">тут залишено %d нотаток — торкніться, щоб прочитати</string>
</resources>

View File

@ -286,7 +286,6 @@
<string name="select">منتخب کریں</string>
<string name="type_a_message_placeholder">پیغام لکھیں…</string>
<string name="mention">ذکر</string>
<string name="pow_time_estimate">(~%1$s)</string>
<string name="version_prefix">v%1$s</string>
<string name="image_star">image/*</string>
<string name="media_type_image">تصویر</string>
@ -300,11 +299,6 @@
<!-- وائس پلیئر رسائی -->
<string name="cd_play_voice">چلائیں</string>
<string name="cd_pause_voice">روکیں</string>
<string name="cd_mining_pow">PoW مائننگ</string>
<string name="cd_pow_enabled">PoW فعال</string>
<string name="cd_proof_of_work">پروف آف ورک</string>
<string name="pow_mining_ellipsis">مائننگ…</string>
<string name="pow_label_format">pow: %1$dbit</string>
<!-- اجازات کی تفصیل -->
<string name="perm_nearby_devices_desc">بلوٹوتھ کے ذریعے bitchat صارفین کی دریافت کے لیے ضروری</string>
@ -394,4 +388,7 @@
<string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
<string name="cd_open_about">تعارف کھولیں</string>
<string name="nearby_notes_reveal">دیکھیں کہ یہاں نوٹ چھوڑے گئے ہیں یا نہیں</string>
<string name="nearby_notes_one">یہاں 1 نوٹ چھوڑا گیا ہے — پڑھنے کے لیے تھپتھپائیں</string>
<string name="nearby_notes_many">یہاں %d نوٹ چھوڑے گئے ہیں — پڑھنے کے لیے تھپتھپائیں</string>
</resources>

View File

@ -286,7 +286,6 @@
<string name="select">chọn</string>
<string name="type_a_message_placeholder">Nhập tin nhắn …</string>
<string name="mention">nhắc đến</string>
<string name="pow_time_estimate">(~%1$s)</string>
<string name="version_prefix">v%1$s</string>
<string name="image_star">image/*</string>
<string name="media_type_image">Hình ảnh</string>
@ -300,11 +299,6 @@
<!-- Khả năng truy cập trình phát thoại -->
<string name="cd_play_voice">Phát</string>
<string name="cd_pause_voice">Tạm dừng</string>
<string name="cd_mining_pow">Đang mining PoW</string>
<string name="cd_pow_enabled">PoW đã bật</string>
<string name="cd_proof_of_work">Proof of Work</string>
<string name="pow_mining_ellipsis">Đang mining …</string>
<string name="pow_label_format">pow: %1$dbit</string>
<!-- Mô tả quyền -->
<string name="perm_nearby_devices_desc">Cần thiết để khám phá người dùng bitchat qua Bluetooth</string>
@ -381,4 +375,7 @@
<string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
<string name="cd_open_about">Mở phần Giới thiệu</string>
<string name="nearby_notes_reveal">kiểm tra ghi chú để lại ở đây</string>
<string name="nearby_notes_one">có 1 ghi chú để lại ở đây — chạm để đọc</string>
<string name="nearby_notes_many">có %d ghi chú để lại ở đây — chạm để đọc</string>
</resources>

View File

@ -53,5 +53,7 @@
<string name="verify_success_title">已验证</string>
<string name="verify_success_body">你已验证 %1$s</string>
<string name="verify_success_system_message">已验证 %1$s</string>
<string name="nearby_notes_reveal">查看这里留下的留言</string>
<string name="nearby_notes_one">这里留有 1 条留言 — 点按阅读</string>
<string name="nearby_notes_many">这里留有 %d 条留言 — 点按阅读</string>
</resources>

View File

@ -53,5 +53,7 @@
<string name="verify_success_title">已验证</string>
<string name="verify_success_body">你已验证 %1$s</string>
<string name="verify_success_system_message">已验证 %1$s</string>
<string name="nearby_notes_reveal">查看這裡留下的留言</string>
<string name="nearby_notes_one">這裡留有 1 則留言 — 點按閱讀</string>
<string name="nearby_notes_many">這裡留有 %d 則留言 — 點按閱讀</string>
</resources>

View File

@ -283,7 +283,6 @@
<string name="mention_suggestion_at">@%1$s</string>
<string name="mention">提及</string>
<string name="image_counter">%1$d / %2$d</string>
<string name="pow_time_estimate">(~%1$s)</string>
<string name="at_nickname">@%1$s</string>
<string name="version_prefix">v%1$s</string>
<string name="hash_symbol">#</string>
@ -406,4 +405,7 @@
<string name="verify_success_body">你已验证 %1$s</string>
<string name="verify_success_system_message">已验证 %1$s</string>
<string name="cd_open_about">打开“关于”</string>
<string name="nearby_notes_reveal">查看这里留下的留言</string>
<string name="nearby_notes_one">这里留有 1 条留言 — 点按阅读</string>
<string name="nearby_notes_many">这里留有 %d 条留言 — 点按阅读</string>
</resources>

View File

@ -177,6 +177,71 @@
<string name="cd_privacy_protected">Privacy Protected</string>
<string name="cancel_lower">Cancel</string>
<!-- APK Sharing -->
<string name="share_bitchat_title">Share BitChat</string>
<string name="share_bitchat_subtitle">Share installation file for offline distribution</string>
<string name="share_apk_title">Share BitChat App</string>
<string name="share_apk_explanation">This will share the BitChat installation file(s) so others can install the app without internet access. Perfect for mesh network expansion!</string>
<string name="share_apk_receiver_instructions">The receiver will need to:\n• Enable \"Install from unknown sources\" in Android settings\n• Uninstall BitChat first if already installed (signatures differ)</string>
<string name="share_apk_confirm">Share App</string>
<string name="share_apk_chooser_title">Share BitChat via…</string>
<string name="share_apk_error">Failed to prepare app for sharing. Please try again.</string>
<!-- Universal APK Preparation -->
<string name="prepare_apk_title">Prepare App for Sharing</string>
<string name="prepare_apk_ready_title" translatable="false">App Ready for Offline Sharing</string>
<string name="prepare_apk_subtitle">Download universal APK for offline sharing</string>
<string name="prepare_apk_status_not_downloaded">Not ready • Tap to download</string>
<string name="prepare_apk_status_ready">Ready to share</string>
<string name="prepare_apk_source_installed" translatable="false">Sharing source: this installed APK</string>
<string name="prepare_apk_source_github" translatable="false">Sharing source: verified GitHub universal APK</string>
<string name="prepare_apk_status_downloading">Downloading… %1$d%%</string>
<string name="prepare_apk_status_update_available">Update available</string>
<string name="prepare_apk_button_prepare">Prepare</string>
<string name="prepare_apk_button_update">Update</string>
<string name="prepare_apk_button_delete">Delete</string>
<string name="prepare_apk_info">Version %1$s • %2$d MB</string>
<string name="prepare_apk_dialog_title">Download Universal APK?</string>
<string name="prepare_apk_dialog_message">This will download the universal APK (~%1$d MB) from GitHub releases. You only need to do this once.</string>
<string name="prepare_apk_dialog_message_unknown_size" translatable="false">The release size is temporarily unavailable. BitChat will retry the GitHub request before downloading.</string>
<string name="prepare_apk_dialog_confirm">Download</string>
<string name="prepare_apk_downloading_title">Downloading Universal APK</string>
<string name="prepare_apk_downloading_message">Downloading %1$d MB…</string>
<string name="prepare_apk_verifying">Verifying checksum…</string>
<string name="prepare_apk_success">Universal APK ready!</string>
<string name="prepare_apk_error_network">Network error. Check your connection.</string>
<string name="prepare_apk_error_checksum">Checksum verification failed. Please try again.</string>
<string name="prepare_apk_error_storage">Not enough storage space.</string>
<string name="prepare_apk_error_github">Failed to fetch release info from GitHub.</string>
<string name="prepare_apk_delete_confirm">Delete cached APK?</string>
<string name="prepare_apk_delete_message">This will free up ~%1$d MB of storage.</string>
<string name="prepare_apk_update_dialog_title">Update Available</string>
<string name="prepare_apk_update_dialog_message">A newer version (%1$s) is available. Current: %2$s</string>
<string name="prepare_apk_required">Please prepare the app for sharing first.</string>
<string name="prepare_apk_download_interrupted">Download interrupted</string>
<string name="prepare_apk_download_cancelled">Download cancelled</string>
<string name="apk_download_notification_title">Downloading universal APK</string>
<string name="apk_download_channel_name">APK downloads</string>
<!-- Hotspot Sharing -->
<string name="hotspot_share_via">Share via Hotspot</string>
<string name="hotspot_share_via_subtitle">Create Wi-Fi hotspot to share offline</string>
<string name="hotspot_share_other">Share via Quick Share</string>
<string name="hotspot_share_other_subtitle">Use standard Android sharing</string>
<!-- APK Installation -->
<string name="install_bitchat_title">Install Received APK</string>
<string name="install_bitchat_subtitle">Install BitChat from received files</string>
<string name="install_apk_dialog_title">Install BitChat Update</string>
<string name="install_apk_dialog_message">Install BitChat from the received APK file(s)? This allows offline app distribution in mesh networks.</string>
<string name="install_apk_permission_title">Permission Required</string>
<string name="install_apk_permission_message">BitChat needs permission to install packages for self-distribution. Please enable \"Install unknown apps\" in the next screen.</string>
<string name="install_apk_confirm">Install</string>
<string name="install_apk_grant_permission">Grant Permission</string>
<string name="install_apk_select_files">Select APK Files</string>
<string name="install_apk_error">Failed to install APK. Please try again.</string>
<string name="install_apk_no_files">No APK files selected.</string>
<!-- Generic content descriptions -->
<string name="cd_warning">Warning</string>
<string name="cd_location_services">Location Services</string>
@ -207,11 +272,6 @@
<string name="cd_link">Link</string>
<string name="cd_record_voice">Record voice note</string>
<string name="cd_pick_media">Pick media</string>
<string name="cd_mining_pow">Mining PoW</string>
<string name="cd_pow_enabled">PoW Enabled</string>
<string name="cd_proof_of_work">Proof of Work</string>
<string name="pow_mining_ellipsis">Mining…</string>
<string name="pow_label_format">PoW: %1$dbit</string>
<string name="cd_offline_mesh_chat">Offline Mesh Chat</string>
<string name="cd_online_geohash_channels">Online Geohash Channels</string>
<string name="cd_end_to_end_encryption">End-to-End Encryption</string>
@ -279,6 +339,10 @@
<string name="location_level_region">Region</string>
<!-- Location notes sheet -->
<string name="nearby_notes_reveal">check for notes left here</string>
<string name="nearby_notes_one">1 note left here — tap to read</string>
<!-- Explicit one/many copy mirrors iOS across locales without CLDR quantity gaps. -->
<string name="nearby_notes_many" tools:ignore="PluralsCandidate">%d notes left here — tap to read</string>
<plurals name="location_notes_title">
<item quantity="one">#%1$s ± 1 • %2$d note</item>
<item quantity="other">#%1$s ± 1 • %2$d notes</item>
@ -426,7 +490,6 @@
<string name="mention_suggestion_at">@%1$s</string>
<string name="mention">Mention</string>
<string name="image_counter">%1$d / %2$d</string>
<string name="pow_time_estimate">(~%1$s)</string>
<string name="at_nickname">@%1$s</string>
<string name="version_prefix">v%1$s</string>
<string name="hash_symbol">#</string>
@ -489,6 +552,8 @@
<string name="join">Join</string>
<string name="cancel">Cancel</string>
<string name="tor_not_available_in_this_build">Tor not available in this build</string>
<string name="checking">Checking...</string>
<string name="apk_not_ready_please_prepare_it_first">APK not ready. Please prepare it first.</string>
<!-- Plurals -->
<plurals name="notification_and_more">

View File

@ -6,4 +6,8 @@
<files-path
name="files"
path="." />
<!-- For APK sharing - cache subdirectory -->
<cache-path
name="apk_share"
path="apk_share/" />
</paths>

View File

@ -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<String>()
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<String>()
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,
),
)
}
}

View File

@ -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
}
}

View File

@ -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"))
}
}

View File

@ -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

View File

@ -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" }