mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-15 06:56:30 +00:00
feat: Add offline APK sharing via Wi-Fi hotspot
This commit introduces a comprehensive feature for sharing the BitChat application offline using a self-hosted Wi-Fi Direct hotspot. This enables mesh network expansion by allowing users to distribute the app without requiring an internet connection.
Key components:
- **`HotspotManager`**: A new class that manages the creation and lifecycle of a Wi-Fi P2P (Wi-Fi Direct) group. It handles generating secure credentials (SSID/password), acquiring WakeLocks, and monitoring connected peers. It supports custom credentials on Android 10+ and falls back to system-generated ones on older versions.
- **`ApkWebServer`**: A lightweight HTTP server based on `NanoHTTPD` that serves the APK file and a user-friendly HTML landing page to connected devices.
- **`ApkSharingUtils`**: A utility to detect whether the app is installed as a single or split APK, collect the necessary files, and copy them to a cache directory for sharing.
- **`ApkInstaller`**: A utility using the `PackageInstaller` API to handle the installation of single or split APKs received from another user.
- **`HotspotActivity`**: A new Compose-based UI that guides the user through starting the hotspot, displays connection details (Wi-Fi credentials, QR codes for Wi-Fi and the download URL), and shows the number of connected peers. It also handles the necessary runtime permissions (`NEARBY_WIFI_DEVICES` or `ACCESS_FINE_LOCATION`).
- **Configuration**:
- Adds necessary Wi-Fi and P2P permissions to `AndroidManifest.xml`.
- Defines a `FileProvider` path for APK sharing in `file_paths.xml`.
- Adds numerous string resources for the new UI.
This commit is contained in:
parent
b046bc22f3
commit
c5964184e9
@ -22,11 +22,14 @@
|
||||
<!-- 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) -->
|
||||
<!-- Android 13+ runtime permission for Wi‑Fi operations (including Aware and Wi‑Fi P2P) -->
|
||||
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" />
|
||||
<!-- 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 -->
|
||||
@ -140,5 +143,13 @@
|
||||
<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" />
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
317
app/src/main/java/com/bitchat/android/hotspot/ApkWebServer.kt
Normal file
317
app/src/main/java/com/bitchat/android/hotspot/ApkWebServer.kt
Normal file
@ -0,0 +1,317 @@
|
||||
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 {
|
||||
val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0)
|
||||
packageInfo.versionName ?: "Unknown"
|
||||
} catch (e: Exception) {
|
||||
"Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
override fun serve(session: IHTTPSession): Response {
|
||||
val uri = session.uri ?: "/"
|
||||
|
||||
Log.d(TAG, "Request: ${session.method} $uri from ${session.remoteIpAddress}")
|
||||
|
||||
return when {
|
||||
uri.endsWith(".apk") || 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 {
|
||||
val html = generateLandingPageHtml()
|
||||
return newFixedLengthResponse(
|
||||
Response.Status.OK,
|
||||
"text/html",
|
||||
html
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
683
app/src/main/java/com/bitchat/android/hotspot/HotspotActivity.kt
Normal file
683
app/src/main/java/com/bitchat/android/hotspot/HotspotActivity.kt
Normal file
@ -0,0 +1,683 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
viewModel.stopHotspot()
|
||||
}
|
||||
}
|
||||
|
||||
@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) }
|
||||
|
||||
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
|
||||
permissionState.launchPermissionRequest()
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(56.dp),
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = if (permissionState != null && !permissionState.status.isGranted) {
|
||||
"Grant Permission"
|
||||
} else {
|
||||
"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")
|
||||
}
|
||||
}
|
||||
}
|
||||
435
app/src/main/java/com/bitchat/android/hotspot/HotspotManager.kt
Normal file
435
app/src/main/java/com/bitchat/android/hotspot/HotspotManager.kt
Normal file
@ -0,0 +1,435 @@
|
||||
package com.bitchat.android.hotspot
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
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 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
|
||||
|
||||
// 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
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
this.callback = callback
|
||||
isStarting = true
|
||||
|
||||
Log.d(TAG, "Starting Wi-Fi P2P hotspot")
|
||||
|
||||
// Register broadcast receiver
|
||||
val intentFilter = IntentFilter().apply {
|
||||
addAction(WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION)
|
||||
addAction(WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION)
|
||||
}
|
||||
context.registerReceiver(broadcastReceiver, intentFilter)
|
||||
|
||||
// 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
|
||||
try {
|
||||
context.unregisterReceiver(broadcastReceiver)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Error unregistering receiver", e)
|
||||
}
|
||||
|
||||
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")
|
||||
isStarting = false
|
||||
callback?.onError("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.
|
||||
*/
|
||||
private fun createGroup(attempt: Int) {
|
||||
val ch = channel ?: return
|
||||
|
||||
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, object : ActionListener {
|
||||
override fun onSuccess() {
|
||||
Log.d(TAG, "P2P group created successfully")
|
||||
isStarting = false
|
||||
// Don't call onHotspotStarted() yet - wait for group info
|
||||
startGroupInfoPolling()
|
||||
}
|
||||
|
||||
override fun onFailure(reason: Int) {
|
||||
handleGroupCreationFailure(reason, attempt)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// Android 9 and below: System-generated SSID/password
|
||||
wifiP2pManager?.createGroup(ch, object : ActionListener {
|
||||
override fun onSuccess() {
|
||||
Log.d(TAG, "P2P group created successfully")
|
||||
isStarting = false
|
||||
// Don't call onHotspotStarted() yet - wait for group info
|
||||
startGroupInfoPolling()
|
||||
}
|
||||
|
||||
override fun onFailure(reason: Int) {
|
||||
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 {
|
||||
isStarting = false
|
||||
callback?.onError("Failed to create hotspot: $reasonStr")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start polling for group info to track connected clients.
|
||||
*/
|
||||
private fun startGroupInfoPolling() {
|
||||
requestGroupInfo()
|
||||
|
||||
handler.postDelayed(object : Runnable {
|
||||
override fun run() {
|
||||
if (channel != null && currentGroup != null) {
|
||||
requestGroupInfo()
|
||||
handler.postDelayed(this, GROUP_INFO_POLL_INTERVAL_MILLIS)
|
||||
}
|
||||
}
|
||||
}, GROUP_INFO_POLL_INTERVAL_MILLIS)
|
||||
}
|
||||
|
||||
/**
|
||||
* Request current group information.
|
||||
*/
|
||||
private fun requestGroupInfo() {
|
||||
val ch = channel ?: return
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.FULL_WAKE_LOCK,
|
||||
"BitChat:HotspotWakeLock"
|
||||
)
|
||||
wakeLock?.acquire(10 * 60 * 1000L) // 10 minutes max
|
||||
|
||||
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,146 @@
|
||||
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() {
|
||||
Log.d(TAG, "Hotspot started successfully")
|
||||
|
||||
// Get connection info
|
||||
val info = manager.getConnectionInfo()
|
||||
if (info == null) {
|
||||
_state.value = HotspotState.Error("Failed to get hotspot connection info")
|
||||
return
|
||||
}
|
||||
|
||||
// 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?) {
|
||||
// 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) {
|
||||
Log.e(TAG, "Hotspot error: $message")
|
||||
_state.value = HotspotState.Error(message)
|
||||
}
|
||||
})
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error starting hotspot", e)
|
||||
_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()
|
||||
}
|
||||
}
|
||||
170
app/src/main/java/com/bitchat/android/util/ApkInstaller.kt
Normal file
170
app/src/main/java/com/bitchat/android/util/ApkInstaller.kt
Normal file
@ -0,0 +1,170 @@
|
||||
package com.bitchat.android.util
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageInstaller
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.core.content.FileProvider
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* Utility for installing APK files (single or split) using PackageInstaller API.
|
||||
* This enables BitChat to be self-distributing in offline mesh network scenarios.
|
||||
*/
|
||||
object ApkInstaller {
|
||||
private const val TAG = "ApkInstaller"
|
||||
const val ACTION_INSTALL_COMPLETE = "com.bitchat.android.INSTALL_COMPLETE"
|
||||
|
||||
/**
|
||||
* Install APK files using PackageInstaller API.
|
||||
* Handles both single APK and split APKs (from AAB).
|
||||
*
|
||||
* @param context Application context
|
||||
* @param apkFiles List of APK files to install (can be single file or multiple splits)
|
||||
* @return true if installation session was created successfully, false otherwise
|
||||
*/
|
||||
fun installApks(context: Context, apkFiles: List<File>): Boolean {
|
||||
return try {
|
||||
Log.d(TAG, "Starting installation of ${apkFiles.size} APK file(s)")
|
||||
|
||||
val packageInstaller = context.packageManager.packageInstaller
|
||||
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
|
||||
|
||||
// Create installation session
|
||||
val sessionId = packageInstaller.createSession(params)
|
||||
val session = packageInstaller.openSession(sessionId)
|
||||
|
||||
try {
|
||||
// Write each APK file to the session
|
||||
apkFiles.forEachIndexed { index, apkFile ->
|
||||
if (!apkFile.exists()) {
|
||||
Log.e(TAG, "APK file does not exist: ${apkFile.absolutePath}")
|
||||
session.abandon()
|
||||
return false
|
||||
}
|
||||
|
||||
val name = if (apkFiles.size == 1) {
|
||||
"base.apk"
|
||||
} else {
|
||||
"split_$index.apk"
|
||||
}
|
||||
|
||||
session.openWrite(name, 0, apkFile.length()).use { output ->
|
||||
apkFile.inputStream().use { input ->
|
||||
input.copyTo(output)
|
||||
session.fsync(output)
|
||||
}
|
||||
}
|
||||
Log.d(TAG, "Wrote ${apkFile.name} to session (${apkFile.length()} bytes)")
|
||||
}
|
||||
|
||||
// Create pending intent for installation result
|
||||
val intent = Intent(ACTION_INSTALL_COMPLETE).apply {
|
||||
setPackage(context.packageName)
|
||||
}
|
||||
|
||||
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
} else {
|
||||
PendingIntent.FLAG_UPDATE_CURRENT
|
||||
}
|
||||
|
||||
val pendingIntent = PendingIntent.getBroadcast(
|
||||
context,
|
||||
sessionId,
|
||||
intent,
|
||||
flags
|
||||
)
|
||||
|
||||
// Commit the session - this will show the system install dialog
|
||||
session.commit(pendingIntent.intentSender)
|
||||
Log.d(TAG, "Installation session committed (ID: $sessionId)")
|
||||
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error writing APKs to session", e)
|
||||
session.abandon()
|
||||
false
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error creating installation session", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a single APK file.
|
||||
*
|
||||
* @param context Application context
|
||||
* @param apkFile APK file to install
|
||||
* @return true if installation session was created successfully, false otherwise
|
||||
*/
|
||||
fun installApk(context: Context, apkFile: File): Boolean {
|
||||
return installApks(context, listOf(apkFile))
|
||||
}
|
||||
|
||||
/**
|
||||
* Install APK from URI (e.g., content:// URI from FileProvider).
|
||||
* Copies the URI to a temporary file first, then installs.
|
||||
*
|
||||
* @param context Application context
|
||||
* @param apkUri URI pointing to the APK file
|
||||
* @return true if installation started successfully, false otherwise
|
||||
*/
|
||||
fun installApkFromUri(context: Context, apkUri: Uri): Boolean {
|
||||
return try {
|
||||
// Copy URI to temporary file
|
||||
val tempFile = File(context.cacheDir, "temp_install.apk")
|
||||
context.contentResolver.openInputStream(apkUri)?.use { input ->
|
||||
tempFile.outputStream().use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
|
||||
if (!tempFile.exists() || tempFile.length() == 0L) {
|
||||
Log.e(TAG, "Failed to copy APK from URI to temp file")
|
||||
return false
|
||||
}
|
||||
|
||||
Log.d(TAG, "Copied APK from URI to temp file (${tempFile.length()} bytes)")
|
||||
installApk(context, tempFile)
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, "Error installing APK from URI", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the app has permission to install packages.
|
||||
* On Android 8.0+, user must grant "Install unknown apps" permission.
|
||||
*
|
||||
* @param context Application context
|
||||
* @return true if permission is granted, false otherwise
|
||||
*/
|
||||
fun canRequestPackageInstalls(context: Context): Boolean {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.packageManager.canRequestPackageInstalls()
|
||||
} else {
|
||||
true // No permission needed on older Android versions
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open system settings to allow installing from this app.
|
||||
*
|
||||
* @param context Application context
|
||||
*/
|
||||
fun requestInstallPermission(context: Context) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val intent = Intent(android.provider.Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
|
||||
data = Uri.parse("package:${context.packageName}")
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
131
app/src/main/java/com/bitchat/android/util/ApkSharingUtils.kt
Normal file
131
app/src/main/java/com/bitchat/android/util/ApkSharingUtils.kt
Normal file
@ -0,0 +1,131 @@
|
||||
package com.bitchat.android.util
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Utility object for sharing the installed APK with other users.
|
||||
* Supports both single APK installations (direct downloads) and split APK installations (Play Store AAB).
|
||||
*/
|
||||
object ApkSharingUtils {
|
||||
private const val TAG = "ApkSharingUtils"
|
||||
private const val APK_SHARE_DIR = "apk_share"
|
||||
|
||||
/**
|
||||
* Detects if the app is installed as split APKs (from AAB) or single APK.
|
||||
*
|
||||
* @param context Application context
|
||||
* @return Pair<Boolean, List<File>> where:
|
||||
* - First: true if split APKs, false if single APK
|
||||
* - Second: List of APK files to share
|
||||
*/
|
||||
fun detectAndCollectApks(context: Context): Pair<Boolean, List<File>> {
|
||||
val applicationInfo = context.applicationInfo
|
||||
val sourceDir = applicationInfo.sourceDir // Base APK path
|
||||
val splitSourceDirs = applicationInfo.splitSourceDirs // Split APKs (null if single)
|
||||
|
||||
return if (splitSourceDirs.isNullOrEmpty()) {
|
||||
// Single APK installation (direct download/sideload)
|
||||
Log.d(TAG, "Detected single APK installation: $sourceDir")
|
||||
Pair(false, listOf(File(sourceDir)))
|
||||
} else {
|
||||
// Split APK installation (Play Store AAB)
|
||||
val apkFiles = mutableListOf(File(sourceDir)) // Base APK
|
||||
splitSourceDirs.forEach { splitPath ->
|
||||
apkFiles.add(File(splitPath))
|
||||
}
|
||||
Log.d(TAG, "Detected split APK installation with ${apkFiles.size} files")
|
||||
Pair(true, apkFiles)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares APKs for sharing by copying them to the cache directory with friendly names.
|
||||
* This is necessary because APKs in /data/app/ are not directly accessible for sharing.
|
||||
*
|
||||
* @param context Application context
|
||||
* @return List<File> of cached APK files ready for sharing, or null on error
|
||||
*/
|
||||
fun prepareApksForSharing(context: Context): List<File>? {
|
||||
return try {
|
||||
val (isSplit, apkFiles) = detectAndCollectApks(context)
|
||||
|
||||
// Create cache subdirectory for APKs
|
||||
val cacheDir = File(context.cacheDir, APK_SHARE_DIR).apply {
|
||||
if (exists()) {
|
||||
deleteRecursively() // Clean old files
|
||||
Log.d(TAG, "Cleaned existing APK share cache")
|
||||
}
|
||||
mkdirs()
|
||||
}
|
||||
|
||||
val cachedFiles = mutableListOf<File>()
|
||||
|
||||
apkFiles.forEachIndexed { index, sourceFile ->
|
||||
if (!sourceFile.exists()) {
|
||||
Log.e(TAG, "Source APK not found: ${sourceFile.path}")
|
||||
return null
|
||||
}
|
||||
|
||||
// Generate friendly names
|
||||
val fileName = when {
|
||||
isSplit && index == 0 -> "bitchat-base.apk"
|
||||
isSplit -> {
|
||||
// Extract split name (e.g., config.arm64_v8a, config.xxhdpi)
|
||||
val splitName = sourceFile.name
|
||||
.replace("split_config.", "")
|
||||
.replace("split_", "")
|
||||
.replace(".apk", "")
|
||||
"bitchat-$splitName.apk"
|
||||
}
|
||||
else -> "bitchat.apk"
|
||||
}
|
||||
|
||||
val destFile = File(cacheDir, fileName)
|
||||
sourceFile.copyTo(destFile, overwrite = true)
|
||||
cachedFiles.add(destFile)
|
||||
Log.d(TAG, "Copied ${sourceFile.name} -> ${destFile.name} (${destFile.length()} bytes)")
|
||||
}
|
||||
|
||||
Log.d(TAG, "Prepared ${cachedFiles.size} APK(s) for sharing, total size: ${cachedFiles.sumOf { it.length() }} bytes")
|
||||
|
||||
// TEMPORARY DEBUG: Also copy to Downloads for testing
|
||||
try {
|
||||
val downloadsDir = android.os.Environment.getExternalStoragePublicDirectory(android.os.Environment.DIRECTORY_DOWNLOADS)
|
||||
cachedFiles.forEach { cachedFile ->
|
||||
val testFile = File(downloadsDir, "debug-${cachedFile.name}")
|
||||
cachedFile.copyTo(testFile, overwrite = true)
|
||||
Log.d(TAG, "DEBUG: Copied to Downloads for testing: ${testFile.absolutePath} (${testFile.length()} bytes)")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "DEBUG: Could not copy to Downloads (this is OK)", e)
|
||||
}
|
||||
|
||||
cachedFiles
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to prepare APKs for sharing", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up the cached APKs after sharing.
|
||||
* This prevents accumulation of APK copies in the cache directory.
|
||||
*
|
||||
* @param context Application context
|
||||
*/
|
||||
fun cleanupSharedApks(context: Context) {
|
||||
try {
|
||||
val cacheDir = File(context.cacheDir, APK_SHARE_DIR)
|
||||
if (cacheDir.exists()) {
|
||||
val deletedFiles = cacheDir.listFiles()?.size ?: 0
|
||||
cacheDir.deleteRecursively()
|
||||
Log.d(TAG, "Cleaned up $deletedFiles shared APK file(s)")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to cleanup shared APKs", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -142,6 +142,61 @@
|
||||
<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_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_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_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>
|
||||
|
||||
<!-- Hotspot Sharing -->
|
||||
<string name="hotspot_share_via">Share via Hotspot</string>
|
||||
<string name="hotspot_share_other">Share via Bluetooth/Email</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>
|
||||
|
||||
@ -6,4 +6,8 @@
|
||||
<files-path
|
||||
name="files"
|
||||
path="." />
|
||||
<!-- For APK sharing - cache subdirectory -->
|
||||
<cache-path
|
||||
name="apk_share"
|
||||
path="apk_share/" />
|
||||
</paths>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user