When I tried using standard screen-time apps on Android to stop scrolling short-form video late at night, I ran into a predictable problem. Setting a 90-minute daily limit at 10:00 AM is easy. Keeping that limit intact at 12:30 AM takes much more than a pop-up dialog.
On Android, bypassing a typical app blocker takes about five seconds:
- Tap "Ignore limit for today" if the app is polite enough to offer one.
- Open Settings > Accessibility, toggle off the blocker's service, or long-press the launcher icon and tap Uninstall.
- Change the system clock forward by a few hours to skip a cooldown timer, or change the date to reset the daily quota.
- On aggressive OEM ROMs like OPPO ColorOS, Xiaomi HyperOS, or vivo OriginOS, swipe the app card away in Recents so the system kills its
AccessibilityService.
I built Ripple (涟漪) (English page / APK Releases) to close each of those escape hatches without requiring root access, a desktop computer, or a paid subscription.
This post walks through the engineering choices behind Ripple: enforcing asymmetric rule changes on Android's monotonic clock, closing whitelist loopholes, running a self-healing shell (UID 2000) daemon via Android 11+ on-device wireless debugging, and fixing a subtle uint32 overflow in AndroidPubkey that initially caused half of all generated RSA keys to fail pairing.
1. Asymmetric Rules and Monotonic Cooldowns (SystemClock.elapsedRealtime())
In Ripple, rule changes follow an asymmetric model:
- Tightening a rule takes effect immediately: Lowering a daily limit from 90 minutes to 45 minutes, extending a nightly focus schedule, or removing an app from the focus whitelist happens with zero delay.
- Relaxing a rule requires waiting through a cooldown timer: Adding time to an app, skipping tonight's sleep schedule, pausing protection, or uninstalling Ripple starts a countdown (15 minutes by default, configurable up to 2 hours, and automatically doubled for short-video apps like TikTok, YouTube, and Douyin). Users can also set a Friend / Parent Unlock Code so a family member or parent can unlock it immediately when needed.
Why System.currentTimeMillis() Fails for Cooldowns
A common mistake in productivity apps is storing a cooldown target timestamp using wall-clock time:
// Vulnerable to Settings > Date & Time changes
val unlockAtWallMs = System.currentTimeMillis() + cooldownDurationMs
If a user (or a teenager whose parent set up the phone) turns off automatic network time and moves the clock forward by 30 minutes, a wall-clock timer expires immediately.
Instead, Ripple anchors active cooldowns to Android's monotonic boot clock, SystemClock.elapsedRealtime(), which counts milliseconds since boot including deep sleep and cannot be altered by user time settings:
val nowElapsed = SystemClock.elapsedRealtime()
val nowWall = System.currentTimeMillis()
We persist both (startedElapsedMs, startedWallMs, durationMs). While the device remains booted, remaining time is computed strictly from SystemClock.elapsedRealtime() - startedElapsedMs. If the user reboots the phone mid-cooldown (which resets elapsedRealtime to zero), we detect the boot count / monotonic reset and prevent any negative jump or forward clock jump from crediting unelapsed time.
2. Closing the Whitelist Loophole and Protecting System Essentials
During a nightly focus schedule (Ripple defaults to 23:30–07:00 on fresh installs), blocking everything except a few allowed apps creates two practical problems:
- The Messaging App Video-Feed Loophole: If you whitelist a messaging app like WeChat or Telegram so you do not miss urgent messages, you also gain access to its built-in short-video feed or channels. To solve this, Ripple limits the focus whitelist to at most 5 apps and applies a per-session usage cap (30 minutes by default) to each whitelisted app during focus hours. You have plenty of time to reply to messages, after which the app is paused for the rest of that focus session.
-
Zero False Positives on System Essentials: An app blocker should never stop an incoming phone call, an emergency SMS, a morning alarm, or a payment screen at a subway gate. Ripple permanently exempts system essentials without consuming whitelist slots:
- Phone dialer and active call screens
- SMS / Messaging
- Clock and Alarms (full-screen ringing alarm activities are explicitly whitelisted so morning alarms always work)
- Calendar, System Camera (resolved dynamically via
MediaStore.ACTION_IMAGE_CAPTURE), Input Methods (IME), System File / Photo Pickers (documentsuiand MediaProvider picker), TalkBack, and default NFC Wallets.
3. Two-Tier Anti-Uninstall Architecture
Tier 1: Basic Protection (No PC, No Pairing)
Most users only need a 15-minute speed bump when an urge hits. Basic Protection takes about one minute to set up on the phone by enabling Accessibility Service and Device Administrator (DeviceAdminReceiver).
When active, ProtectionMatcher inspects window state changes in the Accessibility service to detect self-sabotage paths:
- Long-pressing Ripple on the home screen to open the system uninstall dialog
- Opening Android Settings > App Info for Ripple to tap Uninstall or Force Stop
- Opening Ripple's Storage page to tap Clear Storage / Clear Data
- Navigating to Ripple's Accessibility toggle or the Device Admin deactivation screen
When any of these screens is entered, Ripple immediately navigates back to the home screen and displays a calm dark-mode screen stating which action was just intercepted and how long the cooldown timer is.
Crucially, ProtectionMatcher checks the target package name and specific installer view nodes so that opening App Info for other apps (for example, clearing cache in a browser) or installing an in-place APK update for Ripple is never falsely blocked.
Tier 2: Advanced Protection via On-Device Wireless Debugging (ripple_ss)
Basic Protection has two limitations on modern Android phones:
- If the user connects the phone to a computer or navigates fast enough on certain custom ROMs, Device Admin can still be deactivated.
- On aggressive OEM skins (ColorOS, HyperOS/MIUI, OriginOS), swiping Ripple away from the Recents screen force-stops the app process and tears down its
AccessibilityService.
Traditionally, surviving task killers or locking uninstallation at the system level required connecting the phone to a PC with USB debugging (adb shell) or installing a separate third-party helper like Shizuku.
Starting with Android 11 (API 30), Android includes built-in Wireless Debugging. Because adbd listens on the device's local interface, an app can pair with adbd directly on the phone (127.0.0.1), open a local ADB connection, and spawn a helper process running under the shell user (UID 2000) via app_process.
In Ripple, this is an optional feature under Settings > Anti-Uninstall > Advanced Protection:
- The user follows a 4-step guide to enable Developer Options and Wireless Debugging, then taps Pair device with pairing code.
- Ripple posts a notification with an inline text input (
RemoteInput) so the user can type the 6-digit pairing code directly from the notification shade without closing Android's pairing dialog (closing the dialog invalidates the pairing code). - Once paired, Ripple connects to
127.0.0.1:<connectPort>, grants itselfandroid.permission.WRITE_SECURE_SETTINGS, and starts a lightweight JVM daemon namedripple_ss:
CLASSPATH=$(pm path com.slowisle.app | cut -d: -f2-) \
app_process /system/bin --nice-name=ripple_ss \
com.slowisle.app.selfstart.Server <bridgeToken> <wakeToken> <userId>
Running as UID 2000 (shell), ripple_ss does three things:
-
System-Level Uninstall Lock: Calls
DevicePolicyManager.setBlockUninstallForUserso the package manager rejects uninstallation even before any UI appears. When the user legitimately finishes the cooldown timer under "I want to uninstall Ripple", Ripple releases this lock and verifies the unlocked state viaDevicePolicyManager.isUninstallBlockedbefore launching the system uninstaller. -
Strong Binder Reference +
DeathRecipient+ 5s Heartbeat:ripple_sspasses anIBinderback to the main app process through aContentProvider.call()handshake. If the user swipes Ripple away from Recents on ColorOS 14,ripple_ssdetects process death (and missingenabled_accessibility_services), re-writes the Accessibility setting, and brings the service back in about 2.5 seconds. -
Reboot Self-Recovery: After a phone reboot, once the user unlocks the screen, Ripple uses its granted
WRITE_SECURE_SETTINGSpermission to restore Accessibility and reconnects toripple_sswithin about a minute.
4. The Bug That Broke 50% of Wireless Debugging Pairings: AndroidPubkey n0inv Overflow
While building the built-in wireless debugging client, we ran into a baffling bug: pairing succeeded on the first try about half the time, and failed silently after the SPAKE2 / TLS handshake the other half of the time.
When pairing over Android 11+ wireless debugging:
- The client and
adbdestablish a TLS 1.3 connection authenticated with SPAKE2 using the 6-digit pairing code. - Inside that encrypted channel, the client sends a
PeerInfostructure containing its 2048-bit RSA public key encoded in AOSP's custom binaryRSAPublicKeyformat (libcrypto_utils/android_pubkey.cpp), Base64-encoded. -
adbddecodes the struct and saves the public key to/data/misc/adb/adb_keysso subsequentAdbConnectionhandshakes on the connect port are trusted.
AOSP's 524-byte RSAPublicKey struct looks like this in C (system/core/libcrypto_utils/include/crypto_utils/android_pubkey.h):
typedef struct RSAPublicKey {
uint32_t modulus_size_words; // 64 for 2048-bit RSA
uint32_t n0inv; // Precomputed Montgomery parameter: -1 / n[0] mod 2^32
uint8_t n[256]; // Modulus as little-endian byte array
uint8_t rr[256]; // R^2 mod N as little-endian byte array
uint32_t exponent; // 65537
} RSAPublicKey;
Notice the second field: uint32_t n0inv, which is $-N^{-1} \bmod 2^{32}$, an unsigned 32-bit integer in the range $[1, 2^{32} - 1]$ (since any RSA modulus $N$ is odd).
In Kotlin/Java, we compute n0inv using BigInteger:
val r32 = BigInteger.ZERO.setBit(32) // 2^32
var n0inv = n.mod(r32).modInverse(r32)
n0inv = r32.subtract(n0inv) // Result is in [1, 2^32 - 1]
In our initial port, we wrote n0inv into the little-endian ByteBuffer using BigInteger.intValueExact():
// BUG: throws ArithmeticException whenever bit 31 of n0inv is 1!
buf.putInt(n0inv.intValueExact())
Here is why that failed 50% of the time:
-
n0invis an unsigned 32-bit value uniformly distributed across odd integers in $[1, 2^{32} - 1]$. - Java's
intis signed ([-2^31, 2^31 - 1]). - Whenever a newly generated RSA key had $n_0^{-1}$ such that
n0inv >= 0x80000000(which happens for 50% of all random RSA moduli),n0inv.intValueExact()threwArithmeticException: BigInteger out of int rangebecause the positiveBigIntegerrequired 33 bits in two's-complement signed representation!
The fix is a one-line change from intValueExact() to toInt() (or intValue()), which preserves the exact lower 32 bits in two's-complement representation when written by ByteBuffer.putInt():
val r32 = BigInteger.ZERO.setBit(32)
var n0inv = n.mod(r32).modInverse(r32)
n0inv = r32.subtract(n0inv)
// toInt() preserves all 32 bits of the uint32_t value without throwing on >= 2^31
buf.putInt(n0inv.toInt())
Once we switched intValueExact() to toInt() (and added a unit test covering RSA moduli across both halves of the uint32 range), wireless debugging pairing became 100% reliable.
5. Try Ripple
Ripple v0.1.1 is out today. It is free, has no ads, requires no account, and stores all rules and usage statistics locally on your device.
- English Overview & Guide: https://ripplefocus.github.io/en/
- Chinese Homepage: https://ripplefocus.github.io/
- Download APK (GitHub Releases): https://github.com/ripplefocus/ripplefocus.github.io/releases/latest
(Note: v0.1.1 is out today, and the Android app interface now supports full English and Simplified Chinese.)

Top comments (0)