Skip to content

feat: add offline mode banner (#623)#626

Merged
torlando-tech merged 3 commits intomainfrom
feat/offline-mode-banner
Mar 11, 2026
Merged

feat: add offline mode banner (#623)#626
torlando-tech merged 3 commits intomainfrom
feat/offline-mode-banner

Conversation

@torlando-tech
Copy link
Copy Markdown
Owner

Summary

  • Adds a persistent thin banner at the top of all screens showing "Columba is offline" with a Reconnect button when Reticulum is in SHUTDOWN or ERROR state
  • Fixes ServiceReticulumProtocol.unbindService() to emit NetworkStatus.SHUTDOWN — previously only process crashes triggered the status update, so explicit shutdown from Settings never surfaced to the UI
  • Banner consumes status bar insets so inner TopAppBars don't double-pad when visible

Test plan

  • Unit tests for shouldShowOfflineBanner() covering all NetworkStatus variants
  • Build succeeds (compileNoSentryDebugKotlin)
  • Manual: Settings → shut down service → verify banner appears on all tabs
  • Manual: Tap Reconnect → verify "Reconnecting..." with spinner → banner disappears when service restarts
  • Manual: Verify no layout shift on screens when banner is hidden (normal operation)

Closes #623

🤖 Generated with Claude Code

@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!

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 8, 2026

Greptile Summary

This PR adds a persistent offline-mode banner at the top of all screens that appears when Reticulum is in SHUTDOWN or ERROR state, and also fixes a pre-existing bug in ServiceReticulumProtocol.unbindService() where explicit service shutdown was silently swallowed and never surfaced to the UI. The core logic is solid and the root-cause fix is correct.

Key changes:

  • OfflineModeBanner composable with animated enter/exit, "Reconnecting..." spinner state, and a "Reconnect" TextButton that calls settingsViewModel.restartService()
  • unbindService() now explicitly emits NetworkStatus.SHUTDOWN — fixing the bug where onServiceDisconnected (which is only called on unexpected process death, not intentional unbinds) was relied upon for intentional shutdowns
  • SettingsState gains networkStatus and isRestarting fields collected from reticulumProtocol.networkStatus; both are forwarded to the banner
  • All user-visible strings added to strings.xml; previous review comments on hardcoded strings and missing spinner accessibility description have been addressed

Issues found:

  • Modifier.statusBarsPadding() inside the banner's Row only consumes insets within the banner's own subtree. The sibling NavHost's inner TopAppBar composables still receive the full WindowInsets.statusBars via TopAppBarDefaults.windowInsets, so they will be unnecessarily taller (by ~status-bar height) whenever the banner is visible — contrary to the PR description's intent
  • A minor race between isRestarting = false and the networkStatus StateFlow delivering READY could cause a sub-frame flicker of the "Reconnect" button on a successful restart

Confidence Score: 3/5

  • The core bug fix and banner logic are correct, but the inset handling has a real layout defect when the banner is visible.
  • The unbindService() SHUTDOWN emission fix is correct and well-reasoned. The banner visibility logic and state management are sound. However, the statusBarsPadding() modifier on the banner Row does not consume insets for the sibling NavHost, meaning inner TopAppBars will add spurious extra height equal to the status bar when the offline banner is showing — a visual regression on every screen while the service is offline. This is a direct consequence of Compose's inset consumption rules (consumption is per-subtree, not cross-sibling), and the PR description's claim of "consumes status bar insets" is not achieved by the current implementation.
  • app/src/main/java/com/lxmf/messenger/ui/components/OfflineModeBanner.kt and app/src/main/java/com/lxmf/messenger/MainActivity.kt — the inset consumption strategy needs to be corrected.

Important Files Changed

Filename Overview
app/src/main/java/com/lxmf/messenger/ui/components/OfflineModeBanner.kt New composable implementing the offline banner; logic is sound, but the statusBarsPadding() call does not consume insets for the sibling NavHost, likely causing TopAppBar double-padding when the banner is visible.
app/src/main/java/com/lxmf/messenger/reticulum/protocol/ServiceReticulumProtocol.kt Correctly adds _networkStatus.value = NetworkStatus.SHUTDOWN directly in unbindService(), fixing the bug where explicit shutdown never surfaced to the UI (since onServiceDisconnected is not called for intentional unbinds).
app/src/main/java/com/lxmf/messenger/viewmodel/SettingsViewModel.kt isRestarting and networkStatus are propagated cleanly to the UI state; minor race between isRestarting=false and the networkStatus StateFlow delivery could cause a sub-frame banner flicker on successful restart.
app/src/main/java/com/lxmf/messenger/MainActivity.kt OfflineModeBanner correctly placed above NavHost inside the outer Scaffold's content; the NavHost receives Modifier.weight(1f) to fill remaining space. The inset consumption concern (see banner comment) originates here.
app/src/test/java/com/lxmf/messenger/ui/components/OfflineModeBannerTest.kt Unit tests cover all five NetworkStatus variants for shouldShowOfflineBanner(); complete and correct coverage for the pure visibility function.
app/src/main/res/values/strings.xml All five banner strings (shutdown, error, reconnecting, reconnect button, spinner description) added correctly to strings.xml; previously-flagged hardcoding concern is resolved.

Sequence Diagram

sequenceDiagram
    participant U as User
    participant VM as SettingsViewModel
    participant SRP as ServiceReticulumProtocol
    participant SC as ServiceConnection
    participant UI as OfflineModeBanner

    U->>VM: restartService()
    VM->>VM: isRestarting = true
    VM-->>UI: banner shows "Reconnecting…" + spinner

    VM->>SRP: applyInterfaceChanges()
    SRP->>SC: unbindService()
    SC->>SRP: _networkStatus = SHUTDOWN
    SRP-->>VM: networkStatus flow → SHUTDOWN
    VM-->>UI: networkStatus = SHUTDOWN (banner still visible via isRestarting)

    SRP->>SC: bindService()
    SC->>SRP: onServiceConnected → CONNECTING → INITIALIZING → READY
    SRP-->>VM: networkStatus flow → READY
    VM-->>UI: networkStatus = READY

    VM->>VM: isRestarting = false
    VM-->>UI: shouldShowOfflineBanner(READY)=false, isRestarting=false → banner hidden

    Note over U,UI: Settings → Shutdown path (bug fix)
    U->>VM: shutdownService()
    VM->>SRP: unbindService()
    SRP->>SRP: _networkStatus = SHUTDOWN  (NEW — was missing before)
    SRP-->>VM: networkStatus flow → SHUTDOWN
    VM-->>UI: banner appears with "Reconnect" button
Loading

Comments Outside Diff (1)

  1. app/src/main/java/com/lxmf/messenger/viewmodel/SettingsViewModel.kt, line 916-938 (link)

    Banner flickers to "Reconnect" on successful restart

    isRestarting is set back to false on line 934 immediately after applyInterfaceChanges() returns. However, networkStatus is updated by a separate collection coroutine (reticulumProtocol.networkStatus.collect { ... }), which runs on the same dispatcher but as a separate coroutine. There is a window between the two _state.update calls where isRestarting = false but networkStatus is still SHUTDOWN, making shouldShowOfflineBanner(SHUTDOWN) || false = true. The banner would briefly flash the "Reconnect" button before the status flow delivers READY.

    Consider updating both fields atomically in a single _state.update once the service is confirmed ready:

    _state.update { it.copy(isRestarting = false, networkStatus = NetworkStatus.READY) }

    Or hold a snapshotted value of networkStatus from the service before clearing isRestarting, to avoid the single-frame flash.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: app/src/main/java/com/lxmf/messenger/viewmodel/SettingsViewModel.kt
    Line: 916-938
    
    Comment:
    **Banner flickers to "Reconnect" on successful restart**
    
    `isRestarting` is set back to `false` on line 934 immediately after `applyInterfaceChanges()` returns. However, `networkStatus` is updated by a *separate* collection coroutine (`reticulumProtocol.networkStatus.collect { ... }`), which runs on the same dispatcher but as a separate coroutine. There is a window between the two `_state.update` calls where `isRestarting = false` but `networkStatus` is still `SHUTDOWN`, making `shouldShowOfflineBanner(SHUTDOWN) || false = true`. The banner would briefly flash the "Reconnect" button before the status flow delivers `READY`.
    
    Consider updating both fields atomically in a single `_state.update` once the service is confirmed ready:
    
    ```kotlin
    _state.update { it.copy(isRestarting = false, networkStatus = NetworkStatus.READY) }
    ```
    
    Or hold a snapshotted value of `networkStatus` from the service before clearing `isRestarting`, to avoid the single-frame flash.
    
    How can I resolve this? If you propose a fix, please make it concise.

Last reviewed commit: a6aa26c

Comment on lines +40 to +42
@Composable
fun OfflineModeBanner(
networkStatus: NetworkStatus,
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 causes the "Reconnecting..." banner to disappear prematurely because the isRestarting flag is cleared before the service's networkStatus is updated to READY.
Severity: MEDIUM

Suggested Fix

Decouple the UI state from the completion of the applyInterfaceChanges() call. The isRestarting flag should only be set to false after the service has confirmed it has reached the READY state, not just when the restart process has been initiated. This ensures the banner remains visible for the entire duration of the reconnection process.

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/ui/components/OfflineModeBanner.kt#L40-L42

Potential issue: A race condition exists when restarting the service. The `isRestarting`
state is set to `false` as soon as the `applyInterfaceChanges()` function returns, which
occurs before the service has fully re-initialized and the `networkStatus` becomes
`READY`. The offline banner's visibility is determined by
`shouldShowOfflineBanner(networkStatus) || isRestarting`. Because `isRestarting` becomes
false prematurely, there is a window of time where both conditions are false, causing
the "Reconnecting..." banner to disappear before the connection is actually
re-established, potentially confusing the user.

Did we get this right? 👍 / 👎 to inform future reviews.

@torlando-tech torlando-tech added this to the v0.10.0 milestone Mar 9, 2026
torlando-tech and others added 2 commits March 10, 2026 00:55
…623)

Show a thin banner above the TopAppBar across all screens when the
service is in SHUTDOWN or ERROR state. The banner displays "Columba is
offline" with a Reconnect button, or "Reconnecting..." with a spinner
while the service restarts. Status bar insets are consumed at the Column
level so inner TopAppBars don't double-pad when the banner is visible.

Also fix ServiceReticulumProtocol.unbindService() to emit SHUTDOWN
status — previously only onServiceDisconnected (process crash) set it,
so explicit shutdown via Settings never updated the networkStatus flow.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Move hardcoded UI strings to res/values/strings.xml for localization
readiness. Add semantics contentDescription to the reconnecting spinner
so TalkBack users know it is a loading indicator.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@torlando-tech torlando-tech force-pushed the feat/offline-mode-banner branch from 26a1599 to 6512789 Compare March 10, 2026 04:56
…tion

Moves statusBarsPadding() from the parent Column (toggled instantly via
boolean) into OfflineModeBanner's Row so it animates together with
AnimatedVisibility, preventing a ~24dp layout jump during banner exit.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@torlando-tech
Copy link
Copy Markdown
Owner Author

@greptileai

Comment on lines +58 to +60
Modifier
.fillMaxWidth()
.statusBarsPadding()
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.

Status bar insets not consumed for sibling NavHost

Modifier.statusBarsPadding() is applied to the Row inside the banner. In Compose, inset consumption propagates down to children of the modifier's owner — not laterally to sibling composables. The NavHost is a sibling in the outer Column, so it never sees those consumed insets.

As a result, every inner screen's TopAppBar (which uses TopAppBarDefaults.windowInsets = WindowInsets.statusBars by default) still applies the full status-bar height as its own top padding. When the banner is visible and the NavHost starts below the banner, the TopAppBar is rendered already below the status bar but still adds status-bar-height padding, making it appear taller than intended.

The PR description says "Banner consumes status bar insets so inner TopAppBars don't double-pad", but the current implementation doesn't achieve this because statusBarsPadding() on a sibling's child does not propagate consumption outward.

A targeted fix is to consume the status-bar insets on the NavHost itself when the banner is visible, so that inner Scaffolds receive zero status-bar insets:

val bannerVisible = shouldShowOfflineBanner(networkStatus) || isRestarting

NavHost(
    modifier = Modifier
        .weight(1f)
        .then(
            if (bannerVisible)
                Modifier.consumeWindowInsets(WindowInsets.statusBars)
            else
                Modifier
        ),
    ...
)

This requires importing androidx.compose.foundation.layout.consumeWindowInsets (already used in several screen files, e.g. SettingsScreen.kt).

Prompt To Fix With AI
This is a comment left during a code review.
Path: app/src/main/java/com/lxmf/messenger/ui/components/OfflineModeBanner.kt
Line: 58-60

Comment:
**Status bar insets not consumed for sibling NavHost**

`Modifier.statusBarsPadding()` is applied to the `Row` inside the banner. In Compose, inset consumption propagates **down** to children of the modifier's owner — not laterally to sibling composables. The `NavHost` is a sibling in the outer `Column`, so it never sees those consumed insets.

As a result, every inner screen's `TopAppBar` (which uses `TopAppBarDefaults.windowInsets` = `WindowInsets.statusBars` by default) still applies the full status-bar height as its own top padding. When the banner is visible and the `NavHost` starts below the banner, the `TopAppBar` is rendered already below the status bar but still adds status-bar-height padding, making it appear taller than intended.

The PR description says "Banner consumes status bar insets so inner TopAppBars don't double-pad", but the current implementation doesn't achieve this because `statusBarsPadding()` on a sibling's child does not propagate consumption outward.

A targeted fix is to consume the status-bar insets on the `NavHost` itself when the banner is visible, so that inner Scaffolds receive zero status-bar insets:

```kotlin
val bannerVisible = shouldShowOfflineBanner(networkStatus) || isRestarting

NavHost(
    modifier = Modifier
        .weight(1f)
        .then(
            if (bannerVisible)
                Modifier.consumeWindowInsets(WindowInsets.statusBars)
            else
                Modifier
        ),
    ...
)
```

This requires importing `androidx.compose.foundation.layout.consumeWindowInsets` (already used in several screen files, e.g. `SettingsScreen.kt`).

How can I resolve this? If you propose a fix, please make it concise.

@torlando-tech torlando-tech merged commit 9d288a0 into main Mar 11, 2026
23 of 25 checks passed
@torlando-tech torlando-tech deleted the feat/offline-mode-banner branch March 11, 2026 02:14
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.

Warning when RNS is shut down

1 participant