Moe Hamade c5964184e9 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.
2026-07-24 17:15:00 +03:00

132 lines
5.2 KiB
Kotlin

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