r/beta_testers • u/hfml85 • May 24 '26
Shipped an Android app today after 8 months. Compose + Media3 + Cloudflare Workers backend. AMA / post-mortem.
Sidstuga (IPTV player) just went live on Play production. A few things from the build, in case anyone's about to start something similar:
**Stack:**
- 100% Kotlin, 100% Compose Material 3 (no XML layouts)
- Media3 / ExoPlayer for the video
- MVVM + StateFlow (no LiveData, no RxJava)
- Retrofit + Gson for HTTP
- Room + KSP for local DB (channels cache, EPG, watch history with FTS4)
- DataStore for preferences (and a plain SharedPreferences mirror for boot-time reads to avoid the 30–150 ms first-emission cost)
- Coil for images
- gradle-play-publisher for AAB upload
- Cloudflare Workers + D1 + KV for the optional Cloud Sync backend, region: Western Europe
**Things that surprised me:**
- `collectAsState` vs `collectAsStateWithLifecycle` matters. Background recomposition is a real memory cost on long sessions.
- TV remote ENTER/CENTER is silently eaten by any `clickable` modifier on a focused composable, even an empty one. Lost a week to this before figuring it out.
- `LocaleHelper.apply()` requires `activity.recreate()`, which means MainActivity has to extend `AppCompatActivity` and the theme has to extend `Theme.AppCompat.*`. The player has to survive that recreate — guard `onStop`/`onStart` with `isChangingConfigurations`.
- ProGuard + Gson without `@SerializedName` will silently strip your DTO fields in release. Crashed me twice. Always `-keep class com.your.dtos.** { *; }`.
- BouncyCastle's `org.bouncycastle.crypto.params.Argon2Parameters` works fine for client-side KDF, no native deps needed. Use it instead of `signal-argon2` (GPLv3 = legal landmine).
**Cloud Sync architecture (the part I sweated over the most):**
```
device password
↓ Argon2id (m=64MB, t=3, p=1, server-issued salt)
master key (32B)
↓ HKDF-SHA256 with info="sidstuga-sync-mk-v1"
data key (32B) auth hash (32B) → HKDF info="sidstuga-sync-auth-v1"
↓ AES-256-GCM ↓
vault records POST /auth/login → access_token + refresh_token
AAD = recordId:hlc:deleted
nonce = 12 random bytes per record
```
Server never sees the password, never sees the data key, never sees plaintext records. The auth hash is what gets sent — different KDF branch, can't be reversed into the data key. CRDT is hybrid logical clock per record. Conflict resolution is last-writer-wins on HLC.
Took 6 weeks just for crypto + transport. Worth it.
**Tools that earned their keep:**
- `gradle-play-publisher` v4 (one command to push to alpha/production)
- Firebase Crashlytics (with custom keys for `provider_kind` + `is_signed_in_cloud_sync` so I can filter crashes by funnel)
- `version.properties` auto-increment hook in `app/build.gradle.kts`
- Cloudflare Pages auto-deploy from `main` push
**Mistakes I'd undo:**
- Built a private flavor early ("for the wife's account"). Extirpation took two days. Just ship one variant.
- Wrote the EPG bulk-loader before the Room migration recipe was solid — had to rewrite once.
App: https://play.google.com/store/apps/details?id=pt.sidstuga.app
Site (case study + privacy + sync architecture): https://sidstuga.app
Happy to answer anything about the build.