fix(android): self-heal — restart VpnService if it should be running but isn't

After an APK reinstall (or an OS low-memory kill that START_STICKY didn't recover
promptly), the VpnService dies but `filter_enabled` stays true. isVpnEffectivelyOn
then reports vpn:true (from the flag) → tamperLock:true → lockedIn:true → the green
"protection active" card with no toggles, while in reality nothing is filtering.

New native reconcileVpn(): if `filter_enabled` && !RebreakVpnService.isRunning &&
VpnService.prepare()==null → startVpnService(). Wired into _layout.tsx enforceProtection()
(runs on launch / foreground / 15s poll), called before reading combined state. No-op
on iOS/web. If the VPN consent was revoked, isVpnEffectivelyOn already clears the flag,
so that case self-resolves too.

Net behavior: while `filter_enabled` is true (user hasn't exited via the cooldown),
the app keeps the VPN alive. Exiting still goes through the cooldown → forceDisable →
filter_enabled=false → reconcile leaves it off. DiGA-compliant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-05-11 20:10:43 +02:00
parent 4492c7b265
commit af87893eb9
5 changed files with 55 additions and 0 deletions

View File

@ -81,6 +81,10 @@ export default function AppLayout() {
async function enforceProtection() {
if (cancelled || rearmInFlightRef.current) return;
try {
// Self-Heal: wenn der Schutz an sein soll der VpnService aber tot ist
// (Reinstall / OS-Kill) → neu starten, bevor wir den State lesen.
await protection.reconcileVpn();
if (cancelled) return;
const state = await protection.getCombinedState();
if (cancelled) return;
if (state.phase !== 'recoveringFromBypass') {

View File

@ -150,6 +150,18 @@ export const protection = {
return RebreakProtection.getDeviceState();
},
/** Android: VpnService neu starten falls er laufen sollte (`filter_enabled`)
* aber tot ist (Reinstall / OS-Kill). Bei App-Start/Foreground aufrufen,
* damit der State nicht an aber tot" bleibt. No-op auf iOS/Web. */
async reconcileVpn(): Promise<void> {
if (Platform.OS !== "android") return;
try {
await RebreakProtection.reconcileVpn();
} catch (e) {
console.warn("[protection] reconcileVpn failed:", e);
}
},
syncBlocklist(opts: SyncBlocklistOpts): Promise<SyncBlocklistResult> {
return RebreakProtection.syncBlocklist(opts);
},

View File

@ -282,6 +282,37 @@ class RebreakProtectionModule : Module() {
"tamperArmed" to armed,
)
}
/**
* Reconcile: Wenn `filter_enabled == true` (User WILL Schutz) der VpnService
* aber nicht läuft (z.B. nach App-Reinstall oder Low-Memory-Kill den
* START_STICKY nicht zeitnah recovert hat) neu starten. Sonst bleibt der
* State an aber tot": UI zeigt grün/„aktiv", real ist nichts geschützt.
* Wird vom JS bei App-Start / Foreground aufgerufen.
*
* Wenn die VPN-Permission entzogen wurde, kann hier nicht ohne UI neu
* established werden `isVpnEffectivelyOn` clear't in dem Fall bereits den
* Flag, also self-resolved das.
*/
AsyncFunction("reconcileVpn") {
val ctx = requireContext()
val shouldBeOn = isEnabledFlag(ctx)
val running = RebreakVpnService.isRunning
if (shouldBeOn && !running) {
val consentNeeded = try { VpnService.prepare(ctx) } catch (_: Exception) { null }
if (consentNeeded == null) {
startVpnService()
Log.i(TAG, "reconcileVpn: VpnService neu gestartet (flag=true, war aber nicht running)")
sendLayerChange()
mapOf("restarted" to true)
} else {
Log.w(TAG, "reconcileVpn: flag=true aber keine VPN-Permission — re-activate via UI nötig")
mapOf("restarted" to false, "needsConsent" to true)
}
} else {
mapOf("restarted" to false)
}
}
}
// ─── Helpers ────────────────────────────────────────────────────────────

View File

@ -87,6 +87,11 @@ declare class RebreakProtectionModule extends NativeModule<RebreakProtectionEven
blocklistCount: number;
tamperArmed: boolean;
}>;
/** Android: Wenn der Schutz an sein soll (`filter_enabled`) der VpnService
* aber nicht läuft (Reinstall / Low-Mem-Kill) neu starten. Bei App-Start /
* Foreground aufrufen, damit der State nicht an aber tot" bleibt. */
reconcileVpn(): Promise<{ restarted: boolean; needsConsent?: boolean }>;
}
export default requireNativeModule<RebreakProtectionModule>('RebreakProtection');

View File

@ -64,6 +64,9 @@ class RebreakProtectionModuleWeb extends NativeModule<RebreakProtectionEvents> {
tamperArmed: false,
};
}
async reconcileVpn() {
return { restarted: false };
}
}
export default registerWebModule(RebreakProtectionModuleWeb, 'RebreakProtection');