From e54b2da29a287743d4e43ba4d4e9b93c0f8d9d9f Mon Sep 17 00:00:00 2001 From: Moe Hamade Date: Sun, 18 Jan 2026 15:55:53 +0200 Subject: [PATCH] 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. --- .../bitchat/android/hotspot/ApkWebServer.kt | 8 +- .../android/hotspot/HotspotActivity.kt | 10 ++- .../bitchat/android/hotspot/HotspotManager.kt | 32 +++++--- .../android/hotspot/HotspotViewModel.kt | 68 ++++++++-------- .../android/receiver/InstallResultReceiver.kt | 79 +++++++++++++++++++ .../java/com/bitchat/android/ui/AboutSheet.kt | 12 ++- .../android/util/UniversalApkManager.kt | 30 ++++++- app/src/main/res/values/strings.xml | 2 + 8 files changed, 191 insertions(+), 50 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/receiver/InstallResultReceiver.kt diff --git a/app/src/main/java/com/bitchat/android/hotspot/ApkWebServer.kt b/app/src/main/java/com/bitchat/android/hotspot/ApkWebServer.kt index bb401310..3e396cb9 100644 --- a/app/src/main/java/com/bitchat/android/hotspot/ApkWebServer.kt +++ b/app/src/main/java/com/bitchat/android/hotspot/ApkWebServer.kt @@ -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 ) } diff --git a/app/src/main/java/com/bitchat/android/hotspot/HotspotActivity.kt b/app/src/main/java/com/bitchat/android/hotspot/HotspotActivity.kt index 250c7065..fe64bfad 100644 --- a/app/src/main/java/com/bitchat/android/hotspot/HotspotActivity.kt +++ b/app/src/main/java/com/bitchat/android/hotspot/HotspotActivity.kt @@ -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() } }, diff --git a/app/src/main/java/com/bitchat/android/hotspot/HotspotManager.kt b/app/src/main/java/com/bitchat/android/hotspot/HotspotManager.kt index b64e12b2..caaf2d78 100644 --- a/app/src/main/java/com/bitchat/android/hotspot/HotspotManager.kt +++ b/app/src/main/java/com/bitchat/android/hotspot/HotspotManager.kt @@ -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) { diff --git a/app/src/main/java/com/bitchat/android/hotspot/HotspotViewModel.kt b/app/src/main/java/com/bitchat/android/hotspot/HotspotViewModel.kt index ef2cee86..9c8870b4 100644 --- a/app/src/main/java/com/bitchat/android/hotspot/HotspotViewModel.kt +++ b/app/src/main/java/com/bitchat/android/hotspot/HotspotViewModel.kt @@ -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) + } } }) diff --git a/app/src/main/java/com/bitchat/android/receiver/InstallResultReceiver.kt b/app/src/main/java/com/bitchat/android/receiver/InstallResultReceiver.kt new file mode 100644 index 00000000..771d63eb --- /dev/null +++ b/app/src/main/java/com/bitchat/android/receiver/InstallResultReceiver.kt @@ -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.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() + } + } + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt index e0f31bca..8c4b50f4 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt @@ -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) { diff --git a/app/src/main/java/com/bitchat/android/util/UniversalApkManager.kt b/app/src/main/java/com/bitchat/android/util/UniversalApkManager.kt index b14acaaf..d40558f3 100644 --- a/app/src/main/java/com/bitchat/android/util/UniversalApkManager.kt +++ b/app/src/main/java/com/bitchat/android/util/UniversalApkManager.kt @@ -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( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4659377f..4515515a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -182,7 +182,9 @@ Share via Hotspot + Create Wi-Fi hotspot to share offline Share via Bluetooth/Email + Use standard Android sharing Install Received APK