feat: implement global Force Offline Mode - #1765
Conversation
📝 WalkthroughWalkthroughAdds a persisted “Force Offline” setting that drives network and view-model state, updates settings and lifecycle behavior, suppresses connection activity, skips update checks, and stops Steam, Epic, GOG, and Amazon services when enabled. ChangesForce Offline Mode
Estimated code review effort: 3 (Moderate) | ~20 minutes Sequence Diagram(s)sequenceDiagram
participant SettingsGroupInterface
participant PrefManager
participant NetworkMonitor
participant PluviaMain
participant PlatformServices
SettingsGroupInterface->>PrefManager: save forceOffline
SettingsGroupInterface->>NetworkMonitor: update connectivity state
NetworkMonitor-->>NetworkMonitor: report no internet
PluviaMain->>PrefManager: read forceOffline
PrefManager-->>PluviaMain: return preference
PluviaMain->>PlatformServices: skip startup and reconnection
PlatformServices-->>PlatformServices: stop service with START_NOT_STICKY
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 5
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
app/src/main/java/app/gamenative/ui/PluviaMain.kt (1)
363-377: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winApply forced-offline state to every game-launch path.
The new check only covers
launchIntentApp. HomeScreen callbacks at Lines 1403, 1412, and 1440 still pass the route’sisOfflinedirectly topreLaunchApp; if the route isoffline=false, enabling Force Offline does not prevent online prelaunch/cloud-sync work. Pass one effective value such asPrefManager.forceOffline || isOfflinethrough all launch callbacks.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/src/main/java/app/gamenative/ui/PluviaMain.kt` around lines 363 - 377, Update the HomeScreen launch callbacks around the preLaunchApp calls to pass an effective offline value that combines PrefManager.forceOffline with each route’s isOffline value. Apply this to all three callbacks near the existing launch paths, preserving true whenever either forced-offline mode or the route already indicates offline operation.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In
@.artifacts/52d073f8-a39e-4a31-878e-015eb94da79a/implementation_plan.artifact.md:
- Around line 7-10: Keep the blockquote in the implementation plan contiguous by
removing the blank line between the IMPORTANT admonition content and the
following NOTE admonition, or prefixing that line with “>”. Ensure the
admonition formatting no longer triggers MD028.
In `@app/src/main/java/app/gamenative/NetworkMonitor.kt`:
- Around line 71-96: Serialize the entire NetworkMonitor.update recomputation,
including the forceOffline check and both _hasInternet/_hasWifiOrEthernet
assignments, using one shared lock or equivalent synchronization. Ensure
concurrent network callbacks and settings-triggered updates cannot interleave
and allow a stale update to overwrite the forced-offline state.
In `@app/src/main/java/app/gamenative/PrefManager.kt`:
- Around line 553-558: Propagate committed Force Offline preference transitions
across all affected sites: in
app/src/main/java/app/gamenative/PrefManager.kt:553-558, expose an observable or
suspending update from forceOffline; in
app/src/main/java/app/gamenative/ui/PluviaMain.kt:677-680, collect transitions
within repeatOnLifecycle instead of returning after the initial check; in
app/src/main/java/app/gamenative/service/SteamService.kt:3442-3443, disconnect
and cancel reconnect work when enabled; and in
app/src/main/java/app/gamenative/service/amazon/AmazonService.kt:792-798,
app/src/main/java/app/gamenative/service/epic/EpicService.kt:644-650, and
app/src/main/java/app/gamenative/service/gog/GOGService.kt:730-736, cancel
active sync or download work when enabled while preserving startup handling and
allowing services to restart when disabled.
In `@app/src/main/java/app/gamenative/service/SteamService.kt`:
- Around line 3442-3448: Process the `ACTION_EXIT` intent before the
`PrefManager.forceOffline` guard in `SteamService`, ensuring it emits
`AndroidEvent.EndProcess` before stopping the service. Keep the existing offline
behavior for other intents unchanged, and preserve the later `when
(intent?.action)` handling without allowing `ACTION_EXIT` to be skipped.
In `@app/src/main/java/app/gamenative/ui/model/MainViewModel.kt`:
- Around line 87-91: Update the offline-state flow around
MainViewModel.setOffline and PrefManager.forceOffline so changes to Force
Offline Mode propagate to the active ViewModel, not just during initial
construction. Make the preference observable or explicitly notify the ViewModel
whenever the setting changes, ensuring isOffline remains true while
force-offline is enabled and handleExitCloudSync cannot upload saves based on
stale state.
---
Outside diff comments:
In `@app/src/main/java/app/gamenative/ui/PluviaMain.kt`:
- Around line 363-377: Update the HomeScreen launch callbacks around the
preLaunchApp calls to pass an effective offline value that combines
PrefManager.forceOffline with each route’s isOffline value. Apply this to all
three callbacks near the existing launch paths, preserving true whenever either
forced-offline mode or the route already indicates offline operation.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 1fe20e54-1509-4ba4-a094-b50dac8be53f
📒 Files selected for processing (15)
.artifacts/52d073f8-a39e-4a31-878e-015eb94da79a/implementation_plan.artifact.md.artifacts/52d073f8-a39e-4a31-878e-015eb94da79a/pull_request_description.artifact.md.artifacts/52d073f8-a39e-4a31-878e-015eb94da79a/task.artifact.md.artifacts/52d073f8-a39e-4a31-878e-015eb94da79a/walkthrough.artifact.mdapp/src/main/java/app/gamenative/NetworkMonitor.ktapp/src/main/java/app/gamenative/PrefManager.ktapp/src/main/java/app/gamenative/service/SteamService.ktapp/src/main/java/app/gamenative/service/amazon/AmazonService.ktapp/src/main/java/app/gamenative/service/epic/EpicService.ktapp/src/main/java/app/gamenative/service/gog/GOGService.ktapp/src/main/java/app/gamenative/ui/PluviaMain.ktapp/src/main/java/app/gamenative/ui/model/MainViewModel.ktapp/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupInterface.ktapp/src/main/res/values-es/strings.xmlapp/src/main/res/values/strings.xml
| > [!IMPORTANT] | ||
| > **Global Override:** When "Force Offline" is enabled, the app will report no internet connection to all internal components. This will prevent library syncing, workshop downloads, and cloud save uploads/downloads until disabled. | ||
|
|
||
| > [!NOTE] |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Keep the admonition blockquote contiguous.
The blank line at Line 9 triggers MD028. Prefix it with > or close the blockquote before starting the next admonition.
🧰 Tools
🪛 markdownlint-cli2 (0.23.0)
[warning] 9-9: Blank line inside blockquote
(MD028, no-blanks-blockquote)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
@.artifacts/52d073f8-a39e-4a31-878e-015eb94da79a/implementation_plan.artifact.md
around lines 7 - 10, Keep the blockquote in the implementation plan contiguous
by removing the blank line between the IMPORTANT admonition content and the
following NOTE admonition, or prefixing that line with “>”. Ensure the
admonition formatting no longer triggers MD028.
Source: Linters/SAST tools
| fun update() { | ||
| if (PrefManager.forceOffline) { | ||
| _hasInternet.value = false | ||
| _hasWifiOrEthernet.value = false | ||
| return | ||
| } | ||
|
|
||
| val validatedCaps = networkCaps.values.filter { | ||
| it.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) | ||
| } | ||
| val nonVpnCaps = validatedCaps.filter { !hasVpn(it) } | ||
| val nonVpnExists = networkCaps.values.any { !hasVpn(it) } | ||
| // trust VPN for internet only if a non-VPN network physically exists | ||
| // (guards against stale VPN after underlying WiFi drops; | ||
| // allows VPN in censorship scenarios where WiFi exists but isn't validated) | ||
| val vpnValidated = validatedCaps.any { hasVpn(it) } | ||
| _hasInternet.value = nonVpnCaps.isNotEmpty() || (vpnValidated && nonVpnExists) | ||
| // WiFi/Ethernet transport: only trust non-VPN networks (VPN reports stale transports). | ||
| // known edge case: censored WiFi + VPN → WiFi not validated → hasWifiOrEthernet=false, | ||
| // so "WiFi only" blocks downloads. user must disable "WiFi only" to download via VPN. | ||
| // fixing this would risk treating always-on VPN without real WiFi as valid. | ||
| _hasWifiOrEthernet.value = nonVpnCaps.any { | ||
| it.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) || | ||
| it.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) | ||
| } | ||
| } |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Serialize network-state recomputation.
A callback that read forceOffline=false can finish after the settings-triggered call has set both flows to false, overwriting them back to online. This can leave forced offline ineffective until another network event arrives. Guard the full recomputation with one lock (or otherwise serialize updates).
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@app/src/main/java/app/gamenative/NetworkMonitor.kt` around lines 71 - 96,
Serialize the entire NetworkMonitor.update recomputation, including the
forceOffline check and both _hasInternet/_hasWifiOrEthernet assignments, using
one shared lock or equivalent synchronization. Ensure concurrent network
callbacks and settings-triggered updates cannot interleave and allow a stale
update to overwrite the forced-offline state.
| private val FORCE_OFFLINE = booleanPreferencesKey("force_offline") | ||
| var forceOffline: Boolean | ||
| get() = getPref(FORCE_OFFLINE, false) | ||
| set(value) { | ||
| setPref(FORCE_OFFLINE, value) | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift
Propagate Force Offline transitions, not just startup checks.
The preference write is asynchronous and the lifecycle/service checks only run during startup. Enabling Force Offline while services are active therefore does not stop downloads, sync jobs, or Steam reconnects; disabling it while the app remains STARTED does not restart services.
app/src/main/java/app/gamenative/PrefManager.kt#L553-L558: expose a committed observable/suspending preference update.app/src/main/java/app/gamenative/ui/PluviaMain.kt#L677-L680: collect preference transitions instead of returning once fromrepeatOnLifecycle.app/src/main/java/app/gamenative/service/SteamService.kt#L3442-L3443: disconnect and cancel reconnect work when the setting becomes true.app/src/main/java/app/gamenative/service/amazon/AmazonService.kt#L792-L798: cancel active sync/download work on transition.app/src/main/java/app/gamenative/service/epic/EpicService.kt#L644-L650: cancel active sync work on transition.app/src/main/java/app/gamenative/service/gog/GOGService.kt#L730-L736: cancel active sync work on transition.
📍 Affects 6 files
app/src/main/java/app/gamenative/PrefManager.kt#L553-L558(this comment)app/src/main/java/app/gamenative/ui/PluviaMain.kt#L677-L680app/src/main/java/app/gamenative/service/SteamService.kt#L3442-L3443app/src/main/java/app/gamenative/service/amazon/AmazonService.kt#L792-L798app/src/main/java/app/gamenative/service/epic/EpicService.kt#L644-L650app/src/main/java/app/gamenative/service/gog/GOGService.kt#L730-L736
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@app/src/main/java/app/gamenative/PrefManager.kt` around lines 553 - 558,
Propagate committed Force Offline preference transitions across all affected
sites: in app/src/main/java/app/gamenative/PrefManager.kt:553-558, expose an
observable or suspending update from forceOffline; in
app/src/main/java/app/gamenative/ui/PluviaMain.kt:677-680, collect transitions
within repeatOnLifecycle instead of returning after the initial check; in
app/src/main/java/app/gamenative/service/SteamService.kt:3442-3443, disconnect
and cancel reconnect work when enabled; and in
app/src/main/java/app/gamenative/service/amazon/AmazonService.kt:792-798,
app/src/main/java/app/gamenative/service/epic/EpicService.kt:644-650, and
app/src/main/java/app/gamenative/service/gog/GOGService.kt:730-736, cancel
active sync or download work when enabled while preserving startup handling and
allowing services to restart when disabled.
| if (PrefManager.forceOffline) { | ||
| Timber.i("[SteamService]: Force Offline Mode active - stopping service") | ||
| val notification = notificationHelper.createServiceNotification(NotificationHelper.NOTIFICATION_ID_STEAM, "Stopping...") | ||
| startForeground(NotificationHelper.NOTIFICATION_ID_STEAM, notification) | ||
| stopSelf() | ||
| return START_NOT_STICKY | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Handle ACTION_EXIT before the offline guard.
This return runs before the when (intent?.action) block at Lines 3460–3468, so tapping an existing service notification’s Exit action while Force Offline is enabled only stops SteamService; it never emits AndroidEvent.EndProcess.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@app/src/main/java/app/gamenative/service/SteamService.kt` around lines 3442 -
3448, Process the `ACTION_EXIT` intent before the `PrefManager.forceOffline`
guard in `SteamService`, ensuring it emits `AndroidEvent.EndProcess` before
stopping the service. Keep the existing offline behavior for other intents
unchanged, and preserve the later `when (intent?.action)` handling without
allowing `ACTION_EXIT` to be skipped.
| private val _offline = MutableStateFlow(PrefManager.forceOffline) | ||
| val isOffline: StateFlow<Boolean> get() = _offline | ||
|
|
||
| fun setOffline(value: Boolean) { | ||
| _offline.value = value | ||
| _offline.value = value || PrefManager.forceOffline |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
Propagate force-offline changes into the active ViewModel.
This is only an initial snapshot; toggling the setting later does not update _offline. An already-running game can therefore reach handleExitCloudSync() with isOffline=false and upload saves after Force Offline Mode was enabled. Make the preference observable or explicitly notify the ViewModel when the switch changes.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@app/src/main/java/app/gamenative/ui/model/MainViewModel.kt` around lines 87 -
91, Update the offline-state flow around MainViewModel.setOffline and
PrefManager.forceOffline so changes to Force Offline Mode propagate to the
active ViewModel, not just during initial construction. Make the preference
observable or explicitly notify the ViewModel whenever the setting changes,
ensuring isOffline remains true while force-offline is enabled and
handleExitCloudSync cannot upload saves based on stale state.
There was a problem hiding this comment.
13 issues found across 15 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="app/src/main/java/app/gamenative/service/epic/EpicService.kt">
<violation number="1" location="app/src/main/java/app/gamenative/service/epic/EpicService.kt:646">
P3: Hardcoded notification string "Stopping..." should be moved to strings.xml for localization support. The team prefers all user-facing text to go into resource files.</violation>
</file>
<file name="app/src/main/java/app/gamenative/service/SteamService.kt">
<violation number="1" location="app/src/main/java/app/gamenative/service/SteamService.kt:3444">
P2: User-facing notification text `"Stopping..."` is hardcoded inline. For localization and maintainability, define it as a string resource in `strings.xml` and reference it via `getString(R.string.steam_service_stopping)` — consistent with how other notification messages in this file use `getString(R.string.download_paused_wifi)`.</violation>
</file>
<file name="app/src/main/java/app/gamenative/service/amazon/AmazonService.kt">
<violation number="1" location="app/src/main/java/app/gamenative/service/amazon/AmazonService.kt:794">
P2: "Stopping..." notification text is hardcoded instead of using a string resource. Move it to strings.xml and reference it via getString(R.string. ...) for localization support.
(Based on your team's feedback about hardcoded UI strings.) [FEEDBACK_USED]</violation>
</file>
<file name="app/src/main/java/app/gamenative/ui/model/MainViewModel.kt">
<violation number="1" location="app/src/main/java/app/gamenative/ui/model/MainViewModel.kt:87">
P1: Read-once snapshot of PrefManager.forceOffline at init creates a stale-state window: if the user toggles Force Offline in Settings while MainViewModel is alive (same-activity navigation), _offline won't reactively update. Consider wrapping the preference in a Flow (e.g., dataStore.data.map) and collecting it in init { } so _offline stays in sync whenever the preference changes.</violation>
</file>
<file name="app/src/main/java/app/gamenative/ui/PluviaMain.kt">
<violation number="1" location="app/src/main/java/app/gamenative/ui/PluviaMain.kt:677">
P1: Toggling Force Offline OFF while in the foreground won't restart platform services until the next lifecycle transition (e.g., background → foreground). `repeatOnLifecycle` only re-runs the block when the lifecycle state enters STARTED — it doesn't react to `PrefManager.forceOffline` changes. Since `LaunchedEffect(Unit)` never restarts either, the user has to background/foreground the app to restore service connections after disabling offline mode. This contradicts the PR's goal of clean restoration.</violation>
<violation number="2" location="app/src/main/java/app/gamenative/ui/PluviaMain.kt:1286">
P2: An existing “Connecting to Steam…” banner can remain on screen after Force Offline Mode is enabled because this direct preference read is not observable by Compose. The banner condition would need to consume a reactive force-offline state (or an explicitly emitted UI event) so the visibility is recomputed immediately.</violation>
</file>
<file name="app/src/main/java/app/gamenative/service/gog/GOGService.kt">
<violation number="1" location="app/src/main/java/app/gamenative/service/gog/GOGService.kt:732">
P3: `</violation>
</file>
<file name="app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupInterface.kt">
<violation number="1" location="app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupInterface.kt:361">
P2: Changing the switch does not reliably propagate the new offline state immediately because the preference write is asynchronous but `NetworkMonitor.update()` reads the preference synchronously on the following line. The network state can remain stale after either direction of the toggle; passing the new value directly to the monitor or maintaining a synchronous in-memory override would make the transition atomic.</violation>
<violation number="2" location="app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupInterface.kt:362">
P2: The toggle triggers a blocking DataStore read on the main thread. `NetworkMonitor.update()` reads back `PrefManager.forceOffline` via `getPref()`, which runs `runBlocking { dataStore.data.first() }` — blocking the main thread on each toggle. The MainViewModel's `_offline` flow (initialized once at creation) also won't reflect this toggle change, creating a stale state consumers of `MainViewModel.isOffline` won't see. Consider passing the known value directly to avoid the blocking read (`NetworkMonitor.onForceOfflineChanged(newValue)`) and connecting this toggle to MainViewModel's offline flow for consistency.</violation>
</file>
<file name=".artifacts/52d073f8-a39e-4a31-878e-015eb94da79a/implementation_plan.artifact.md">
<violation number="1" location=".artifacts/52d073f8-a39e-4a31-878e-015eb94da79a/implementation_plan.artifact.md:17">
P3: The `file:///E:/workspace/StudioProjects/GameNative/...` absolute paths in the file references won't resolve for any other developer or on CI. Since these are just reference annotations in markdown, consider either removing the URI portion entirely (the descriptive text in brackets already identifies each file) or using a relative path from the repo root. This keeps the plan portable and avoids confusion when someone follows a dead link.</violation>
</file>
<file name=".artifacts/52d073f8-a39e-4a31-878e-015eb94da79a/walkthrough.artifact.md">
<violation number="1" location=".artifacts/52d073f8-a39e-4a31-878e-015eb94da79a/walkthrough.artifact.md:21">
P2: The companion `start()` methods for EpicService, GOGService, and AmazonService call `startForegroundService()` before the `onStartCommand` guard fires. This causes a "Stopping..." notification to briefly flash when force offline is active. Add `if (PrefManager.forceOffline) return` at the top of each `start()` method so the service is never started and no notification is shown.</violation>
</file>
<file name="app/src/main/res/values/strings.xml">
<violation number="1" location="app/src/main/res/values/strings.xml:1088">
P3: The setting subtitle says it disables all network connections, but this feature only forces the app's observed connectivity state and suppresses selected services/alerts; other network requests can still be made. A less misleading description such as “Make the app behave as offline and suppress connection alerts” would match the actual behavior in both locales.</violation>
</file>
<file name="app/src/main/java/app/gamenative/NetworkMonitor.kt">
<violation number="1" location="app/src/main/java/app/gamenative/NetworkMonitor.kt:71">
P2: Race condition: `update()` reads `PrefManager.forceOffline` and then writes to the state flows without any synchronization. A network callback that entered `update()` before the toggle was set can finish after the settings-triggered `update()` call, overwriting `_hasInternet` back to `true`. Consider serializing state recomputation (e.g., with a `Mutex` or by confining updates to a single-threaded dispatcher) so that a concurrent network callback cannot clobber the forced-offline state.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| val uiEvent = _uiEvent.receiveAsFlow() | ||
|
|
||
| private val _offline = MutableStateFlow(false) | ||
| private val _offline = MutableStateFlow(PrefManager.forceOffline) |
There was a problem hiding this comment.
P1: Read-once snapshot of PrefManager.forceOffline at init creates a stale-state window: if the user toggles Force Offline in Settings while MainViewModel is alive (same-activity navigation), _offline won't reactively update. Consider wrapping the preference in a Flow (e.g., dataStore.data.map) and collecting it in init { } so _offline stays in sync whenever the preference changes.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/java/app/gamenative/ui/model/MainViewModel.kt, line 87:
<comment>Read-once snapshot of PrefManager.forceOffline at init creates a stale-state window: if the user toggles Force Offline in Settings while MainViewModel is alive (same-activity navigation), _offline won't reactively update. Consider wrapping the preference in a Flow (e.g., dataStore.data.map) and collecting it in init { } so _offline stays in sync whenever the preference changes.</comment>
<file context>
@@ -84,11 +84,11 @@ class MainViewModel @Inject constructor(
val uiEvent = _uiEvent.receiveAsFlow()
- private val _offline = MutableStateFlow(false)
+ private val _offline = MutableStateFlow(PrefManager.forceOffline)
val isOffline: StateFlow<Boolean> get() = _offline
</file context>
|
|
||
| LaunchedEffect(Unit) { | ||
| lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { | ||
| if (PrefManager.forceOffline) { |
There was a problem hiding this comment.
P1: Toggling Force Offline OFF while in the foreground won't restart platform services until the next lifecycle transition (e.g., background → foreground). repeatOnLifecycle only re-runs the block when the lifecycle state enters STARTED — it doesn't react to PrefManager.forceOffline changes. Since LaunchedEffect(Unit) never restarts either, the user has to background/foreground the app to restore service connections after disabling offline mode. This contradicts the PR's goal of clean restoration.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/java/app/gamenative/ui/PluviaMain.kt, line 677:
<comment>Toggling Force Offline OFF while in the foreground won't restart platform services until the next lifecycle transition (e.g., background → foreground). `repeatOnLifecycle` only re-runs the block when the lifecycle state enters STARTED — it doesn't react to `PrefManager.forceOffline` changes. Since `LaunchedEffect(Unit)` never restarts either, the user has to background/foreground the app to restore service connections after disabling offline mode. This contradicts the PR's goal of clean restoration.</comment>
<file context>
@@ -673,6 +674,11 @@ fun PluviaMain(
LaunchedEffect(Unit) {
lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
+ if (PrefManager.forceOffline) {
+ Timber.i("[PluviaMain]: Force Offline Mode active - skipping platform service startup")
+ return@repeatOnLifecycle
</file context>
| override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { | ||
| if (PrefManager.forceOffline) { | ||
| Timber.i("[SteamService]: Force Offline Mode active - stopping service") | ||
| val notification = notificationHelper.createServiceNotification(NotificationHelper.NOTIFICATION_ID_STEAM, "Stopping...") |
There was a problem hiding this comment.
P2: User-facing notification text "Stopping..." is hardcoded inline. For localization and maintainability, define it as a string resource in strings.xml and reference it via getString(R.string.steam_service_stopping) — consistent with how other notification messages in this file use getString(R.string.download_paused_wifi).
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/java/app/gamenative/service/SteamService.kt, line 3444:
<comment>User-facing notification text `"Stopping..."` is hardcoded inline. For localization and maintainability, define it as a string resource in `strings.xml` and reference it via `getString(R.string.steam_service_stopping)` — consistent with how other notification messages in this file use `getString(R.string.download_paused_wifi)`.</comment>
<file context>
@@ -3439,6 +3439,13 @@ class SteamService : Service(), IChallengeUrlChanged {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ if (PrefManager.forceOffline) {
+ Timber.i("[SteamService]: Force Offline Mode active - stopping service")
+ val notification = notificationHelper.createServiceNotification(NotificationHelper.NOTIFICATION_ID_STEAM, "Stopping...")
+ startForeground(NotificationHelper.NOTIFICATION_ID_STEAM, notification)
+ stopSelf()
</file context>
| override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { | ||
| if (PrefManager.forceOffline) { | ||
| Timber.i("[AmazonService] Force Offline Mode active - stopping service") | ||
| val notification = notificationHelper.createServiceNotification(NotificationHelper.NOTIFICATION_ID_AMAZON, "Stopping...") |
There was a problem hiding this comment.
P2: "Stopping..." notification text is hardcoded instead of using a string resource. Move it to strings.xml and reference it via getString(R.string. ...) for localization support.
(Based on your team's feedback about hardcoded UI strings.)
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/java/app/gamenative/service/amazon/AmazonService.kt, line 794:
<comment>"Stopping..." notification text is hardcoded instead of using a string resource. Move it to strings.xml and reference it via getString(R.string. ...) for localization support.
(Based on your team's feedback about hardcoded UI strings.) </comment>
<file context>
@@ -788,6 +789,13 @@ class AmazonService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ if (PrefManager.forceOffline) {
+ Timber.i("[AmazonService] Force Offline Mode active - stopping service")
+ val notification = notificationHelper.createServiceNotification(NotificationHelper.NOTIFICATION_ID_AMAZON, "Stopping...")
+ startForeground(NotificationHelper.NOTIFICATION_ID_AMAZON, notification)
+ stopSelf()
</file context>
| onCheckedChange = { newValue -> | ||
| forceOffline = newValue | ||
| PrefManager.forceOffline = newValue | ||
| NetworkMonitor.update() |
There was a problem hiding this comment.
P2: The toggle triggers a blocking DataStore read on the main thread. NetworkMonitor.update() reads back PrefManager.forceOffline via getPref(), which runs runBlocking { dataStore.data.first() } — blocking the main thread on each toggle. The MainViewModel's _offline flow (initialized once at creation) also won't reflect this toggle change, creating a stale state consumers of MainViewModel.isOffline won't see. Consider passing the known value directly to avoid the blocking read (NetworkMonitor.onForceOfflineChanged(newValue)) and connecting this toggle to MainViewModel's offline flow for consistency.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupInterface.kt, line 362:
<comment>The toggle triggers a blocking DataStore read on the main thread. `NetworkMonitor.update()` reads back `PrefManager.forceOffline` via `getPref()`, which runs `runBlocking { dataStore.data.first() }` — blocking the main thread on each toggle. The MainViewModel's `_offline` flow (initialized once at creation) also won't reflect this toggle change, creating a stale state consumers of `MainViewModel.isOffline` won't see. Consider passing the known value directly to avoid the blocking read (`NetworkMonitor.onForceOfflineChanged(newValue)`) and connecting this toggle to MainViewModel's offline flow for consistency.</comment>
<file context>
@@ -349,6 +350,19 @@ fun SettingsGroupInterface(
+ onCheckedChange = { newValue ->
+ forceOffline = newValue
+ PrefManager.forceOffline = newValue
+ NetworkMonitor.update()
+ },
+ )
</file context>
| private fun hasVpn(caps: NetworkCapabilities) = | ||
| caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN) | ||
|
|
||
| fun update() { |
There was a problem hiding this comment.
P2: Race condition: update() reads PrefManager.forceOffline and then writes to the state flows without any synchronization. A network callback that entered update() before the toggle was set can finish after the settings-triggered update() call, overwriting _hasInternet back to true. Consider serializing state recomputation (e.g., with a Mutex or by confining updates to a single-threaded dispatcher) so that a concurrent network callback cannot clobber the forced-offline state.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/java/app/gamenative/NetworkMonitor.kt, line 71:
<comment>Race condition: `update()` reads `PrefManager.forceOffline` and then writes to the state flows without any synchronization. A network callback that entered `update()` before the toggle was set can finish after the settings-triggered `update()` call, overwriting `_hasInternet` back to `true`. Consider serializing state recomputation (e.g., with a `Mutex` or by confining updates to a single-threaded dispatcher) so that a concurrent network callback cannot clobber the forced-offline state.</comment>
<file context>
@@ -88,4 +59,39 @@ object NetworkMonitor {
+ private fun hasVpn(caps: NetworkCapabilities) =
+ caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)
+
+ fun update() {
+ if (PrefManager.forceOffline) {
+ _hasInternet.value = false
</file context>
| override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { | ||
| if (PrefManager.forceOffline) { | ||
| Timber.tag("EPIC").i("Force Offline Mode active - stopping service") | ||
| val notification = notificationHelper.createServiceNotification(NotificationHelper.NOTIFICATION_ID_EPIC, "Stopping...") |
There was a problem hiding this comment.
P3: Hardcoded notification string "Stopping..." should be moved to strings.xml for localization support. The team prefers all user-facing text to go into resource files.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/java/app/gamenative/service/epic/EpicService.kt, line 646:
<comment>Hardcoded notification string "Stopping..." should be moved to strings.xml for localization support. The team prefers all user-facing text to go into resource files.</comment>
<file context>
@@ -640,6 +641,13 @@ class EpicService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ if (PrefManager.forceOffline) {
+ Timber.tag("EPIC").i("Force Offline Mode active - stopping service")
+ val notification = notificationHelper.createServiceNotification(NotificationHelper.NOTIFICATION_ID_EPIC, "Stopping...")
+ startForeground(NotificationHelper.NOTIFICATION_ID_EPIC, notification)
+ stopSelf()
</file context>
| override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { | ||
| if (PrefManager.forceOffline) { | ||
| Timber.i("[GOGService] Force Offline Mode active - stopping service") | ||
| val notification = notificationHelper.createServiceNotification(NotificationHelper.NOTIFICATION_ID_GOG, "Stopping...") |
There was a problem hiding this comment.
P3: `
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/java/app/gamenative/service/gog/GOGService.kt, line 732:
<comment>`</comment>
<file context>
@@ -726,6 +727,13 @@ class GOGService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ if (PrefManager.forceOffline) {
+ Timber.i("[GOGService] Force Offline Mode active - stopping service")
+ val notification = notificationHelper.createServiceNotification(NotificationHelper.NOTIFICATION_ID_GOG, "Stopping...")
+ startForeground(NotificationHelper.NOTIFICATION_ID_GOG, notification)
+ stopSelf()
</file context>
|
|
||
| ### Core Logic & State | ||
|
|
||
| #### [MODIFY] [PrefManager.kt](file:///E:/workspace/StudioProjects/GameNative/app/src/main/java/app/gamenative/PrefManager.kt) |
There was a problem hiding this comment.
P3: The file:///E:/workspace/StudioProjects/GameNative/... absolute paths in the file references won't resolve for any other developer or on CI. Since these are just reference annotations in markdown, consider either removing the URI portion entirely (the descriptive text in brackets already identifies each file) or using a relative path from the repo root. This keeps the plan portable and avoids confusion when someone follows a dead link.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At .artifacts/52d073f8-a39e-4a31-878e-015eb94da79a/implementation_plan.artifact.md, line 17:
<comment>The `file:///E:/workspace/StudioProjects/GameNative/...` absolute paths in the file references won't resolve for any other developer or on CI. Since these are just reference annotations in markdown, consider either removing the URI portion entirely (the descriptive text in brackets already identifies each file) or using a relative path from the repo root. This keeps the plan portable and avoids confusion when someone follows a dead link.</comment>
<file context>
@@ -0,0 +1,59 @@
+
+### Core Logic & State
+
+#### [MODIFY] [PrefManager.kt](file:///E:/workspace/StudioProjects/GameNative/app/src/main/java/app/gamenative/PrefManager.kt)
+- Add `forceOffline` boolean preference (default: `false`).
+
</file context>
| <string name="settings_interface_show_gamepad_hints_title">Show controller hints</string> | ||
| <string name="settings_interface_show_gamepad_hints_subtitle">Show the controller button hints bar at the bottom of the screen</string> | ||
| <string name="settings_interface_force_offline_title">Force Offline Mode</string> | ||
| <string name="settings_interface_force_offline_subtitle">Disable all network connections and suppress connection alerts</string> |
There was a problem hiding this comment.
P3: The setting subtitle says it disables all network connections, but this feature only forces the app's observed connectivity state and suppresses selected services/alerts; other network requests can still be made. A less misleading description such as “Make the app behave as offline and suppress connection alerts” would match the actual behavior in both locales.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/res/values/strings.xml, line 1088:
<comment>The setting subtitle says it disables all network connections, but this feature only forces the app's observed connectivity state and suppresses selected services/alerts; other network requests can still be made. A less misleading description such as “Make the app behave as offline and suppress connection alerts” would match the actual behavior in both locales.</comment>
<file context>
@@ -1084,6 +1084,8 @@
<string name="settings_interface_show_gamepad_hints_title">Show controller hints</string>
<string name="settings_interface_show_gamepad_hints_subtitle">Show the controller button hints bar at the bottom of the screen</string>
+ <string name="settings_interface_force_offline_title">Force Offline Mode</string>
+ <string name="settings_interface_force_offline_subtitle">Disable all network connections and suppress connection alerts</string>
<string name="settings_interface_icon_style">Icon style</string>
<string name="settings_interface_custom_games">Custom Games</string>
</file context>
Description
This PR introduces a global "Force Offline Mode" toggle in the Settings > Interface section. When enabled, the application acts as if it is completely disconnected from the internet, regardless of the system's actual network state.
Key Benefits:
SteamUtils.awaitSteamLogin) during game launches, allowing for immediate booting when offline.Technical Implementation:
NetworkMonitoroverride to forcehasInternettofalsewhen active, ensuring all observing UI components adapt.stopSelf) to platform services to catch accidental triggers.PrefManagerandMainViewModel.Recording
(User provided: Skipping recording/GIF as per intent)
Type of Change
Checklist
#code-changes, I have discussed this change there and it has been green-lighted. If I do not have access, I have still provided clear context in this PR. If I skip both, I accept that this change may face delays in review, may not be reviewed at all, or may be closed.CONTRIBUTING.md.Disclaimer: This pull request was prepared and implemented with the assistance of Claude AI (via browser) and Gemini 3 Flash Preview (via Android Studio AI Agent).
Summary by cubic
Adds a global Force Offline Mode that makes the app behave fully offline, regardless of real connectivity. It silences platform services, hides connection noise, and lets games launch instantly.
PrefManager.forceOffline.NetworkMonitorforces offline and pushes updates viaupdate().SteamService,EpicService,GOGService,AmazonServiceself-stop if invoked.PluviaMain.Written for commit ecc04b1. Summary will update on new commits.
Summary by CodeRabbit