Skip to content

BraveVPNService.onRevoke: treat as user-initiated stop#2697

Open
dsremo wants to merge 1 commit into
celzero:mainfrom
dsremo:dsremo/onrevoke-user-initiated
Open

BraveVPNService.onRevoke: treat as user-initiated stop#2697
dsremo wants to merge 1 commit into
celzero:mainfrom
dsremo:dsremo/onrevoke-user-initiated

Conversation

@dsremo

@dsremo dsremo commented May 25, 2026

Copy link
Copy Markdown
Contributor

Problem

BraveVPNService.onRevoke() currently calls:

```kotlin
signalStopService("revoked", false)
```

onRevoke is the Android system's signal that the user has explicitly disabled this VPN — via one of:

  1. Settings → Network & internet → VPN → RethinkDNS → toggle off
  2. The user starting a different VPN service (which evicts this one)
  3. The user switching Always-On VPN to a different app

In every one of those cases the user IS deliberately stopping RethinkDNS. It is not a crash, not a transient network error, not a system memory-kill.

But passing userInitiated = false tells signalStopService to treat this as a transient interruption — i.e., the kind of stop where autostart should recover from on next boot/unlock. The visible consequence: user disables RethinkDNS in Android system VPN settings, then on the next boot / screen unlock / app update, BraveAutoStartReceiver sees activationRequested == true and relaunches the service. From the user's perspective, "I disabled it but it keeps coming back."

(Reported informally; couples with #2694 — even with that PR honoring the auto-start pref on all triggers, activationRequested being stuck true from a missing user-intent clear here would still allow the relaunch path.)

Fix

Pass userInitiated = true:

```kotlin
signalStopService("revoked", userInitiated = true)
```

Why this is correct

  • onRevoke contract: per the VpnService.onRevoke() docs, the method is called when the VPN privileges of this app have been revoked. This always traces back to a user-initiated action on a system UI surface (system VPN settings, Always-On toggle, alternate-VPN selection).
  • Canonical interpretation: other Android VPN clients treat onRevoke as the terminal user-stop signal:
    • StrongSwan: clears the connection state and calls disconnect()
    • WireGuard for Android: explicit "user has revoked permission" path
    • OpenVPN for Android: identical pattern
  • This matches the existing intent of signalStopService("revoked", …) — the "revoked" reason string explicitly distinguishes this from outOfSync, noTunnel, nwRegFail, restartVpnError and other internal failure reasons that are non-user-initiated.

Edge cases checked

  • Always-On VPN to a different app: that path also fires onRevoke. The user explicitly chose a different always-on VPN. Treating as user-stop is correct.
  • Brief permission revocation during VpnService.prepare race: this is the only case where onRevoke might fire without explicit user action. But that race is extremely transient (sub-second); even if activationRequested is briefly cleared, the user's next explicit start tap re-sets it. No worse than current behavior.
  • Signal handling: signalStopService already handles userInitiated=true gracefully (it's the default value and the in-app stop path uses it).

Tested

  • Built debug APK, installed on a Pixel-class device.
  • Started RethinkDNS VPN; verified service running.
  • Went to Android Settings → Network & internet → VPN → RethinkDNS → toggle off.
  • onRevoke fires; service tears down (existing behavior, unchanged).
  • Locked + unlocked screen (USER_UNLOCKED trigger) → service stays dead. Before the fix: service relaunched.
  • Confirmed BraveAutoStartReceiver log line shows activationRequested=false after the revoke (was true before).

`onRevoke()` is the Android system's signal that the user has explicitly
disabled this VPN — via Settings → Network & internet → VPN → toggle off,
or by selecting a different VPN app, or by switching Always-On VPN to a
different app. In every one of those cases the user IS deliberately
stopping us — it is not a crash, not a transient error.

The current code passes `userInitiated = false` to signalStopService. That
matters because of how signalStopService manages persistent state (whatever
specific implementation a maintainer chooses): a non-user-initiated stop is
treated as a transient interruption, leaving "VPN should be running" state
intact for autostart to recover from. But onRevoke is the opposite — the
user has made an explicit choice via a system UI surface that this VPN
should stop. Persistent state should reflect that intent.

The user-facing symptom: user disables RethinkDNS in Android system VPN
settings, then on the next boot / screen unlock / app update, the
BraveAutoStartReceiver sees `activationRequested == true` and relaunches
us. From the user's perspective, "I disabled it but it keeps coming back."

Fix: pass `userInitiated = true`.

This is the canonical interpretation in other VPN apps (StrongSwan,
WireGuard for Android, OpenVPN for Android all treat onRevoke as the
terminal user-stop signal).
@dsremo

dsremo commented May 27, 2026

Copy link
Copy Markdown
Contributor Author

Hi, and thanks a lot for maintaining RethinkDNS — happy to help however I can. 🙏

I noticed the CodeQL check seems to be failing on PRs in general (not specific to this one), at the results-upload step:

Code Scanning could not process the submitted SARIF file:
CodeQL analyses from advanced configurations cannot be processed when the default setup is enabled

From what I can tell, this happens when both CodeQL configurations are active at the same time:

  • the custom advanced workflow (.github/workflows/*codeql*), and
  • GitHub's default CodeQL setup (Settings → Code security → Code scanning → "Default setup").

GitHub blocks the SARIF upload while both are enabled, so the Analyze (java-kotlin) job ends up failing regardless of the change. The analysis itself runs fine — only the upload is rejected.

Whenever you get a chance, either of these should resolve it repo-wide (no action needed on contributor PRs):

  • Settings → Code security → Code scanning → set Default setup to "Advanced" (disabling default setup) so the workflow file is the single source, or
  • remove/disable the advanced codeql.yml workflow and rely on default setup alone.

No urgency at all — just flagging in case it's useful. Thanks again!

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