feat: add WiFi hotspot sharing for APK distribution without existing network#632
Conversation
Greptile SummaryThis PR adds a local-only WiFi hotspot path to the existing APK sharing feature, enabling device-to-device APK transfer in environments without existing WiFi infrastructure. It introduces Key observations from the review:
Confidence Score: 3/5
Important Files Changed
Sequence DiagramsequenceDiagram
participant UI as ApkSharingScreen
participant VM as ApkSharingViewModel
participant HM as LocalHotspotManager
participant WM as WifiManager (Android)
participant SRV as ApkSharingServer
UI->>VM: init → startServer()
VM->>VM: prepareApkFile()
alt WiFi available
VM->>SRV: launchHttpServer(WIFI)
SRV-->>VM: port ready
VM-->>UI: state(isServerRunning=true, sharingMode=WIFI)
else No WiFi, API ≥ 26
VM-->>UI: state(needsHotspotPermission=true) or startHotspotAndServe()
UI->>UI: permissionLauncher.launch(perms)
UI->>VM: onHotspotPermissionGranted()
VM->>HM: start(onSystemStopped, callback)
HM->>WM: startLocalOnlyHotspot()
WM-->>HM: onStarted(reservation)
HM-->>VM: callback(Result.success(HotspotInfo))
VM->>VM: delay(1000ms) → getLocalIpAddress()
VM-->>UI: state(hotspotSsid, hotspotPassword, isHotspotStarting=true)
VM->>SRV: launchHttpServer(HOTSPOT)
SRV-->>VM: port ready
VM-->>UI: state(isServerRunning=true, sharingMode=HOTSPOT)
end
Note over WM,HM: System-initiated teardown
WM-->>HM: onStopped()
HM->>VM: onSystemStopped()
VM->>SRV: stop()
VM-->>UI: state(errorMessage="Hotspot stopped by system")
Note over UI,VM: User navigates away
VM->>VM: onCleared() → stopServer()
VM->>HM: stop()
HM->>WM: reservation.close()
Last reviewed commit: 5dd70b3 |
app/src/main/java/com/lxmf/messenger/viewmodel/ApkSharingViewModel.kt
Outdated
Show resolved
Hide resolved
| if (reservation != null) { | ||
| Log.w(TAG, "Hotspot already active, ignoring start request") | ||
| return | ||
| } |
There was a problem hiding this comment.
Bug: Tapping 'Start Hotspot' while a hotspot is already active causes the UI to get stuck in a loading state because an existing session is not properly stopped.
Severity: HIGH
Suggested Fix
In the ApkSharingViewModel, modify the startHotspotSharing() function to call hotspotManager.stop() before it calls startHotspotAndServe(). This will ensure any existing hotspot reservation is cleared, allowing the new start request to proceed correctly and invoke its callback to update the UI state.
Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.
Location: app/src/main/java/com/lxmf/messenger/service/LocalHotspotManager.kt#L60-L63
Potential issue: When the user attempts to start a hotspot while one is already active,
the UI enters a permanent loading state. This occurs because the `startHotspotSharing()`
function in the `ApkSharingViewModel` does not stop the existing hotspot before
attempting to start a new one. The `LocalHotspotManager.start()` method detects an
existing hotspot reservation and returns early without invoking its completion callback.
Since the callback is never called, the `isHotspotStarting` state variable is never
reset to `false`, leaving the UI indefinitely showing a progress indicator.
Did we get this right? 👍 / 👎 to inform future reviews.
app/src/main/java/com/lxmf/messenger/viewmodel/ApkSharingViewModel.kt
Outdated
Show resolved
Hide resolved
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
| serverJob?.cancel() | ||
| serverJob = null | ||
|
|
||
| // Obtain the readiness signal before launching so both the | ||
| // caller and the server use the same CompletableDeferred instance. | ||
| val portDeferred = server.prepareStart() | ||
| val apkFile = cachedApkFile ?: prepareApkFile() ?: return | ||
| cachedApkFile = apkFile | ||
| startHotspotAndServe(apkFile) |
There was a problem hiding this comment.
Bug: A race condition when starting the server can cause an early return in server.start(), leaving a CompletableDeferred incomplete and causing the caller to hang indefinitely.
Severity: MEDIUM
Suggested Fix
Ensure the portReady CompletableDeferred is always completed, even when the server is already running. In the if (isRunning.getAndSet(true)) block within server.start(), complete the deferred with an error or a specific value (like 0) before returning, for example: portReady.complete(0). This will prevent the await() call from hanging.
Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.
Location:
app/src/main/java/com/lxmf/messenger/viewmodel/ApkSharingViewModel.kt#L198-L203
Potential issue: When switching from WiFi sharing to Hotspot sharing, a race condition
can occur. The `startHotspotSharing` function calls `server.stop()` which sets
`isRunning` to `false`, then launches a new server. However, if the old server's
coroutine has not yet finished its cleanup and another `start` call is made (e.g., by a
quick user interaction), the `isRunning` flag might be `true`. The new `server.start()`
call will then check `if (isRunning.getAndSet(true))`, find it `true`, and return early.
This prevents the `portReady` `CompletableDeferred` from being completed, causing the
`portDeferred.await()` call in `launchHttpServer` to hang indefinitely, freezing part of
the UI.
26e88bf to
5dd70b3
Compare
…network When no WiFi network is available, the APK sharing feature now offers to create a local-only hotspot (Android 8.0+) so the receiving device can connect directly. This enables sharing Columba in areas without WiFi infrastructure. - Add LocalHotspotManager wrapping WifiManager.startLocalOnlyHotspot() - Update ApkSharingViewModel with hotspot lifecycle and permission handling - Update ApkSharingScreen with hotspot credentials display and instructions - Add NEARBY_WIFI_DEVICES permission for Android 13+ hotspot support - Add tests for LocalHotspotManager and updated ViewModel state https://claude.ai/code/session_012tKdUtSrBWstSKcJHHy22D
1. Fix silent no-op when start() called while hotspot already active — now re-delivers existing credentials via callback so UI never gets stuck in "Starting WiFi hotspot..." state. 2. Surface system-initiated hotspot teardowns to ViewModel via onSystemStopped listener — UI now shows error and resets instead of displaying stale credentials/QR code. 3. Add missing ACCESS_COARSE_LOCATION permission check for API 26-27 in hasHotspotPermissions() and getRequiredHotspotPermissions() — prevents silent SecurityException on Android 8.0-8.1. https://claude.ai/code/session_012tKdUtSrBWstSKcJHHy22D
Rewrite hasHotspotPermissions() as a single expression that delegates to getRequiredHotspotPermissions(), eliminating 4 return statements (detekt ReturnCount max=3) and 3 duplicate checkSelfPermission blocks (CPD). https://claude.ai/code/session_012tKdUtSrBWstSKcJHHy22D
app/src/main/java/com/lxmf/messenger/viewmodel/ApkSharingViewModel.kt
Outdated
Show resolved
Hide resolved
app/src/main/java/com/lxmf/messenger/viewmodel/ApkSharingViewModel.kt
Outdated
Show resolved
Hide resolved
5dd70b3 to
d0b562d
Compare
…page Hotspot mode now shows a two-step QR flow: the first QR code uses the standard WIFI:T:WPA;S:...;P:...;; format so the receiving phone auto- connects when scanned, and the second QR code opens the download URL. The download page served by the HTTP server now displays the Columba app icon loaded at runtime from the launcher mipmap resource. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
d0b562d to
541460c
Compare
| val bitmap = | ||
| android.graphics.Bitmap.createBitmap( | ||
| drawable.intrinsicWidth, | ||
| drawable.intrinsicHeight, | ||
| android.graphics.Bitmap.Config.ARGB_8888, | ||
| ) |
There was a problem hiding this comment.
Bug: The loadIconBase64 function fails on Android 8.0+ because AdaptiveIconDrawable returns -1 for dimensions, causing Bitmap.createBitmap to throw an exception, preventing the icon from being displayed.
Severity: MEDIUM
Suggested Fix
Instead of relying on intrinsicWidth and intrinsicHeight which can be -1 for AdaptiveIconDrawable, create a Bitmap with fixed dimensions. Draw the Drawable onto a Canvas backed by this new Bitmap. Set the drawable's bounds to the full size of the canvas before drawing. This ensures a valid bitmap is created regardless of the drawable's intrinsic dimensions.
Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.
Location:
app/src/main/java/com/lxmf/messenger/viewmodel/ApkSharingViewModel.kt#L406-L411
Potential issue: On Android 8.0+ devices, the app icon resource `R.mipmap.ic_launcher`
resolves to an `AdaptiveIconDrawable`. This drawable type returns -1 for its intrinsic
width and height. The `loadIconBase64` function passes these negative dimensions to
`Bitmap.createBitmap`, which throws an `IllegalArgumentException`. The exception is
caught and the function returns `null`, causing the APK sharing download page to be
rendered without the intended app icon. This silently breaks the icon display feature
for its target API levels.
When no WiFi network is available, the APK sharing feature now offers
to create a local-only hotspot (Android 8.0+) so the receiving device
can connect directly. This enables sharing Columba in areas without
WiFi infrastructure.
https://claude.ai/code/session_012tKdUtSrBWstSKcJHHy22D