feat(android): Phase 1 — session mechanics (sensors, timer, offline queue) - #7
Open
sasly2048 wants to merge 4 commits into
Open
feat(android): Phase 1 — session mechanics (sensors, timer, offline queue)#7sasly2048 wants to merge 4 commits into
sasly2048 wants to merge 4 commits into
Conversation
Restores the Phase 0 skeleton (reverted in #6) and begins Phase 1 with the riskiest piece: the sensor logic that decides when someone has broken the stack. BreachDetector is a direct port of the web app's useSensors hook. Every threshold and interval is copied rather than re-derived — they are tuned product values, and a session that breaches at different angles on Android than on the web is a different product. Two things genuinely differ from the web, both platform-driven: - Orientation arrives as a rotation vector in radians, so pitch and roll are converted to the degrees the web hook's beta/gamma work in. - Android surfaces no wake-lock release callback, so leaving the foreground reports as `tab-hidden` where the web could also distinguish `wake-lost`. Both enum values are kept; the column accepts them and the web still emits both. The decision itself lives in BreachRules, split out from the SensorManager plumbing so it can be exercised on the JVM. 18 tests cover the mode thresholds, the gentle-mode hold window, lift short-circuiting, shake magnitude, and the radian/degree conversion — the constants most likely to drift unnoticed. Detection only reports; recording a breach stays with the caller, matching how the web hook keeps its sensor loop free of network concerns.
The three pieces a session needs to survive the phone being ignored, which is the whole premise of the app. FocusSessionService keeps the remaining time glanceable while the app is off screen. The notification counts itself down — anchored to the session's real end time via setWhen + chronometer — so there are no per-second wakeups and the countdown can't drift with a handler the OS is free to throttle. It shows time and nothing else; leaving the app still breaks the stack, exactly as on the web. FinalizeQueue parks results that couldn't be submitted, usually because the room ended somewhere with no signal. Entries are keyed by (owner, room) so a retry replaces the pending row rather than stacking duplicates, and the owner stamp keeps a result from being replayed under a different account after a sign-out. It's a handful of rows at most, so DataStore holds it as JSON rather than it earning a database. FinalizeQueueWorker drains it under a CONNECTED constraint — a queued result now lands when connectivity returns instead of whenever the user next opens the app. SessionClock derives every timing value from the server's started_at instead of a local tick. Ten minutes in the background come back as ten minutes gone. 12 tests cover the drift case, backwards clock jumps, expiry boundaries, and formatting. Play review reads the specialUse subtype declared in the manifest: no standard foreground service type describes a countdown the user deliberately started.
There was a problem hiding this comment.
Pull request overview
Reintroduces the Android project scaffold and implements Phase 1 “session mechanics” that don’t require backend wiring: breach detection (sensors), a wall-clock anchored session timer (foreground notification), and an offline finalization queue (WorkManager + DataStore), plus supporting Compose theming/navigation and JVM unit tests for the pure logic.
Changes:
- Adds Android Gradle project structure and app module configuration (Compose, DataStore, WorkManager, Supabase client wiring).
- Implements session breach detection (rotation vector + accelerometer) with pure, unit-tested rule evaluation.
- Implements wall-clock-derived session timing utilities and a foreground service countdown notification; adds offline finalize queue + worker.
Reviewed changes
Copilot reviewed 40 out of 45 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| android/settings.gradle.kts | Android project settings + repos |
| android/gradlew.bat | Gradle wrapper (Windows) |
| android/gradlew | Gradle wrapper (POSIX) |
| android/gradle/wrapper/gradle-wrapper.properties | Gradle distribution pin |
| android/gradle/libs.versions.toml | Version catalog (libs/plugins) |
| android/gradle.properties | Gradle/Android build flags |
| android/build.gradle.kts | Root build plugins setup |
| android/app/build.gradle.kts | App module config + deps |
| android/app/proguard-rules.pro | Release shrinker rules placeholder |
| android/.gitignore | Android-local ignores (local.properties, build) |
| android/app/src/main/AndroidManifest.xml | Permissions, activity, FGS service |
| android/app/src/main/res/xml/data_extraction_rules.xml | Backup/transfer rules resource |
| android/app/src/main/res/xml/backup_rules.xml | Full-backup rules resource |
| android/app/src/main/res/values/themes.xml | Pre-Compose window theme |
| android/app/src/main/res/values/strings.xml | App name string |
| android/app/src/main/res/values/colors.xml | XML colors for pre-Compose |
| android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml | Adaptive launcher icon |
| android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml | Round adaptive icon |
| android/app/src/main/res/drawable/ic_stat_session.xml | Status-bar session icon |
| android/app/src/main/java/app/stackd/MainActivity.kt | Compose entry activity |
| android/app/src/main/java/app/stackd/StackdApplication.kt | Manual DI (SettingsStore) |
| android/app/src/main/java/app/stackd/Destinations.kt | Route table (web parity) |
| android/app/src/main/java/app/stackd/StackdNavHost.kt | NavHost + placeholder screens |
| android/app/src/main/java/app/stackd/core/settings/SettingsStore.kt | DataStore-backed settings |
| android/app/src/main/java/app/stackd/core/supabase/SupabaseModule.kt | Supabase client factory + guard |
| android/app/src/main/java/app/stackd/core/theme/Color.kt | App color palette + locals |
| android/app/src/main/java/app/stackd/core/theme/Theme.kt | Material3 + bespoke theme bridge |
| android/app/src/main/java/app/stackd/core/theme/Type.kt | Typography scale + mono label |
| android/app/src/main/java/app/stackd/core/theme/Shape.kt | Shape/radius scale |
| android/app/src/main/java/app/stackd/core/theme/Motion.kt | Reduce-motion + easing constants |
| android/app/src/main/java/app/stackd/core/ui/GlassCard.kt | “Glass” surface composable |
| android/app/src/main/java/app/stackd/core/ui/PulseDot.kt | Animated status pip composable |
| android/app/src/main/java/app/stackd/core/ui/Placeholder.kt | Placeholder screen composable |
| android/app/src/main/java/app/stackd/feature/room/session/BreachDetector.kt | Sensor listener + breach firing |
| android/app/src/main/java/app/stackd/feature/room/session/BreachRules.kt | Pure breach rule evaluation |
| android/app/src/main/java/app/stackd/feature/room/session/SessionClock.kt | Wall-clock session timing utils |
| android/app/src/main/java/app/stackd/feature/room/session/FocusSessionService.kt | Foreground countdown notification |
| android/app/src/main/java/app/stackd/core/workmanager/FinalizeQueue.kt | DataStore-backed finalize queue |
| android/app/src/main/java/app/stackd/core/workmanager/FinalizeQueueWorker.kt | WorkManager drain worker |
| android/app/src/test/java/app/stackd/feature/room/session/BreachRulesTest.kt | JVM tests for breach rules |
| android/app/src/test/java/app/stackd/feature/room/session/SessionClockTest.kt | JVM tests for session clock |
Suppressed comments (1)
android/app/src/main/java/app/stackd/feature/room/session/FocusSessionService.kt:125
stop()starts the service just to deliver an ACTION_STOP intent. On Android O+ this can fail when called from the background due to background service start restrictions. PreferstopService()for an app-initiated stop to avoid starting a background service at all.
fun stop(context: Context) {
context.startService(
Intent(context, FocusSessionService::class.java).setAction(ACTION_STOP),
)
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+70
to
+76
| WorkManager.getInstance(context).enqueueUniqueWork( | ||
| WORK_NAME, | ||
| // A newer flush supersedes a pending one; they'd read the same | ||
| // queue anyway. | ||
| ExistingWorkPolicy.REPLACE, | ||
| request, | ||
| ) |
Comment on lines
+41
to
+45
| val endsAtMillis = intent?.getLongExtra(EXTRA_ENDS_AT, 0L) ?: 0L | ||
| val roomCode = intent?.getStringExtra(EXTRA_ROOM_CODE).orEmpty() | ||
|
|
||
| createChannel() | ||
| startForeground(NOTIFICATION_ID, buildNotification(roomCode, endsAtMillis)) |
Comment on lines
+61
to
+65
| return if (hours > 0) { | ||
| "%d:%02d:%02d".format(hours, minutes, seconds) | ||
| } else { | ||
| "%02d:%02d".format(minutes, seconds) | ||
| } |
| ) { | ||
| PulseDot(animated = false) | ||
| Text( | ||
| text = title.uppercase(), |
Comment on lines
+49
to
+56
| suspend fun enqueue(payload: FinalizePayload) { | ||
| context.finalizeStore.edit { prefs -> | ||
| val existing = decode(prefs[KEY]) | ||
| val deduped = existing.filterNot { | ||
| it.owner == payload.owner && it.roomId == payload.roomId | ||
| } | ||
| prefs[KEY] = json.encodeToString(serializer,deduped + payload) | ||
| } |
Wires the Android client to the same Supabase project the web app uses, and ports the pre-auth throttle that sits in front of every sign-in. The RPCs behind that throttle (check_and_record_hit, recent_auth_failures) are REVOKEd from anon and authenticated, so they are only reachable with service_role — which cannot ship inside an APK. supabase/functions/auth-guard is that missing tier: the same constants, checks, and message strings as the web's guardSignIn, running where the key can live. Two deliberate divergences from web, both agreed with the product owner and documented at their call sites: - No Turnstile CAPTCHA. There is no native Android widget, and a signed APK is a weaker bot target than an open web form. Every other guard check is preserved. - Google sign-in arrives as an ID token from Credential Manager rather than through Lovable's OAuth broker, which is browser-only. Different transport, same Supabase user. Apple is not offered on Android. The guard fails open on transport errors, matching the web's catch-to-null: it is a throttle, not the authorization boundary, so an outage in it must not lock every user out. RLS and Supabase Auth still stand behind it. Also bundles the real Inter (400/500/600/800) and JetBrains Mono (400/500) faces the web app loads from Google Fonts, replacing the platform fallbacks — both OFL 1.1, latin subset, ~380KB total. headlineLarge moves from Bold to ExtraBold because 700 is not among the shipped weights and would otherwise be synthesised; the web's headings are font-extrabold regardless. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two vulnerabilities in the endpoint added in 9f5de18, both mine. 1. Account-lockout DoS (high). The /log route accepted {email, success} from an unauthenticated caller and inserted it into auth_attempts. recent_auth_failures counts exactly those rows, so five POSTs claiming failure locked any known address out of its own account for ten minutes. An audit log had become a remote lockout weapon. Authenticating the caller would not have fixed this — a signed-in user could still lie about a third party's address. The fix removes the claim entirely: password sign-in now runs inside the function, which performs the credential check itself and records the outcome it observed. The client receives session tokens or a refusal and never asserts an outcome. Failure messages are uniform so the endpoint is not an account-existence oracle. 2. Rate-limit bypass via X-Forwarded-For (high). Throttle keys were derived from a caller-controlled header, so rotating one value evaded every per-IP limit; the throttle constrained only honest clients. IPs now come from the connection's peer address via Deno.ServeHandlerInfo.remoteAddr. The /signin path fails closed — an unreachable or erroring function yields no session. The advisory guard used by Google still fails open, since it only advises and Supabase Auth plus RLS remain the real boundary. Found by automated security review. Note: the web app's logAuthAttempt (src/lib/auth.functions.ts) carries the same unauthenticated-write weakness and should be migrated onto this shape. Untouched here to keep this change to the Android surface. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Re-lands the Phase 0 scaffolding (reverted in #6) and builds the risky half of Phase 1: everything the session needs that doesn't touch the backend.
What's here
Breach detection (
BreachDetector/BreachRules) — a direct port of the web app'suseSensorshook. Every threshold copied rather than re-derived; a session that breaches at different angles on Android than on the web is a different product.Two platform-driven differences, both deliberate:
beta/gammawork in.tab-hiddenwhere the web can also saywake-lost. Both enum values kept — the column accepts them and the web still emits both.Session timer (
FocusSessionService) — foreground notification anchored to the session's real end time viasetWhen+ chronometer, so the system renders the countdown. No per-second wakeups, no drift. Shows time only; leaving the app still breaks the stack.Offline queue (
FinalizeQueue/FinalizeQueueWorker) — parks results that couldn't be submitted. Keyed by(owner, room)so retries replace rather than stack, with an owner stamp so nothing replays under a different account after sign-out. Drains under aCONNECTEDconstraint.Clock (
SessionClock) — every timing value derived from the server'sstarted_at. Ten minutes backgrounded come back as ten minutes gone.Tests
30 JVM tests, no device needed:
Not here, and why
Auth, dashboard, start, room, and results screens all need a live Supabase connection.
android/local.propertieshas empty credentials, so that code couldn't be run or verified — writing it blind would just accumulate untested bugs. Those land once credentials are in.Test plan
./gradlew testDebugUnitTest— 30/30 pass./gradlew assembleDebug— BUILD SUCCESSFUL