Skip to content

Rewrite the app in Kotlin Multiplatform and Compose for 2.0 - #6

Merged
anish-sahoo merged 12 commits into
masterfrom
rewrite-2.0
Aug 13, 2026
Merged

anish-sahoo merged 12 commits into
masterfrom
rewrite-2.0

Conversation

@anish-sahoo

@anish-sahoo anish-sahoo commented Aug 13, 2026

Copy link
Copy Markdown
Owner

Rewrite of the app in Kotlin Multiplatform and Compose, shipped to the existing
Play listing as versionCode 7. Already published.

New

  • Scan expiry dates off a label with ML Kit, with the GenAI Prompt API on devices that support it
  • Glance home screen widget, four layouts chosen by available size
  • Photos and notes per item, search, custom categories, locale-aware date formats
  • Reminders at a configurable lead time and hour
  • Eight languages

Changed

  • Java, Activities and raw SQLite replaced with Kotlin Multiplatform, Compose and SQLDelight
  • LegacyImporter imports the 1.5 database and image cache on first launch, once, leaving the old data in place
  • ktlint, detekt and Android Lint under ./gradlew ciCheck, run by GitHub Actions
  • minSdk 26 to 31

Known gaps

  • Translations not reviewed by native speakers
  • OCR and the GenAI path have only run on an emulator
  • The widget leaves dead space and drops its overflow line at tall sizes

Replaces the Java/Activities/raw-SQLite app with Kotlin Multiplatform,
Compose and SQLDelight, shipped to the same Play listing as versionCode 7.

The old app is deleted rather than migrated in place. Almost nothing
survived contact with the rewrite: the schema changed, the UI is entirely
new, and the only real continuity is the package name and the data users
already have.

What is new:

- Scanning expiry dates off a label with ML Kit, with the GenAI Prompt API
  handling awkward formats on devices that support it
- A Glance home screen widget with four layouts chosen by available size
- Photos and notes per item, search, custom categories, locale-aware date
  formats, and reminders at a configurable lead time and hour
- Eight languages, machine translated and not yet reviewed by speakers

Carrying over from 1.5:

- LegacyImporter reads the old database and image cache on first launch.
  It is idempotent and leaves the old data untouched. This is the only
  path that can lose data users cannot recover, so it has the most tests.

Tooling:

- ktlint, detekt and Android Lint, all reachable through ./gradlew ciCheck
- GitHub Actions running that task on push
- 188 unit tests and 10 instrumented tests

minSdk moves from 26 to 31, which strands users on Android 8 to 11 on 1.5.
A single ciCheck step meant a red run said only that something failed. One step
per gate names it in the job summary instead.

Each step runs even when an earlier one failed, so one push reports every problem
rather than one per round trip.

Also fixes the branch: the workflow triggered on main and gated the release bundle
on refs/heads/main, neither of which exist here. The default branch is master, so
push builds and the bundle job have never run.
.kotlin/ is the Kotlin plugin's per-build session directory. It appears at the
root the first time the project is opened and was not covered by any existing
rule, so it would have shown up as untracked on a clean checkout.

Also scopes the Inspect Code export rules to the root. index.html, script.js and
styles.css are generic enough that an unanchored rule could swallow a real file.
The rewrite's README dropped the store link, the badge and the license line, and
replaced the original About/Features/Technicalities shape with something longer.
This keeps the old skeleton and updates the contents for 2.0.
The README is for someone deciding whether to install the app, so build commands,
module layout, signing and the migration internals do not belong in it.

AGENTS.md also collects the things that have already gone wrong here and are
invisible in the code: R8 stripping ML Kit's reflective constructors, dex refusing
commas in test method names, Robolectric double-starting Koin.
Steps inside a single job all report as one check. Separate jobs surface as
CI / Format, CI / Lint, CI / Unit tests and so on, which is what the checks list
and branch protection can actually see.

A matrix rather than five near-identical jobs. Each compiles independently, which
the Gradle cache mostly absorbs.
The job logs already say what failed, and nobody was downloading the HTML.
The release bundle upload stays, since that is a build output rather than a report.
CI died on 'Attempt 1/1 failed: Unexpected end of file from server' while
fetching the distribution zip. The wrapper was configured with retries=0, so a
single dropped connection failed the job, and a 10s timeout is tight for a
130 MB download behind a redirect.

Three retries with a 1s backoff, and a 60s timeout.
gradle/gradle-daemon-jvm.properties pinned the daemon to JDK 25, so every build
ran there whatever JAVA_HOME said. detekt 1.23 embeds a Kotlin compiler that
cannot parse a 25.x version string and died with 'IllegalArgumentException:
25.0.3' before analysing anything, which is what failed CI.

Dropping the generated toolchainUrl entries with it: they pointed at JDK 25
downloads and would be wrong for a 21 request. Gradle now uses the locally
installed 21 that dev machines and the runner already have.

With detekt actually running, three real findings surfaced and one config bug:

- BADGE_ALPHA and BORDER_ALPHA in UrgencyStyle.kt were dead
- MagicNumber listed excludes, which replaces detekt's defaults instead of
  adding to them, so the test sources lost their exemption and 55 deliberate
  literals in tests were flagged
- parseIsoish trips ReturnCount at 7 returns, all of them guard clauses, which
  is the house style; guards are no longer counted

Also renames UrgencyBadge.kt to UrgencyStyle.kt. It holds no UrgencyBadge, which
ktlint's filename rule caught and which had been failing since the file was
written.
filteringByCategoryHidesTheOtherCategories matched the Medicine chip by text and
clicked it. The filters are a LazyRow, so a chip that does not fit on screen is
never composed and cannot be found. It passed on a 1080x2400 emulator and failed
on CI's narrower one. The row now carries a test tag and the test scrolls to the
chip, and takes the label from resources rather than hardcoding English.

Also raises the waitUntil timeout from 5s to 15s. Three tests timed out on CI
purely because a cold emulator is slower than a warm local one, which reports as
a behaviour failure rather than as the machine being busy.
Booting an emulator cost most of ten minutes on every push and was the flakiest
thing in the pipeline, so the e2e job is gone. The instrumented tests stay in the
repo behind ./gradlew ciCheckDevice for when a real device is the point.

What replaces them runs on the JVM under Robolectric, using the same Compose test
API, with MockK standing in for the repositories:

- ItemListComponentTest: urgency grouping, the attention count, category filtering
- AddItemComponentTest: what a save actually sends, the reopen-after-save
  regression, the empty-name case, and the scan button gated off

Both hold time still. Clock.System was read at six call sites, which made every
assertion about 'expires in five days' depend on the day it ran and the timezone
it ran in. Today is now injected and fixed at 2026-06-15 in tests.

Writing these found what CI had been failing on. The form is taller than the
sheet, so on a short screen Save sits below the fold, and performClick on an off
screen node silently does nothing: the sheet never closed and the wait timed out
15 seconds later looking like a slow machine. My emulator is tall enough to show
the button, CI's was not. Both suites now scroll to Save first.
@anish-sahoo
anish-sahoo merged commit d1de0df into master Aug 13, 2026
6 checks passed
@anish-sahoo
anish-sahoo deleted the rewrite-2.0 branch August 13, 2026 05:38
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.

1 participant