refactor: Improve hotspot and APK sharing stability

This commit introduces several fixes and refinements to the hotspot sharing and APK handling features, improving stability, user experience, and robustness.

Key changes:

-   **Hotspot Flow:**
    -   Automatically starts the hotspot after the user grants the required Wi-Fi permission, removing the need for a second button press.
    -   Ensures all `HotspotManager` callbacks in `HotspotViewModel` are executed within `viewModelScope` to prevent threading issues and ensure safe UI updates.
    -   Fixes a potential `BroadcastReceiver` leak in `HotspotManager` by tracking its registration state, preventing crashes and resource leaks when stopping the hotspot.
    -   Changes the hotspot `WakeLock` to be non-expiring to prevent the CPU from sleeping while the hotspot is active.

-   **APK Handling & Installation:**
    -   Adds a pre-download disk space check in `UniversalApkManager` to prevent download failures on devices with insufficient storage.
    -   Improves the file move logic after download by falling back to a copy-and-delete strategy if a direct rename fails, making it more robust across different filesystems.
    -   Introduces `InstallResultReceiver` to provide clear Toast notifications to the user about the success or failure of an APK installation, including specific error reasons (e.g., "Not enough storage").

-   **Performance & UI:**
    -   Caches the generated HTML in `ApkWebServer` to improve performance by avoiding regeneration on every request.
    -   Throttles the APK download progress updates to prevent UI jankiness from too-frequent state changes.
    -   Moves hardcoded strings in the "Share App" UI to `strings.xml` for better localization and maintenance.
This commit is contained in:
Moe Hamade 2026-01-18 15:55:53 +02:00 committed by Moe Hamade
parent 8c9c78d2af
commit e54b2da29a
8 changed files with 191 additions and 50 deletions

View File

@ -30,6 +30,11 @@ class ApkWebServer(
}
}
// 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 ?: "/"
@ -90,11 +95,10 @@ class ApkWebServer(
* Serve the HTML landing page.
*/
private fun serveLandingPage(): Response {
val html = generateLandingPageHtml()
return newFixedLengthResponse(
Response.Status.OK,
"text/html",
html
cachedHtml
)
}

View File

@ -169,7 +169,13 @@ fun IntroScreen(onStartHotspot: () -> Unit) {
else -> null // No runtime permission needed on Android < 10
}
val permissionState = requiredPermission?.let { rememberPermissionState(it) }
val permissionState = requiredPermission?.let {
rememberPermissionState(it) { granted ->
if (granted) {
onStartHotspot()
}
}
}
Column(
modifier = Modifier
@ -280,7 +286,7 @@ fun IntroScreen(onStartHotspot: () -> Unit) {
// No permission needed or already granted
onStartHotspot()
} else {
// Request permission
// Request permission (auto-start handled by onPermissionResult callback)
permissionState.launchPermissionRequest()
}
},

View File

@ -56,6 +56,7 @@ class HotspotManager(private val context: Context) {
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
@ -97,12 +98,16 @@ class HotspotManager(private val context: Context) {
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)
// 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")
}
context.registerReceiver(broadcastReceiver, intentFilter)
// Acquire locks
acquireLocks()
@ -147,11 +152,16 @@ class HotspotManager(private val context: Context) {
// Release locks
releaseLocks()
// Unregister receiver
try {
context.unregisterReceiver(broadcastReceiver)
} catch (e: Exception) {
Log.w(TAG, "Error unregistering receiver", e)
// 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
@ -325,7 +335,7 @@ class HotspotManager(private val context: Context) {
PowerManager.FULL_WAKE_LOCK,
"BitChat:HotspotWakeLock"
)
wakeLock?.acquire(10 * 60 * 1000L) // 10 minutes max
wakeLock?.acquire()
val wifiManager = context.getSystemService(Context.WIFI_SERVICE) as android.net.wifi.WifiManager
val lockType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {

View File

@ -46,49 +46,55 @@ class HotspotViewModel(application: Application) : AndroidViewModel(application)
manager.startHotspot(object : HotspotManager.HotspotCallback {
override fun onHotspotStarted() {
Log.d(TAG, "Hotspot started successfully")
viewModelScope.launch {
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
}
// Get connection info
val info = manager.getConnectionInfo()
if (info == null) {
_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
// Start web server
try {
val server = ApkWebServer(context, apkFile)
server.startServer()
webServer = server
Log.d(TAG, "Web server started on port ${ApkWebServer.DEFAULT_PORT}")
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}")
// 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)
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) {
Log.e(TAG, "Hotspot error: $message")
_state.value = HotspotState.Error(message)
viewModelScope.launch {
Log.e(TAG, "Hotspot error: $message")
_state.value = HotspotState.Error(message)
}
}
})

View File

@ -0,0 +1,79 @@
package com.bitchat.android.receiver
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.pm.PackageInstaller
import android.util.Log
import android.widget.Toast
import com.bitchat.android.R
import com.bitchat.android.util.ApkInstaller
/**
* Receives installation results from PackageInstaller.
* Shows toast messages to inform user of success/failure.
*/
class InstallResultReceiver : BroadcastReceiver() {
companion object {
private const val TAG = "InstallResultReceiver"
}
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != ApkInstaller.ACTION_INSTALL_COMPLETE) {
return
}
val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)
val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
when (status) {
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
// System is asking for user confirmation
Log.d(TAG, "Installation pending user action")
val confirmIntent = intent.getParcelableExtra<Intent>(Intent.EXTRA_INTENT)
if (confirmIntent != null) {
confirmIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
try {
context.startActivity(confirmIntent)
} catch (e: Exception) {
Log.e(TAG, "Failed to start confirmation intent", e)
Toast.makeText(
context,
"Installation failed: Could not show confirmation dialog",
Toast.LENGTH_SHORT
).show()
}
}
}
PackageInstaller.STATUS_SUCCESS -> {
Log.d(TAG, "Installation succeeded")
Toast.makeText(
context,
"BitChat installed successfully!",
Toast.LENGTH_LONG
).show()
}
PackageInstaller.STATUS_FAILURE,
PackageInstaller.STATUS_FAILURE_ABORTED,
PackageInstaller.STATUS_FAILURE_BLOCKED,
PackageInstaller.STATUS_FAILURE_CONFLICT,
PackageInstaller.STATUS_FAILURE_INCOMPATIBLE,
PackageInstaller.STATUS_FAILURE_INVALID,
PackageInstaller.STATUS_FAILURE_STORAGE -> {
Log.e(TAG, "Installation failed with status $status: $message")
val errorMsg = when (status) {
PackageInstaller.STATUS_FAILURE_ABORTED -> "Installation was cancelled"
PackageInstaller.STATUS_FAILURE_BLOCKED -> "Installation blocked by system policy"
PackageInstaller.STATUS_FAILURE_CONFLICT -> "Package conflicts with existing installation. Try uninstalling first."
PackageInstaller.STATUS_FAILURE_INCOMPATIBLE -> "Package is incompatible with this device"
PackageInstaller.STATUS_FAILURE_INVALID -> "Package is invalid or corrupted"
PackageInstaller.STATUS_FAILURE_STORAGE -> "Not enough storage space"
else -> "Installation failed: ${message ?: "Unknown error"}"
}
Toast.makeText(context, errorMsg, Toast.LENGTH_LONG).show()
}
}
}
}

View File

@ -740,7 +740,7 @@ fun AboutSheet(
color = colorScheme.onSurface
)
Text(
text = "Create Wi-Fi hotspot to share offline",
text = stringResource(R.string.hotspot_share_via_subtitle),
style = MaterialTheme.typography.bodySmall,
color = colorScheme.onSurface.copy(alpha = 0.6f),
lineHeight = 16.sp
@ -805,7 +805,7 @@ fun AboutSheet(
color = colorScheme.onSurface
)
Text(
text = "Use standard Android sharing",
text = stringResource(R.string.hotspot_share_other_subtitle),
style = MaterialTheme.typography.bodySmall,
color = colorScheme.onSurface.copy(alpha = 0.6f),
lineHeight = 16.sp
@ -1199,8 +1199,14 @@ private suspend fun downloadUniversalApk(
onResult: (ApkPreparationStatus) -> Unit
) {
withContext(Dispatchers.IO) {
var lastUpdateTime = 0L
val result = apkManager.downloadUniversalApk { progress ->
onProgress(progress)
// Throttle updates to max 10 per second (100ms interval) to avoid janky UI
val now = System.currentTimeMillis()
if (now - lastUpdateTime >= 100 || progress == 100) {
lastUpdateTime = now
onProgress(progress)
}
}
val status = if (result.isSuccess) {

View File

@ -123,6 +123,24 @@ class UniversalApkManager(private val context: Context) {
}
}
/**
* 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.
* @param progressCallback Called with progress percentage (0-100)
@ -144,6 +162,9 @@ class UniversalApkManager(private val context: Context) {
Log.d(TAG, "Downloading from: $url")
Log.d(TAG, "Expected size: ${expectedSize / 1024 / 1024}MB")
// Check available disk space before downloading
checkDiskSpace(expectedSize)
// Download to temporary file first
val tempFile = File(cacheDir, "download_temp.apk")
if (tempFile.exists()) {
@ -218,7 +239,14 @@ class UniversalApkManager(private val context: Context) {
if (finalFile.exists()) {
finalFile.delete()
}
tempFile.renameTo(finalFile)
// Try rename first (fast), fallback to copy if it fails (different partitions/filesystems)
val moved = tempFile.renameTo(finalFile)
if (!moved) {
Log.w(TAG, "Rename failed, falling back to copy")
tempFile.copyTo(finalFile, overwrite = true)
tempFile.delete()
}
// Save metadata
saveMetadata(

View File

@ -182,7 +182,9 @@
<!-- 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 Bluetooth/Email</string>
<string name="hotspot_share_other_subtitle">Use standard Android sharing</string>
<!-- APK Installation -->
<string name="install_bitchat_title">Install Received APK</string>