Skip to content

feat: add WiFi hotspot sharing for APK distribution without existing network#632

Merged
torlando-tech merged 4 commits intomainfrom
claude/apk-hotspot-sharing-0DELP
Mar 11, 2026
Merged

feat: add WiFi hotspot sharing for APK distribution without existing network#632
torlando-tech merged 4 commits intomainfrom
claude/apk-hotspot-sharing-0DELP

Conversation

@torlando-tech
Copy link
Copy Markdown
Owner

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

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 8, 2026

Greptile Summary

This 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 LocalHotspotManager as a thin wrapper around WifiManager.startLocalOnlyHotspot() (API 26+), threads hotspot lifecycle events into ApkSharingViewModel, and surfaces hotspot credentials (SSID/password) alongside the QR code in ApkSharingScreen.

Key observations from the review:

  • IP resolution may target the wrong interface when startHotspotSharing() is explicitly invoked while the device is still on an existing WiFi network. ApkSharingServer.getLocalIpAddress() returns the first available non-loopback IPv4 address, which would be the WiFi IP rather than the hotspot gateway (192.168.43.1). The QR code URL would then be unreachable from a device connected only to the hotspot.
  • Silent failure in onHotspotPermissionGranted() — if APK preparation previously failed (cachedApkFile == null), granting permissions produces no hotspot start and no new error message, leaving the user with stale state.
  • Hardcoded 1-second delay for hotspot interface IP assignment is fragile on slower devices; a retry-with-timeout loop would be more robust.
  • Previously flagged issues (duplicate-start callback, system-stop surface to ViewModel, and API 26–27 location permission) have been addressed in this revision.

Confidence Score: 3/5

  • Feature works in the primary no-WiFi scenario but has a reproducible broken-URL bug when hotspot mode is triggered while WiFi is still connected.
  • The core no-WiFi hotspot path is functionally correct and the previously reported bugs are addressed. However, the IP-selection logic in startHotspotAndServe has a genuine failure mode when the device is simultaneously on WiFi and the new hotspot — a realistic case since startHotspotSharing() is user-callable even while WiFi is active. Combined with the silent failure in onHotspotPermissionGranted() when APK prep failed, these warrant fixes before shipping.
  • app/src/main/java/com/lxmf/messenger/viewmodel/ApkSharingViewModel.kt — IP selection logic and silent failure path need attention.

Important Files Changed

Filename Overview
app/src/main/java/com/lxmf/messenger/service/LocalHotspotManager.kt New file wrapping WifiManager.startLocalOnlyHotspot(). Correctly handles duplicate-start re-delivery, system-stop notification, API-level SSID extraction (26/30/33), and permission errors. onStopped() does not clear onSystemStoppedListener after invoking it, but this is benign since subsequent stop() or start() calls reset it.
app/src/main/java/com/lxmf/messenger/viewmodel/ApkSharingViewModel.kt Core ViewModel update adding hotspot lifecycle management. Contains two logic issues: (1) getLocalIpAddress() after hotspot start may resolve the existing WiFi IP instead of the hotspot gateway when both interfaces are active, producing a broken QR code URL; (2) onHotspotPermissionGranted() silently returns when cachedApkFile is null with no user-visible feedback. The fixed 1-second delay for IP assignment is also fragile on slower devices.
app/src/main/java/com/lxmf/messenger/ui/screens/ApkSharingScreen.kt Adds hotspot credential card and permission-request UI. Logic for dispatching to onHotspotPermissionGranted() vs startHotspotSharing() is correct for all reachable code paths. Single permissionLauncher shared across both permission entry points is fine.
app/src/main/AndroidManifest.xml Adds NEARBY_WIFI_DEVICES with neverForLocation flag, correctly scoped to API 33+ via tools:targetApi="tiramisu". No issues.
app/src/test/java/com/lxmf/messenger/service/LocalHotspotManagerTest.kt Covers basic state management and data-class validation. Robolectric limitations prevent testing the actual startLocalOnlyHotspot callback path; the tests are appropriately scoped to what can be verified without a real device.
app/src/test/java/com/lxmf/messenger/viewmodel/ApkSharingViewModelTest.kt Uses Turbine + StandardTestDispatcher correctly. Tests verify error state, state-copy semantics, and hotspot state fields. The two newly identified logic bugs (wrong IP, silent cachedApkFile == null failure) are not exercised by these tests.

Sequence Diagram

sequenceDiagram
    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()
Loading

Last reviewed commit: 5dd70b3

Comment on lines +60 to +63
if (reservation != null) {
Log.w(TAG, "Hotspot already active, ignoring start request")
return
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@sentry
Copy link
Copy Markdown
Contributor

sentry bot commented Mar 8, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Comment on lines +198 to +203
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)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@torlando-tech torlando-tech force-pushed the claude/apk-hotspot-sharing-0DELP branch from 26e88bf to 5dd70b3 Compare March 10, 2026 04:53
@torlando-tech
Copy link
Copy Markdown
Owner Author

@greptileai

claude added 3 commits March 11, 2026 00:06
…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
@torlando-tech torlando-tech force-pushed the claude/apk-hotspot-sharing-0DELP branch from 5dd70b3 to d0b562d Compare March 11, 2026 04:48
…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>
@torlando-tech torlando-tech force-pushed the claude/apk-hotspot-sharing-0DELP branch from d0b562d to 541460c Compare March 11, 2026 04:52
Comment on lines +406 to +411
val bitmap =
android.graphics.Bitmap.createBitmap(
drawable.intrinsicWidth,
drawable.intrinsicHeight,
android.graphics.Bitmap.Config.ARGB_8888,
)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@torlando-tech torlando-tech merged commit 1d66691 into main Mar 11, 2026
14 checks passed
@torlando-tech torlando-tech deleted the claude/apk-hotspot-sharing-0DELP branch March 11, 2026 05:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants