mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-15 06:56:30 +00:00
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:
parent
8c9c78d2af
commit
e54b2da29a
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
}
|
||||
},
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user