Skip to content

feat(android): Phase 1 — session mechanics (sensors, timer, offline queue) - #7

Open
sasly2048 wants to merge 4 commits into
mainfrom
android-phase1
Open

feat(android): Phase 1 — session mechanics (sensors, timer, offline queue)#7
sasly2048 wants to merge 4 commits into
mainfrom
android-phase1

Conversation

@sasly2048

Copy link
Copy Markdown
Owner

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's useSensors hook. 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:

  • Orientation arrives as a rotation vector in radians, so pitch/roll are converted to the degrees beta/gamma work in.
  • Android surfaces no wake-lock release callback, so backgrounding reports as tab-hidden where the web can also say wake-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 via setWhen + 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 a CONNECTED constraint.

Clock (SessionClock) — every timing value derived from the server's started_at. Ten minutes backgrounded come back as ten minutes gone.

Tests

30 JVM tests, no device needed:

  • 18 on breach rules — mode thresholds, gentle hold window, lift short-circuit, shake magnitude, radian/degree conversion
  • 12 on the clock — drift, backwards clock jumps, expiry boundaries, formatting

Not here, and why

Auth, dashboard, start, room, and results screens all need a live Supabase connection. android/local.properties has 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
  • On-device: breach fires on lift, notification survives screen-off, queue drains after airplane mode (blocked on credentials + the room screen)

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.
Copilot AI lite review requested due to automatic review settings August 4, 2026 13:56

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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. Prefer stopService() 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)
}
sasly2048 and others added 2 commits August 5, 2026 07:15
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>
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.

2 participants