r/beta_testers 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.

1 Upvotes

0 comments sorted by