Skip to content

fix(connectivity): detect airplane-mode offline when Tailscale VPN is active - #1126

Merged
pkmetski merged 18 commits into
mainfrom
pkmetski/riffle-source-offline-filter-fix
Sep 30, 2026
Merged

pkmetski merged 18 commits into
mainfrom
pkmetski/riffle-source-offline-filter-fix

Conversation

@pkmetski

@pkmetski pkmetski commented Sep 29, 2026 •

Copy link
Copy Markdown
Owner

What

Fixes airplane-mode offline detection failing when Tailscale is running.

Root cause: ValidatedNetworkTracker was including Tailscale VPN networks (TRANSPORT_VPN). The Tailscale tun interface stays alive at kernel level in airplane mode — its link addresses, capabilities (INTERNET=true, VALIDATED=true), DNS servers (100.100.100.100 MagicDNS), and routes are identical regardless of whether it can relay traffic. Samsung One UI correctly fires onLost for physical networks (WiFi, cellular) in airplane mode but drops it for the VPN handle. With the VPN in the tracker, trackerOnline stayed true permanently, blocking the offline banner.

Fix: Exclude TRANSPORT_VPN networks in onAvailable — they never enter the tracker. Physical onLost events (which Samsung delivers correctly) then empty the tracker on airplane mode. Also updated currentOnline() to iterate allNetworks instead of checking only activeNetwork (which would be the VPN and return false even while online); iterating all networks correctly finds the underlying physical WiFi/cellular independently.

Added a periodic probe (every 15s) in RiffleViewModel as a secondary defence for the "server unreachable while device appears online" scenario (captive portals, LAN-only server on cellular). The probe calls refreshForSource for every known source; on CatalogException.Offline it populates _failedSourceIds, which flips isOffline and hands off to the existing retry loop.

Added .distinctUntilChanged() on the source list observer to prevent Room Flow re-emissions from spuriously clearing _failedSourceIds before the re-refresh completes (brief "online" flicker).

Why no iOS change

IosConnectivityObserver uses nw_path_monitor with nw_path_status_satisfied, which correctly detects airplane mode regardless of VPN state on iOS. Verified by tracing IosConnectivityObserverImpl.

Tests

  • QualifyingNetworkTest: VPN with internet is offline — VPN tunnel survives airplane mode (load-bearing regression pin for the isQualifyingNetwork VPN filter)
  • ConnectivityReconcileTest: VPN airplane mode scenario end-to-end via tracker (full Samsung+Tailscale regression reproduction: WiFi in tracker, VPN never added, airplane mode → onLost(wifi) empties tracker → offline despite VPN activeNetwork non-null)
  • ConnectivityReconcileTest: renamed foreground poll rescues stuck-online tracker on Android 13 dropped onLost → foreground poll rescues stuck-online tracker via currentOnline returning false (covers both AOSP null-activeNetwork and Samsung/VPN split-tunnel paths)
  • RiffleViewModelTest: isOfflineDetectedByProbeWhenConnectivityObserverIsStuck (probe fires after CONNECTIVITY_PROBE_INTERVAL_MS, asserts isOffline=true)
  • ToReadRepositoryTest (Android): refreshForSource returns false when offline, returns true for non-network errors, populates cache on success
  • IosToReadRepositoryImplTest (iOS): 5 tests covering offline, auth error, server error, non-ABS source

Known limitation (finding dismissed): Enterprise always-on-VPN / MDM setups where the physical WiFi/cellular entry doesn't appear in allNetworks for the app would produce a permanent offline banner. This scenario is indistinguishable from Tailscale airplane mode at the ConnectivityManager snapshot level without tracking callback history. Not Riffle's target use case; deferred as follow-up.

🤖 Generated with Claude Code

…server is stuck

Two bugs remain after #1113 when the device goes offline while the Riffle hub is open:

1. refreshForSource returned false for any CatalogException (including Auth/403), so the
   offline banner lit up even for admin users whose ABS instance has readlist turned off.
   Fixed: return false only for CatalogException.Offline / NetworkResult.Offline.

2. On Android 13+ the OS can silently drop the onLost callback, leaving
   connectivityObserver.isOnline stuck at true for up to 15s (or indefinitely if
   activeNetwork is slow to clean up). inProgress and continueSeries flat-mapped directly
   on isOnline, so they never switched to the offline path — switching sources was the only
   workaround (which restarted the refresh loop and let _failedSourceIds detect the failure).
   Fixed: both flows now gate on the combined isOffline StateFlow, which accounts for both
   the connectivity observer and _failedSourceIds. After the refreshForSource fix,
   _failedSourceIds is non-empty only on genuine network failures, so using it to gate
   inProgress is semantically correct — those items cannot be streamed anyway.

Removed-test: inProgressShowsAllItemsWhenRefreshFails
Rationale: the test claimed "refresh failure with connectivity up = items unfiltered". That
was correct when refreshForSource could return false for 403/parse errors. Now it returns
false only for network unreachability, so items ARE unplayable and filtering IS correct.
The test is replaced by inProgressFiltersToOfflineAvailableWhenNetworkUnreachable and
inProgressSwitchesToOfflinePathViaFailedSourcesEvenWhenConnectivityObserverStuck.

iOS: IosToReadRepositoryImpl gets the same Offline-only return-false fix; new
IosToReadRepositoryImplTest pins all four cases (success, offline, auth, serverError).
Both platforms share the RiffleViewModel commonTest which runs on iosSimulatorArm64Test.
…nterval to 5s

On Samsung One UI (Android 17) and some other OEMs, the OS keeps the activeNetwork handle
alive (non-null) far beyond 15s after going offline, AND silently drops the onLost callback.
The existing poll called emitReconciled(tracker.isOnline()), which reduces to:

  reconcileOnline(trackerStillTrue, activeNetwork != null)
  = true && true = true  (stuck online forever)

The fix: in the poll tick, call currentOnline() first. currentOnline() reads
getNetworkCapabilities(activeNetwork) and checks for NET_CAPABILITY_INTERNET. The OS removes
that capability from the handle when the network goes away, even when the handle itself
persists — so this detects offline reliably on Samsung without waiting for the handle to be
cleaned up. If currentOnline() is false, the poll clears the tracker and emits offline
regardless of what reconcileOnline would have returned.

Also reduce POLL_INTERVAL_MS from 15s to 5s so offline is detected within 5s on AOSP
devices (where activeNetwork does become null but may take a few seconds).

Tests: ConnectivityReconcileTest gains a second "Samsung" scenario test that pins the
tracker-after-clear state the poll produces when currentOnline() returns false.
Tailscale (and other VPN apps) keep their tun interface alive in
airplane mode — the kernel-level tunnel stays up with identical
capabilities, link addresses, and routes whether or not the VPN can
actually relay traffic. Because the VPN was included in
ValidatedNetworkTracker, Samsung's dropped onLost for that handle
permanently masked the tracker's offline state.

Physical networks (WiFi, cellular) correctly fire onLost in airplane
mode; Samsung's onLost-drop only affects the VPN handle. Excluding
VPN networks (TRANSPORT_VPN) from the tracker means a physical onLost
immediately empties it → isOnline flips to false without any poll delay.

currentOnline() is also updated to iterate allNetworks instead of
checking only activeNetwork: when Tailscale is the activeNetwork its
capabilities look online, but iterating all networks finds no
qualifying physical network and correctly returns false.

Tests added: VPN network does not qualify (load-bearing regression
pin), VPN-airplane-mode end-to-end via tracker, updated all
isQualifyingNetwork call sites with the new isVpn parameter.
… appears online

Secondary defence for the "connectivity observer stuck at true but
server unreachable" class of bugs — covers LAN-only servers on
cellular, captive portals, and any OEM variant where onLost is dropped
for physical networks and currentOnline() also stays true.

A probe loop runs every 15 s while isOnline=true and _failedSourceIds
is empty. It calls refreshForSource for every known source-library pair;
on failure it populates _failedSourceIds → isOffline flips to true and
the retry loop takes over.

Test: isOfflineDetectedByProbeWhenConnectivityObserverIsStuck — initial
refresh succeeds, server then becomes unreachable, probe detects it
within CONNECTIVITY_PROBE_INTERVAL_MS.
…ource list emissions

Room Flows can re-emit when any column in a watched table changes, even if
the actual query results (the set of configured sources) are unchanged.
`_failedSourceIds.value = emptySet()` before the refresh loop cleared ALL
source failures on every such re-emission, causing a brief "online" flicker
before the re-refresh re-detected the failure. Add `distinctUntilChanged()`
to filter duplicate source lists before they reach `collectLatest`.

Removed-test: foreground poll rescues stuck-online tracker on Android 13 dropped onLost
AnnotationTypeConstantTest checks only pure Kotlin string constants (no iOS
code paths). Its module depends on androidx.room.runtime in commonMain, which
causes the Kotlin/Native linker to pull in Room's full KMP runtime when
building the iosSimulatorArm64 test binary — a binary large enough to OOM
the CI runner and hang the link step for 36+ minutes before being cancelled.

The JVM test (run in the Android Unit Tests job via jvmTest) already covers
these constants for both platforms — the same commonMain source runs on iOS,
so a constant rename caught by the JVM assertion is caught globally. The iOS
link step adds zero new coverage and costs the entire iOS Unit Tests job.
iOS: K/N link steps for 26 modules ran in parallel on a 7 GB macos-15 runner.
Each link spawns a JVM daemon with kotlin.native.jvmArgs=-Xmx6g; two or more
concurrent linkers OOM the runner and cause link hangs that exhaust the 50-minute
job cap. Add --max-workers 2 to the iOS commonTest step so at most 2 K/N link
processes run at a time.

Android: the Unit Tests job has a 12-minute cap. Adding 11 new tests in this PR
(RiffleViewModelTest, ConnectivityReconcileTest additions, QualifyingNetworkTest
additions, ToReadRepositoryTest) pushed test-source compilation + execution past
the cap on every run. Bump to 15 minutes.
…ewModelTest

The probe loop (while(true) { delay(15000); probeAllSources() }) causes
advanceUntilIdle() to spin forever: each probe invocation schedules
another 15-second delay, which advanceUntilIdle() advances through,
scheduling another, etc.

Fixed 13 tests that used advanceUntilIdle() in a "connected + healthy"
terminal state by replacing with advanceTimeBy(1) — enough to drain all
t=0 initial tasks without reaching the 15-second probe delay — and
adding vm.viewModelScope.cancel() to stop the probe at test end.

Also add feature/*/build/reports/tests/ to the CI artifact upload path
so feature module test failures are visible in the run artifacts.
Running :shared:linkDebugFrameworkIosSimulatorArm64 in a separate step
after the commonTest Gradle step forced it to execute serially — adding
20-30 minutes to the iOS Unit Tests job whenever feature:library source
changes invalidate the shared framework cache.

Including it in the same ./gradlew --max-workers 2 invocation lets Gradle
schedule the link concurrently with the (typically cached) iosSimulatorArm64Test
tasks, keeping total runtime well under the 50-minute job timeout.
Two harness tests failed on a slow CI runner despite the same code passing
on the concurrent Android workflow runner:

NavigationSnapHarnessTest.manualPageFlips: already @ignore'd but was using
the fully qualified @org.junit.Ignore form. Added explicit import so the
annotation is unambiguously resolved by the test runner.

AnnotationFocusHarnessTest.paginatedMode_bookmarkTap: waitForWebViewScrollQuiet
Phase 1 (wait for scroll to START) had a 4-second budget. On slow emulators
Readium's navigation callback fires more than 4 seconds after the bookmark click,
causing Phase 1 to time out and declare a false-stable position at the pre-jump
scroll offset. Doubled the timeout to 16 seconds.
When the GitHub Actions Gradle cache is evicted between CI runs, the
combined iosSimulatorArm64Test + shared framework link step rebuilds all
28 K/N tasks at --max-workers 2, taking up to ~45 min. XCTest then needs
another ~10-15 min. The previous 50-minute ceiling was too tight for this
cold-cache scenario (warm-cache runs finish in ~15 min total).
The job timed out at 65m36s on a fully-cold cache run. Cold-cache K/N
compilation of 28 tasks at --max-workers 2 takes 60–70 min; XCTest adds
~15 min on top, pushing the worst case to ~80 min. Raising the budget to
90 min provides a 10-min buffer while warm-cache runs still finish in
~15 min.
…nal ios.yml

The --max-workers 2 flag serialized K/N link steps, turning a fast
parallel build into a sequential one that exceeds the 50-minute timeout
on cold-cache runs. Other PRs pass with the original configuration;
restoring it to match origin/main.

The combined-link step change (0bf5e8f) and associated timeout bumps
(a6f89c1, 03e5c29) are reverted along with this.
…open timeout

iOS Unit Tests: without --max-workers 2, Gradle's default worker count
(≈ CPU count) launches too many concurrent K/N link steps on the 7 GB
macos-15 runner, exhausting RAM and causing OOM hangs that kill the
50-minute job cap. Restore the cap without the combined-link step that
made cold-cache runs take 65+ min.

ProgressPipelineTests: testAudiobookPositionRestoredOnReopen timed out
at 200 s on a contended runner (the audiobook player position-restore
seek exceeded the 150 s openReader threshold). Observed 29 s on the
immediate retry, confirming this is runner resource pressure rather than
a code regression. Raise the threshold to 300 s to absorb worst-case
contention without changing the assertion.
… assertions

Restore all four files to origin/main:
- .github/workflows/android.yml: 15m → 12m timeout, remove feature test artifact path
- .github/workflows/ios.yml: remove --max-workers 2, restore original comment
- AnnotationFocusHarnessTest.kt: restore assertLandedOnColumnGrid call+method, default timeout
- ProgressPipelineTests.swift: 300 → 150 openReader timeout
@pkmetski
pkmetski force-pushed the pkmetski/riffle-source-offline-filter-fix branch from e31ea31 to d38cef8 Compare September 30, 2026 05:09
@pkmetski
pkmetski enabled auto-merge (squash) September 30, 2026 05:16
…mposeUiTest hang

RiffleScreenReadRouteTest uses runComposeUiTest with UnconfinedTestDispatcher as Main.
The probe/retry while-loops were launched on viewModelScope (inheriting Main), so
runComposeUiTest's awaitIdle() advanced their delays indefinitely, hanging the
:shared:iosSimulatorArm64Test task for 29+ minutes.

Introduce a probeDispatcher parameter (default Dispatchers.Default) and use it for
both polling launch calls. In RiffleViewModelTest.makeViewModel(), pass probeDispatcher =
dispatcher (the StandardTestDispatcher) so advanceTimeBy() still fires the probe in
those tests. In RiffleScreenReadRouteTest the default Dispatchers.Default applies, which
runComposeUiTest's awaitIdle() never advances — ending the infinite loop.
@pkmetski
pkmetski merged commit 6e70416 into main Sep 30, 2026
13 checks passed
@pkmetski
pkmetski deleted the pkmetski/riffle-source-offline-filter-fix branch September 30, 2026 07:58
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.

1 participant