Lightweight Singapore bus timing app
Material 3 Compose UI with real-time arrivals, drag-to-reorder, pinning, and smart search. No ads, no accounts, no tracking.
Built with AI assistance — see CREDITS.md
| Stops list | Search dialog |
|---|---|
| Pinned stops at top, arrivals, pull-to-refresh | TokenTrie O(k) search — instant, no network |
![]() |
![]() |
| Feature | Detail | |
|---|---|---|
| 🚌 | Real-time arrivals | Shows next 3 buses per service with minutes-to-arrival |
| 🏷️ | Operator badges | SBS, SMRT, TTS, Go-Ahead colour-coded |
| 🚍 | Bus type icons | Single Decker, Double Decker, Bendy |
| 💺 | Load indicator | Seats Available / Standing Available / Limited Standing |
| ♿ | Wheelchair info | Wheelchair Accessible Bus (WAB) indicator |
| 📌 | Pin stops & services | Pin stops to the top; pin individual bus services within a stop. Survives restarts. |
| 🔍 | Smart search | TokenTrie O(k) prefix search + Levenshtein fuzzy matching over 5,201 stops — instant, no network |
| ✨ | New stop pulse | List auto-scrolls to newly added stop with a brief blue pulse highlight |
| 📍 | Nearby stops | Location-based nearby stop finder (opt-in) |
| 💡 | Random hints | Random bus stop hint shown every time you open the search dialog (from all 5,201 stops) |
| 🌙 | Theme support | Light, Dark, System-following, with Blue and Contrast Blue colour schemes — all persisted |
| 🔄 | Auto-refresh | Configurable interval (30s / 1m / 2m / 5m / Off) — pauses in background |
| Pull to refresh | Swipe down to refresh all stops | |
| 🩺 | API Health Banner | When the API has repeated failures, shows "Delayed" / "Under maintenance" banner — auto-dismisses on recovery |
| 📡 | Offline indicator | Cloud-off icon per stop when no internet, "No internet connection" label, separate from error state |
| 🖱️ | Drag to reorder | Long-press and drag bus stops to reorder — commit on drag end |
| 🗑️ | Drag to delete | Drag a stop into the bottom delete zone — card-center-in-zone threshold |
| 🔢 | Sort by earliest | Toggle stop card order by earliest arrival time — persists across restarts |
| 🔒 | Privacy first | Location is opt-in only. No accounts, no analytics, no telemetry |
| 📱 | Material 3 | Modern Compose UI with animations, pull-to-refresh, edge-to-edge |
| 🎨 | Splash screen | Branded cold-start splash using core-splashscreen library |
| 📦 | In-app update | Checks GitHub Releases for new version, downloads and installs APK directly |
Latest release: v1.0.3 —
bus-hop.apk(1.8 MB, R8-minified, shrinkResources, signed)
Or build from source for a debug APK.
- domain/ — Pure Kotlin (zero framework deps). Models, use cases, repository interfaces.
- data/ — Android library. Retrofit API calls, DataStore persistence, BusStopIndex with TokenTrie for search, update checker.
- app/ — Android app. Jetpack Compose UI, ViewModels (MainViewModel + ThemeManager + UpdateManager), feature flags (FeatureFlag + FeatureFlagScreen), theme, components.
- Development — AI-driven implementation steered by human architectural direction. In-progress features ship behind flags (dark by default) for gradual rollout and instant kill-switch. Unit tests (domain, data, app layers + architecture constraints) run via
./gradlew test. Run./gradlew updateBadges -PautoDetectafter changing test count. - Build — Release build with R8 minification +
shrinkResourcesreduces the APK to ~1.8 MB (vs debug). - Release — APK signed and published as a GitHub Release (
gh release create). - Ship — Tagged release (
v1.0.3) distributed via Obtainium.
In-progress features ship behind toggles (dark by default). Enable them at runtime via the debug menu — long-press the version label in Settings → Feature Flags.
| Flag | Default | Description |
|---|---|---|
NEW_BUS_TIMELINE |
Off | Redesigned bus arrival timeline |
NEARBY_STOPS_V2 |
Off | Enhanced nearby stops with filters |
PINNED_REORDER |
Off | Pinned-stop reorder gestures |
Flags are backed by SharedPreferences and can be toggled without a rebuild. Reset clears all overrides back to defaults. Add new flags to FeatureFlag.kt — the debug dialog picks them up automatically via FeatureFlag.entries.
| Layer | Technology |
|---|---|
| Language | Kotlin 2.4.0 |
| UI | Jetpack Compose (BOM 2026.05.01) + Material 3 |
| Icons | Material Icons |
| Architecture | MVVM + Clean Architecture (3 modules) |
| Networking | Retrofit 3 + OkHttp 5 |
| Serialization | Gson (data layer only) |
| Persistence | DataStore Preferences |
| Async | Kotlin Coroutines 1.11 + Flow |
| DI | Manual constructor injection through ViewModel Factory |
| Search | Inverted index + TokenTrie (prefix) + Levenshtein (fuzzy) |
| Testing | JUnit 4, MockK, Coroutines Test |
| Minification | R8 + ProGuard (release builds) |
| Gradle | 9.5.1, AGP 9.2.1 |
| Target | SDK 37, minSdk 26 |
- JDK 17 (OpenJDK)
- Android SDK 37 with build tools
- Set
ANDROID_HOMEto your SDK path
# Debug build + tests + APK verification
./gradlew clean test checkAndRenameDebugApk
# Release build
./gradlew assembleRelease
# APK output at:
# app/build/outputs/apk/debug/bus-hop.apk| Check | When | Where |
|---|---|---|
| APK integrity | Every ./gradlew assembleDebug |
app/build.gradle.kts — checkAndRenameDebugApk |
| Architecture tests | Every ./gradlew test |
ArchitectureTest.kt — 8 rules (layer separation, ProGuard, deps) |
| Badge freshness | After test count changes | ./gradlew updateBadges -PtestCount=N — refresh static SVGs |
Unit tests across 9 test classes — see tests badge for current count:
| Module | Tests | What's covered |
|---|---|---|
| Domain: BusStopUseCase | 28 | sortServices, sortServicesWithPins, applyPinning, toggleCollapsed |
| Domain: Model | 8 | toDisplayArrival eta/load/busType mapping |
| Domain: RefreshCoordinator | 6 | Cooldown, independent cooldowns, concurrent batching |
| Domain: AutoRefreshController | 7 | Start/stop/restart lifecycle |
| Data: BusStopIndex | 45 | TokenTrie search (exact, prefix, fuzzy, abbreviations, sorting, findNearby) |
| Data: RetryUtil | 6 | Retry with backoff, CancellationException propagation |
| Data: UpdateCheckerImpl | 6 | GitHub API parsing, version comparison, error handling, download guard |
| App: MainViewModel | 47 | add/remove/move/pin/collapse/refresh/sort/errors |
| App: Architecture | 8 | Layer separation, module deps, domain purity, catalog freshness, ProGuard |
BusHop uses the Arrivelah API (arrivelah2.busrouter.sg), which proxies LTA DataMall's BusArrivalv2 endpoint. No API key required.
| Data | Collected? |
|---|---|
| Location | 🔘 — opt-in, never sent off-device |
| Personal info | ❌ — no accounts, no sign-in |
| Analytics | ❌ — no tracking SDKs |
| Crash reports | ❌ — not collected |
| APK install | 🔘 — used only when you tap "Download & Install" for an update |
| Saved stops | 🔒 — stored locally in DataStore |
| API calls | 🔒 — direct to BusRouter, no intermediary |
MIT License — see LICENSE.

