Core recovery could never succeed, and every attempt left a live
libxray.so behind.
startCore() calls startTimer(), which builds a CountDownTimer and so a
Handler on the calling thread. handleXrayCoreExit() runs recovery on a
bare Thread, so the constructor threw "Can't create handler inside
thread ... that has not called Looper.prepare()" and took the whole
restart down with it. The catch then deleted the ephemeral config and
returned false without killing the process pb.start() had already
started, so each retry stranded another core.
Measured on an emulator by killing the live core:
before 1 -> 5 -> 6 -> 8 processes over 120 s, never recovers
after "Xray core recovered without dropping TUN" on attempt 1,
exactly 1 process
startTimer now posts to the main looper, re-checking RUNTIME_TOKEN
inside the post so a stop that lands while it is queued cannot leave a
timer running for a dead generation. stopTimer posts too, so the
countDownTimer field is only touched from one thread. The catch clears
xrayProcess before destroying it, so the monitor thread's
`xrayProcess === process` guard fails and recovery is not re-entered
for a core being killed here.
Pre-existing on main; reachable whenever the core exits.
Co-Authored-By: Claude Opus 5 <[email protected]>
Second review pass found that VpnStatus.error is unreachable mid-session:
AppConfigs.V2RAY_STATES has no error state and the plugin's stateName when()
ends in else -> "DISCONNECTED", so Dart's unknown -> error mapping is dead
code on Android. Every native stop therefore arrived as a plain disconnect,
and anything other than the expiry deadline - an Xray or TUN setup failure, a
mid-session fail-closed deadline arm - left the user on their real IP with no
notification and no message. That is the exact hole this feature exists to
close.
stopAll() is the single funnel for every native stop, so the alert moves
there: it fires when the stop interrupted a live tunnel and the user did not
ask for it. isRunning is captured before cleanup() clears it, which keeps a
failed connect silent - it never had a tunnel to interrupt. STOP_SERVICE
(the notification's Disconnect action) and onRevoke pass userRequested: in
the revoke case the user switched VPNs or withdrew consent themselves, and
may well be protected by the app that took over.
Also from the pass:
- re-arming now needs the deadline to move by more than 15 s, so round-trip
jitter cannot cancel and re-post a warning that is already on screen
- a warning delivered with under a minute left is dropped rather than
announcing "5 minutes"; the disconnected alert follows anyway
- the _doDisconnect comment no longer claims endedMessage posts the alert
- CHANGELOG: the alert does now cover the tunnel stopping unexpectedly
flutter analyze clean; debug APK builds with the Kotlin recompiled.
Co-Authored-By: Claude Opus 5 <[email protected]>
Review of 742f376 (Fable). Three defects made the feature not work for the
case it exists for:
- armExpiryWarning() cancelled the alarm before its own early return. Dart
re-pushes the deadline every 60 s with no dedupe (rule 93's ">5 s" dedupe
does not exist), so the first sync after the warning came due deleted the
armed alarm and never re-armed it. Cancel now happens only on the branch
that re-arms.
- cancelSessionWarningAlarm() built its PendingIntent with
FLAG_UPDATE_CURRENT, rewriting the live alarm's RUNTIME_TOKEN to "" so a
delivery in flight was dropped as a stale generation. It now looks the
existing PendingIntent up with FLAG_NO_CREATE.
- SOCKS5 mode never requested POST_NOTIFICATIONS (requestPermission() is
TUN-only because it also raises the VPN consent dialog), so on Android 13+
both alerts were dropped. New plugin method requests it on its own.
Also:
- the native deadline wins the expiry race against the 1 s Dart tick, so the
commonest session end reached _onVpnConnectionChanged as a bare tunnel
drop with no message. It now reconciles and sets the expiry text, with
postAlert false since the native side already posted.
- a resume reconnect no longer resets the support-ad claim: it re-attaches
to the same server session, where the top-up has already been spent.
- RevoltAlerts sets setOnlyAlertOnce so a replacing post does not re-sound.
- AGENTS.md rule 93 described a setSessionExpiry/SESSION_EXPIRES_AT_MS path
that no longer exists, dedupe included. Corrected; that stale text is what
the warning bug was written against.
- CHANGELOG no longer claims the alert covers the tunnel stopping on its own.
flutter analyze clean; debug APK builds.
Co-Authored-By: Claude Opus 5 <[email protected]>
Review findings #1-#3 in internal/APP_REVIEW_2026-10-16.md: every session
end dropped the user onto their real IP with no warning, and the app showed
no errors at all.
- RevoltAlerts (vendored runtime) owns both alerts: a high-importance
channel, ids 2 and 3, posted from whichever process is alive
- XrayVPNService schedules a 5-minute warning off the deadline it already
holds, best effort (a warning that cannot be armed never stops a tunnel),
with its own request code so it cannot clobber the expiry alarm
- the warning intent is handled above the command dispatch: without that it
would fall through to the final else and stop the tunnel 5 minutes early
- SessionTimer._doDisconnect takes endedMessage: set for ends the user did
not ask for, which both posts the alert and gives the UI a sentence
- the API being unreachable no longer disconnects a working tunnel; it is a
display state now, with expiry still enforced natively and server-side
- status_bar.dart renders connect errors, connecting progress and the
session-end reason
flutter analyze clean; debug APK builds (Kotlin compiles).
Co-Authored-By: Claude Opus 5 <[email protected]>