mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-15 06:56:30 +00:00
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:
commit
0cb28a4f3a
3
.gitignore
vendored
3
.gitignore
vendored
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -22,13 +22,18 @@
|
||||
<!-- Notification permissions -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<!-- Wi‑Fi / Wi‑Fi Aware permissions -->
|
||||
<!-- Wi‑Fi / Wi‑Fi 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 Wi‑Fi operations (including Aware) -->
|
||||
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" />
|
||||
<!-- Android 13+ runtime permission for Wi‑Fi operations (including Aware and Wi‑Fi P2P) -->
|
||||
<uses-permission
|
||||
android:name="android.permission.NEARBY_WIFI_DEVICES"
|
||||
android:usesPermissionFlags="neverForLocation" />
|
||||
<!-- Android 17+ gates local network access; Wi‑Fi 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>
|
||||
|
||||
323
app/src/main/java/com/bitchat/android/hotspot/ApkWebServer.kt
Normal file
323
app/src/main/java/com/bitchat/android/hotspot/ApkWebServer.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
684
app/src/main/java/com/bitchat/android/hotspot/HotspotActivity.kt
Normal file
684
app/src/main/java/com/bitchat/android/hotspot/HotspotActivity.kt
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
503
app/src/main/java/com/bitchat/android/hotspot/HotspotManager.kt
Normal file
503
app/src/main/java/com/bitchat/android/hotspot/HotspotManager.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
126
app/src/main/java/com/bitchat/android/hotspot/QrCodeGenerator.kt
Normal file
126
app/src/main/java/com/bitchat/android/hotspot/QrCodeGenerator.kt
Normal 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(":", "\\:")
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
341
app/src/main/java/com/bitchat/android/ui/ApkDownloadViewModel.kt
Normal file
341
app/src/main/java/com/bitchat/android/ui/ApkDownloadViewModel.kt
Normal 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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)) {
|
||||
|
||||
161
app/src/main/java/com/bitchat/android/util/ApkDownloadWorker.kt
Normal file
161
app/src/main/java/com/bitchat/android/util/ApkDownloadWorker.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
36
app/src/main/java/com/bitchat/android/util/ApkDownloader.kt
Normal file
36
app/src/main/java/com/bitchat/android/util/ApkDownloader.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
@ -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?
|
||||
)
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">Mining‑PoW</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 bitchat‑Benutzer ü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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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ında’yı aç</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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -6,4 +6,8 @@
|
||||
<files-path
|
||||
name="files"
|
||||
path="." />
|
||||
<!-- For APK sharing - cache subdirectory -->
|
||||
<cache-path
|
||||
name="apk_share"
|
||||
path="apk_share/" />
|
||||
</paths>
|
||||
|
||||
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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"))
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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" }
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user