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

View File

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

View File

@ -56,6 +56,7 @@ class HotspotManager(private val context: Context) {
private var callback: HotspotCallback? = null private var callback: HotspotCallback? = null
private var isStarting = false private var isStarting = false
private var hasNotifiedStarted = false // Track if we've notified the callback 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 // Saved credentials for reconnection
private var savedSsid: String? = null private var savedSsid: String? = null
@ -97,12 +98,16 @@ class HotspotManager(private val context: Context) {
Log.d(TAG, "Starting Wi-Fi P2P hotspot") Log.d(TAG, "Starting Wi-Fi P2P hotspot")
// Register broadcast receiver // Register broadcast receiver (only if not already registered)
val intentFilter = IntentFilter().apply { if (!isReceiverRegistered) {
addAction(WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION) val intentFilter = IntentFilter().apply {
addAction(WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION) 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 // Acquire locks
acquireLocks() acquireLocks()
@ -147,11 +152,16 @@ class HotspotManager(private val context: Context) {
// Release locks // Release locks
releaseLocks() releaseLocks()
// Unregister receiver // Unregister receiver (only if registered)
try { if (isReceiverRegistered) {
context.unregisterReceiver(broadcastReceiver) try {
} catch (e: Exception) { context.unregisterReceiver(broadcastReceiver)
Log.w(TAG, "Error unregistering receiver", e) isReceiverRegistered = false
Log.d(TAG, "Broadcast receiver unregistered")
} catch (e: IllegalArgumentException) {
Log.w(TAG, "Receiver was not registered", e)
isReceiverRegistered = false
}
} }
currentGroup = null currentGroup = null
@ -325,7 +335,7 @@ class HotspotManager(private val context: Context) {
PowerManager.FULL_WAKE_LOCK, PowerManager.FULL_WAKE_LOCK,
"BitChat:HotspotWakeLock" "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 wifiManager = context.getSystemService(Context.WIFI_SERVICE) as android.net.wifi.WifiManager
val lockType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { 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 { manager.startHotspot(object : HotspotManager.HotspotCallback {
override fun onHotspotStarted() { override fun onHotspotStarted() {
Log.d(TAG, "Hotspot started successfully") viewModelScope.launch {
Log.d(TAG, "Hotspot started successfully")
// Get connection info // Get connection info
val info = manager.getConnectionInfo() val info = manager.getConnectionInfo()
if (info == null) { if (info == null) {
_state.value = HotspotState.Error("Failed to get hotspot connection info") _state.value = HotspotState.Error("Failed to get hotspot connection info")
return return@launch
} }
// Start web server // Start web server
try { try {
val server = ApkWebServer(context, apkFile) val server = ApkWebServer(context, apkFile)
server.startServer() server.startServer()
webServer = server 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 // Update state with connection info
_state.value = HotspotState.Active( _state.value = HotspotState.Active(
ssid = info.ssid, ssid = info.ssid,
password = info.password, password = info.password,
ipAddress = info.ipAddress, ipAddress = info.ipAddress,
port = ApkWebServer.DEFAULT_PORT, port = ApkWebServer.DEFAULT_PORT,
connectedPeers = info.connectedPeers connectedPeers = info.connectedPeers
) )
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to start web server", e) Log.e(TAG, "Failed to start web server", e)
manager.stopHotspot() manager.stopHotspot()
_state.value = HotspotState.Error("Failed to start web server: ${e.message}") _state.value = HotspotState.Error("Failed to start web server: ${e.message}")
}
} }
} }
override fun onConnectionInfoUpdated(info: HotspotManager.ConnectionInfo?) { override fun onConnectionInfoUpdated(info: HotspotManager.ConnectionInfo?) {
// Update peer count if we're active viewModelScope.launch {
val currentState = _state.value // Update peer count if we're active
if (currentState is HotspotState.Active && info != null) { val currentState = _state.value
_state.value = currentState.copy(connectedPeers = info.connectedPeers) if (currentState is HotspotState.Active && info != null) {
_state.value = currentState.copy(connectedPeers = info.connectedPeers)
}
} }
} }
override fun onError(message: String) { override fun onError(message: String) {
Log.e(TAG, "Hotspot error: $message") viewModelScope.launch {
_state.value = HotspotState.Error(message) 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 color = colorScheme.onSurface
) )
Text( Text(
text = "Create Wi-Fi hotspot to share offline", text = stringResource(R.string.hotspot_share_via_subtitle),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = colorScheme.onSurface.copy(alpha = 0.6f), color = colorScheme.onSurface.copy(alpha = 0.6f),
lineHeight = 16.sp lineHeight = 16.sp
@ -805,7 +805,7 @@ fun AboutSheet(
color = colorScheme.onSurface color = colorScheme.onSurface
) )
Text( Text(
text = "Use standard Android sharing", text = stringResource(R.string.hotspot_share_other_subtitle),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = colorScheme.onSurface.copy(alpha = 0.6f), color = colorScheme.onSurface.copy(alpha = 0.6f),
lineHeight = 16.sp lineHeight = 16.sp
@ -1199,8 +1199,14 @@ private suspend fun downloadUniversalApk(
onResult: (ApkPreparationStatus) -> Unit onResult: (ApkPreparationStatus) -> Unit
) { ) {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
var lastUpdateTime = 0L
val result = apkManager.downloadUniversalApk { progress -> 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) { 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. * Download the universal APK from GitHub.
* @param progressCallback Called with progress percentage (0-100) * @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, "Downloading from: $url")
Log.d(TAG, "Expected size: ${expectedSize / 1024 / 1024}MB") Log.d(TAG, "Expected size: ${expectedSize / 1024 / 1024}MB")
// Check available disk space before downloading
checkDiskSpace(expectedSize)
// Download to temporary file first // Download to temporary file first
val tempFile = File(cacheDir, "download_temp.apk") val tempFile = File(cacheDir, "download_temp.apk")
if (tempFile.exists()) { if (tempFile.exists()) {
@ -218,7 +239,14 @@ class UniversalApkManager(private val context: Context) {
if (finalFile.exists()) { if (finalFile.exists()) {
finalFile.delete() 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 // Save metadata
saveMetadata( saveMetadata(

View File

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