A pixel-perfect Android conversion of the CubeFlow web app.
Built with Kotlin + Jetpack Compose, targeting AMOLED displays.
| Concern | Solution |
|---|---|
| UI | Jetpack Compose (Canvas, Modifier, Animation) |
| Timer precision | SystemClock.elapsedRealtimeNanos() — sub-ms accuracy |
| Multi-touch | awaitPointerEventScope — tracks all PointerIds simultaneously |
| Database | Room (SQLite) — solves, profiles tables |
| Settings | DataStore Preferences |
| State management | ViewModel + StateFlow + collectAsStateWithLifecycle |
| Fonts | Google Fonts (Orbitron, Share Tech Mono) — downloaded at runtime |
| Screen wake | PowerManager.WakeLock + FLAG_KEEP_SCREEN_ON |
| Haptics | VibrationEffect.createOneShot() — API 26+ |
VelocityTimer/
├── app/src/main/
│ ├── AndroidManifest.xml
│ ├── java/com/velocitytimer/app/
│ │ ├── VelocityTimerApp.kt ← Application class / DI root
│ │ ├── MainActivity.kt ← Single activity, edge-to-edge
│ │ ├── navigation/
│ │ │ └── NavGraph.kt ← Compose Navigation host
│ │ ├── ui/
│ │ │ ├── theme/
│ │ │ │ ├── Color.kt ← AMOLED palette + LED tokens
│ │ │ │ ├── Theme.kt ← MaterialTheme wrapper
│ │ │ │ └── Type.kt ← Orbitron + Share Tech Mono
│ │ │ ├── components/
│ │ │ │ ├── GlassTouchPad.kt ← Full-height multi-touch pad
│ │ │ │ ├── LedTimer.kt ← Canvas 7-segment display
│ │ │ │ ├── StatusLed.kt ← Animated LED dot indicator
│ │ │ │ ├── StatCard.kt ← ao5/ao12/... glass card
│ │ │ │ └── GlassCard.kt ← Reusable frosted surface
│ │ │ └── screens/
│ │ │ ├── TimerScreen.kt ← Main timer + touch pads
│ │ │ ├── ScrambleScreen.kt ← Scramble + all averages
│ │ │ ├── SettingsScreen.kt ← Settings toggles + slider
│ │ │ ├── ProfileScreen.kt ← Profile hero + PB + stats
│ │ │ └── CreateProfileScreen.kt← New profile form
│ │ ├── data/
│ │ │ ├── model/
│ │ │ │ ├── Solve.kt ← Room entity
│ │ │ │ ├── Profile.kt ← Room entity
│ │ │ │ └── AppSettings.kt ← DataStore model
│ │ │ ├── db/
│ │ │ │ ├── AppDatabase.kt ← Room database singleton
│ │ │ │ ├── SolveDao.kt ← Solve CRUD + Flow
│ │ │ │ └── ProfileDao.kt ← Profile CRUD + Flow
│ │ │ └── repository/
│ │ │ ├── SolveRepository.kt
│ │ │ ├── ProfileRepository.kt
│ │ │ └── SettingsRepository.kt
│ │ ├── utils/
│ │ │ ├── ScrambleGenerator.kt ← WCA 3×3 scramble generator
│ │ │ ├── TimeFormatter.kt ← ms → "9.87" / "1:02.34"
│ │ │ ├── AverageCalculator.kt ← ao5/ao12/ao25/ao50/ao100
│ │ │ ├── WakeLockManager.kt ← Replaces browser Wake Lock API
│ │ │ └── HapticHelper.kt ← Replaces navigator.vibrate()
│ │ └── viewmodel/
│ │ ├── TimerViewModel.kt ← State machine + timer loop
│ │ ├── ScrambleViewModel.kt ← Reactive stats
│ │ ├── SettingsViewModel.kt ← DataStore ↔ UI bridge
│ │ └── ProfileViewModel.kt ← Profile CRUD
│ └── res/
│ ├── values/
│ │ ├── strings.xml
│ │ ├── colors.xml
│ │ ├── themes.xml ← Fullscreen AMOLED black
│ │ └── font_certs.xml ← Google Fonts provider certs
│ ├── xml/
│ │ ├── backup_rules.xml
│ │ └── data_extraction_rules.xml
│ ├── drawable/
│ │ ├── ic_launcher_background.xml
│ │ └── ic_launcher_foreground.xml
│ └── mipmap-anydpi-v26/
│ ├── ic_launcher.xml
│ └── ic_launcher_round.xml
| Tool | Minimum version |
|---|---|
| Android Studio | Hedgehog (2023.1.1) or newer |
| JDK | 17 |
| Android Gradle Plugin | 8.3.2 |
| Kotlin | 1.9.24 |
| minSdk | 26 (Android 8.0) |
| targetSdk | 34 (Android 14) |
- Unzip
VelocityTimer.zip - Open Android Studio → File → Open
- Select the
VelocityTimer/root folder (the one containingsettings.gradle.kts) - Click OK — Android Studio will auto-detect the Gradle project
- Wait for the initial Gradle sync (~2–4 minutes on first run while dependencies download)
- When the sync completes with no errors, proceed to build
If Gradle sync fails with "Gradle wrapper not found": Run
gradle wrapper --gradle-version=8.6in the project root, or copygradle/wrapper/gradle-wrapper.jarfrom any other Android Studio project.
cd VelocityTimer
./gradlew assembleDebugOutput: app/build/outputs/apk/debug/app-debug.apk
./gradlew installDebug
# or
adb install -r app/build/outputs/apk/debug/app-debug.apk./gradlew bundleReleaseOutput: app/build/outputs/bundle/release/app-release.aab
You will need a signing keystore for release builds. In Android Studio: Build → Generate Signed Bundle/APK → Create new keystore
The app uses Orbitron (display) and Share Tech Mono (timer/mono) from Google Fonts, downloaded at runtime via the Google Mobile Services fonts provider.
This works automatically on all devices with Google Play Services (virtually all Android phones).
If you need to support devices without GMS (e.g. Amazon Fire, custom ROMs):
-
Download the fonts:
-
Place them in
app/src/main/res/font/ -
Replace the
FontFamilydeclarations inType.kt:
val OrbitronFamily = FontFamily(
Font(R.font.orbitron_regular, FontWeight.Normal),
Font(R.font.orbitron_bold, FontWeight.Bold),
)
val ShareTechMonoFamily = FontFamily(
Font(R.font.share_tech_mono_regular)
)- Update
DisplayFontandMonoFontaliases to point to the new families.
The timer is a state machine with 6 states:
IDLE → ONE_HAND → HOLDING → READY → RUNNING → STOPPED → IDLE
| State | Condition | LED colour |
|---|---|---|
IDLE |
No pads held | Dim red |
ONE_HAND |
One pad held | Amber (blinking) |
HOLDING |
Both held, counting down | Red (blinking) |
READY |
Hold delay elapsed | Green (solid) |
RUNNING |
Released both from READY | Blue (dim) |
STOPPED |
Any pad touched while running | White flash |
Hold delay (default 500 ms) is configurable in Settings → Hold sensitivity.
Timer precision: Uses SystemClock.elapsedRealtimeNanos() which is monotonic
and unaffected by clock adjustments. Centisecond display updates run at ~120 fps
via a coroutine loop with delay(8ms).
| Column | Type | Notes |
|---|---|---|
id |
TEXT PK | UUID |
userId |
TEXT | "local" until cloud sync |
scramble |
TEXT | Full 20-move WCA scramble |
timeMs |
INTEGER | Raw solve time in ms |
penalty |
TEXT | NONE / PLUS_2 / DNF |
sessionName |
TEXT | "default" |
createdAt |
INTEGER | Unix timestamp ms |
| Column | Type | Notes |
|---|---|---|
id |
TEXT PK | UUID |
username |
TEXT | Max 20 chars |
avatar |
TEXT | Emoji string |
createdAt |
INTEGER | Unix timestamp ms |
syncStatus |
TEXT | "local" / "synced" / "pending" |
| Permission | Purpose |
|---|---|
WAKE_LOCK |
Keep screen on during solving (WakeLockManager) |
VIBRATE |
Haptic feedback on solve stop (HapticHelper) |
| Original web code | Android equivalent |
|---|---|
performance.now() + RAF |
SystemClock.elapsedRealtimeNanos() + coroutine |
localStorage |
Room DB (solves, profile) + DataStore (settings) |
CSS text-shadow LED glow |
Canvas BlurMaskFilter + setShadowLayer() |
| Pointer Events API | awaitPointerEventScope + PointerId set |
| Wake Lock API | PowerManager.WakeLock |
navigator.vibrate() |
VibrationEffect.createOneShot() |
| TanStack Router | Compose Navigation NavHost |
| Tailwind CSS | Compose Modifier + drawBehind |
oklch() colours |
Color(0xFFRRGGBB) ARGB constants |
| Google Fonts woff2 | GMS Downloadable Fonts provider |
crypto.randomUUID() |
java.util.UUID.randomUUID() |
gradle-wrapper.jaris a binary blob that cannot be distributed as text. Android Studio will prompt you to regenerate it on first open, or run:gradle wrapper --gradle-version=8.6
- The
font_certs.xmlfile ships with placeholder certificate fragments. The full production certificates are embedded in the Google Play Services APK on device — the runtime will validate against those, not the values in this file. In practice thestring-arrayjust needs to exist; the system handles cert pinning.
VelocityTimer v1.0 — converted from CubeFlow (React/Vite) to Kotlin/Compose