MaksForge

Urgent call

Android app

Rings your phone when the same number calls twice within a few minutes, even on silent.

Building

Optional modules — what to delete, and what breaks when you do

The template ships an app that records things: a Room database, photos, backup export and restore, and reminders. Most of that is machinery with a data-loss story attached, and an app that does not need a piece of it should delete it before the first upload, while deleting is free.

Why before, and not “later, if it turns out unused”. Nothing is shipped yet, so a table deleted today comes back at version 1 if a feature ever needs it. Ship an unused items table and remove it later, and that removal is a destructive migration against real installs — the exact failure ADR-0001 exists to prevent. The same asymmetry holds for permissions: one declared today is a Data safety answer you will have to keep defending.

The first app built from this template stored settings rather than records and deleted everything below except the preferences on day one. It took 69 files, and five things around the code broke that nothing had said depended on the code. This page is that strip written down, so the next one is a checklist rather than a discovery.

How to decide

The test is cardinality, not subject matter. A fixed set of values is settings, however many members it has: four schedule options are settings, and seven per-day windows are fourteen settings. What needs a table is the user creating rows nobody knew about at build time. No such rows, no Room.

The modules

Each row lists what to delete together. Delete a module, then build, then run ./gradlew test and the scripts in the last column — they are what tells you the strip is finished rather than the compiler.

Room database and the schema gate

  • data/AppDatabase.kt, ItemDao.kt, ItemEntity.kt, ItemRepository.kt, Converters.kt, Migrations.kt, SchemaGate.kt, DatabasePreserve.kt
  • work/SchemaGuard.kt, ui/wipe/SchemaMismatchScreen.kt, the wipe guard’s call in MainApplication
  • app/schemas/, test/…/data/SchemaGateTest.kt, androidTest/…/data/ItemDaoTest.kt, MigrationTest.kt
  • Gradle: the ksp plugin and ksp {} block, the three Room dependencies, room.testing, and the androidTest schema-assets line in androidComponents
  • What adapts on its own: scripts/project.py reads HAS_DATABASE off data/AppDatabase.kt, so schema-gate.py passes with “no database, nothing to gate”, doze-capture.sh stops pulling a file that is not there, and the mismatch suite leaves edge-to-edge.py and screenshots.py.
  • What does not: docs/DOD.md’s standing schema gate — mark it parked, keep the five points verbatim, and say it comes back the day a feature adds a table. ADR-0001 gets a dated amendment, not a rewrite (docs/adr/0000-template.md).

Photos (the media pipeline)

  • media/MediaFiles.kt, androidTest/…/media/MediaFilesTest.kt, res/xml/file_paths.xml and the FileProvider in the manifest
  • Gradle: coil.compose, both exifinterface lines
  • scripts/aab-permissions.py: nothing to remove — the photo path declares no permission — but CAMERA stays in FORBIDDEN, because a dependency that merges it changes the listing.
  • The privacy policy’s Photos and camera bullets, and play-app-content.md’s Data safety point 2.
  • ADR-0002 gets its amendment.

Backup export/restore, and the custom BackupAgent

  • data/backup/, ui/backup/, test/…/data/backup/, AppBackupAgent.kt and its manifest attributes; scripts/upgrade-diff.py, which reads those backups
  • kotlinx.serialization stays: Navigation 3 serialises every NavKey.
  • Platform Auto Backup takes over, with android:dataExtractionRules if anything must stay on the phone. ⚠ Every DataStore key is then backed up, whether you meant it to be or not — exclusions are per file, and the preferences store is one file — so every key lands on the user’s next phone with no code of yours involved. Ask what each key means there: a setting is welcome, live state is not (“the service should be running”, “a deadline is armed”). Judge live state at the read, against something the platform never backs up — PackageInfo.firstInstallTime is one. The first app built from this template met this in its restore test: a restored “running” flag was acted on by a boot receiver on a phone that had never granted the permission it assumed.
  • ADR-0005 gets its amendment.

Reminders and WorkManager

  • work/ReminderSweep.kt, BootReceiver.kt, PackageReplacedReceiver.kt, ExactAlarms.kt, ReminderChannels.kt and its test; NotificationPermission.kt if nothing else posts
  • Manifest: SCHEDULE_EXACT_ALARM, RECEIVE_BOOT_COMPLETED, the receivers, and the androidx.startup tools:node="remove" for WorkManager’s initializer (the provider itself stays — emoji2 registers there)
  • MainApplication’s Configuration.Provider; Gradle’s work.runtime and work.testing
  • ⚠ WorkManager merges three permissions your source never declares — WAKE_LOCK, ACCESS_NETWORK_STATE, FOREGROUND_SERVICE — and RECEIVE_BOOT_COMPLETED for good measure. If you keep a boot receiver but drop WorkManager, declare RECEIVE_BOOT_COMPLETED yourself first, or the receiver stops being called with nothing to say so. ACCESS_NETWORK_STATE on an app with no INTERNET is a line a Play reviewer can ask about; removing WorkManager removes the question.
  • scripts/aab-permissions.py’s EXPECTED rows for all of the above, in the same commit.
  • scripts/device-gate.py’s ALARM_TAG and the exact-alarm line, if nothing schedules.
  • Settings’ Reminders section, and the ask and banner behind it — the SwitchRow, the rememberNotificationPermissionAsk call, the resumed re-read of notificationsAllowed, the WarningCard and its three settings_reminders_blocked_* strings, plus AppPreferences.remindersEnabled. That whole block is PLAN.md rule 4 worked through for one ask; it is the shape to copy for your first ask, so read it before deleting it.
  • ADR-0003 gets its amendment.

The placeholder domain

  • ui/items/, the items routes in Navigation.kt / NavigationKeys.kt, the tab_items strings
  • debug/…/data/SampleData.kt, debug/…/debug/SeedReceiver.kt, the debug manifest’s receiver, and the seed rows in DebugSettings.kt — or keep the seeding seam and point it at your own data
  • scripts/edge-to-edge.py’s SCENES, TAB_BAR, HOME_TAB and SEED_WALK: they walk the template’s screens. Rewrite them against yours the same week — until then the nightly job boots ten emulators to walk screens that do not exist and reports a red everyone learns to ignore.

Around the code

Five places the strip reaches that no compiler will point at:

  1. scripts/aab-permissions.py — EXPECTED must be exactly what the artifact carries. Run it against a local bundleRelease (or bundleDebug); a stale row fails the release upload, after the tag, which is where the first app built from this template found it.
  2. CI’s instrumented job fails on zero tests. If the strip deletes every androidTest, write one real device test — the thing the app must never get wrong is a good first one — or delete the job.
  3. CLAUDE.md — the stack table, the house rules that name deleted code, and the layout block.
  4. docs/privacy-policy.md and docs/play-app-content.md, together, in the same commit.
  5. The ADRs — each module above names the one that gets a dated amendment. Never delete an ADR: the reasoning is what the next feature that wants the module back will need.

Hi, I'm Maksymilian.

MaksForge is where I build the small tools I wish existed. Each one solves a single problem.