Committing the store assets changed a rule that four files stated, so they now state the new one
instead of contradicting the tree:
- STORE_LISTING: "screenshots are not committed" becomes what is committed, why it is safe (every
published frame is generated demo content), and what is still deliberately out of git (raw
captures, demo photos, the video's milestone PNGs). It also spells out the public URL pattern,
because that is now the deployment step rather than a footnote.
- PLAY_RELEASE_CHECKLIST: the two "(git-ignored)" annotations on the screenshot directories become
"(committed)", and step 5's video item carries the exact link to paste plus what was verified
about it (200, video/mp4, Content-Disposition: inline, sha256 match) and the two traps in using
it (pinned to main, so a re-shoot overwrites in place; and replacing it at all means resubmitting
the declaration).
- ROADMAP: M10 no longer claims the screenshots are left uncommitted, and the declaration's blocker
row loses its "hosting remains" clause - the file is published, so only pasting is left.
- README: says where the link comes from, so the next reader does not go looking for a host.
The declaration's video exists now: store-assets/fgs-demo/foreground-service-demo.mp4, recorded on
the test device (Android 12 / MIUI, 2a2ae895) with INSTALL=0 WARMUP=0, and then checked frame by
frame rather than assumed - the intro at 0 s, the scan starting ("Comparing file signatures...
91/102", 8 s), the shade held open for ~6 s with the live notification ("Finding duplicate photos
/ Looking for visually similar photos... / CANCEL", 13-18 s) while the scan kept running behind
it, and the finished results at the end. Hosting it and pasting the link into the declaration is
what remains.
The script grew two flags, both because an earlier attempt got it wrong:
- WARMUP=0 skips the warm-up and keeps the *cold* scan on camera. A warm scan of this library
takes 5.4 s - less than the notification shade takes to open - so the first phone take filmed an
empty shade, convincingly. The warm-up only ever existed for the emulator's FGS deadline
problem, which a real device does not have.
- INSTALL=0 records with the build already on the device, because MIUI does not allow adb
installs: the command hangs on two on-device prompts (Google Play Protect, then MIUI's own
Installieren) and auto-cancels as INSTALL_FAILED_USER_RESTRICTED if they go unanswered.
Also found by running this against a phone instead of an AVD:
- the trigger is chosen by mode - the intro's "Start scan" when the warm-up is skipped, the
results screen's top-bar "Scan" when it is not - and the lookup result is checked rather than
assumed,
- pacing is a variable now, tightened in the WARMUP=0 path, because the phone's cold scan is only
~20 s and the shade has to be open while it runs,
- the library check counts the demo folder rather than the whole device library: on a phone the
rest of the library is the owner's own photos and is supposed to be there,
- the warm-up became a function and does not run at all under WARMUP=0, and a stray reference to
its `attempt` variable (fatal under `set -u`) is gone.
Step 5 of the checklist now carries the recorded recipe, both flags with the reasons, and two
device facts that were invisible until a phone was attached: MIUI fails every synthetic tap with
INJECT_EVENTS until USB debugging is enabled, and toggling that resets the USB connection onto a
new /dev/bus/usb node. The emulator verdict is compressed to what it is - the platform kills the
app on every scan start there (am_crash ForegroundServiceDidNotStartInTimeException, no am_kill),
with QEMU's -icount clock dilation noted as the one thing that ever worked, and only once.
Chasing the video on the emulator found one lever that actually attacks the failure, and it is
worth writing down even though it did not hold:
emulator -avd phone ... -qemu -icount shift=1,sleep=off
Without -icount the guest clock is tied to the host's wall clock, so Android's five-second
startForegroundService() deadline means five real seconds however slowly TCG executes - which is
what kills the app here every time. With -icount the guest clock advances per executed
instruction, and it measured 2.30x slow (30.9 s host to 13.4 s guest uptime). On that boot a scan
start succeeded: the app's notification appeared five seconds after the tap and there was no
am_crash, the only success recorded on this machine.
It then stopped reproducing - later attempts produced neither a notification nor a crash. Two
possibilities, and the second is an app-side diagnosability gap: either the tap never reached the
app, or ScanService.start() swallowed a ForegroundServiceStartNotAllowedException, which it does
silently (it catches IllegalStateException and does nothing with it, so logcat shows nothing at
all). Notes say so, and point at SETTLE_SECONDS / WARM_SECONDS for scaling the waits, because a
dilated guest is slower at everything - uiautomator dumps included.
The script also retries a tap (up to three times, re-reading the coordinates) before falling back
to a cold restart, since on a slow emulator the window can still be settling while the
accessibility tree already lists the button - and a restart costs another cold start. The
recommendation stands: record the take on the phone.
The script could report "scan started" on runs where the app was being killed, which sent me
chasing a "mid-scan death" that never happened. The mistake was the signal: dumpsys activity
services lists a service *record* from the moment startForegroundService() is called, so it says
nothing about whether startForeground() was reached - and that gap is exactly what the platform's
deadline measures. logcat's events buffer shows what really happened on every attempt:
am_crash: [..., android.app.ForegroundServiceDidNotStartInTimeException,
Context.startForegroundService() did not then call
Service.startForeground(): .../ScanService]
am_proc_died: [...]
No am_kill, no low-memory record: not memory pressure, and not a crash in the app's own code. The
script now waits for the app's notification, which only exists once startForeground() has been
called, and prints that am_crash line when the service never starts, so a failure is never
mistaken for a success again.
Also in the script:
- let the emulator settle before the run (first-boot work competes for the same CPU) and give the
process time to load its classes before asking it to start a service inside the budget, both
overridable as SETTLE_SECONDS and WARM_SECONDS,
- give up with a non-zero exit and an explicit "record this on a real device" message rather than
carrying on to record a video whose scan never started,
- read the events buffer rather than the main one when reporting the reason.
Verdict for the docs, corrected: this capture cannot be done on a KVM-less emulator, and the
previous "second failure mode" was this one misread. The last crash landed on a process that had
been alive and idle for over a minute, so no amount of warming fixes it - while on the phone the
same build scans 5,363 photos in 20 s with the notification up the whole time. The take belongs on
the phone; the script is the checklist for what it has to show.
The script is a convenience, not a requirement: any Android 11+ phone can record the same
journey from the quick settings, with no adb and no tooling. Spell out the six steps so the
take can be done by hand if that is easier, with the two things a reviewer needs to be able to
follow - the trigger, and the service still working after the app leaves the screen.
The Console's Local processing options are Media transcoding, Importing/exporting and Other,
not the two-entry list the help page implies, so record which one this app takes and why the
other two are misdeclarations rather than near-misses:
- Media transcoding is converting media into a different format; the scan writes no media file
at all, it only reads photos to hash and compare them.
- Importing/exporting is moving files in or out of the app; the app's only write is its own
private hash index, which no other app ever sees - that is also what keeps "nothing leaves
the device" true.
- Other is described by Play as "specifically user-initiated work and not for regular system or
server-initiated tasks", which is exactly a scan the user taps Start scan for.
Worth spelling out because a use case that does not match the app is a misdeclaration, and the
description and the accompanying video have to support whichever option is chosen: "reads the
photo library and writes a small local index of file hashes" only fits Other.
The App content > Foreground service permissions form does not just want the justification that
was already written down. Per declared type it asks for four things, one of which is "a link to
a video demonstrating each foreground service feature. The video should demonstrate the steps
the user needs to take in your app in order to trigger the feature."
tools/record-fgs-demo.sh records exactly that journey on a device or emulator: the app on its
intro, the user tapping Start scan, Home, the ongoing notification visible in the shade while
the scan keeps running, and back into the app for the results. It loads the generated demo
library first so no real photo is in frame, writes a checkpoint PNG at each milestone so the
recording can be checked without decoding it, and takes the library off the device afterwards.
The output is git-ignored, like the screenshots.
Four things the script has to get right, each learned by getting it wrong first:
- bounds="[x1,y1][x2,y2]" cannot be parsed by stripping everything but digits: the "][ "
between the corners goes too, which glued y1 to x2 and produced coordinates like
"976 723061" - a tap that lands nowhere and reports no error at all.
- No single text matcher works: an exact match never finds the results card ("2 duplicate
groups"), while a substring match lets "Scan" match both "Start scan" and "Scanned in
39.7 s". Both matchers exist and each call site uses the right one.
- A finished scan leaves the app on its results screen, where the intro's button no longer
exists - tapping those stale coordinates silently does nothing.
- `set -e` plus start="$(failing command)" aborts a run with no message whatsoever, which
cost a whole debugging cycle.
Checklist step 5 now carries all four fields with paste-ready text: the description, the user
impact if the scan is deferred or interrupted (a scan is not deferrable work - the user is
waiting for a result, so there is no WorkManager/JobScheduler fallback), the use case
(Local processing: Other, the only dataSync option that describes a local, user-initiated
scan), and the video link.
Recording the take on this machine is not reliable, and the docs say so rather than pretending
otherwise, because both failures are platform-level rather than app-level:
- a cold app can miss Android's 5-second startForeground deadline and be killed, giving
ForegroundServiceDidNotStartInTimeException -> Force finishing activity, i.e. the launcher
where the scan should have started. The warm-up phase fixes it (a process that has started
the service once never misses), but the fallback path of restarting the app gives the warmth
back again, which is why the recorded scan is now triggered from the results screen's top-bar
"Scan" instead of the intro's button.
- the app also died mid-scan on the emulator, with the launcher on screen afterwards and no
exception of its own anywhere in logcat. No amount of warming fixes that one.
Neither reproduces on the real test device, where the same 22-photo library scans without
incident, so the take belongs on the phone. The script is the checklist for what it must show.
Two Console-facing subjects, both decided here instead of in the Console.
Tablet screenshots (7-inch and 10-inch)
--------------------------------------
Play keeps a separate screenshot slot per form factor and asks for at least four
screenshots on tablets, so the four phone captures did not cover them. Both sets are now
captured on AOSP Android 12 AVDs, over the same generated 22-photo demo library, and
normalised with FORM_FACTOR=tablet-7in|tablet-10in:
- 7-inch - Nexus 7 2013: 1200x1920 portrait, sw600dp, 320 dpi, four files
- 10-inch - Medium Tablet: 1600x2560 portrait, sw800dp, 320 dpi, four files
This machine has no /dev/kvm - the node cannot even be created, because mknod is refused
for the container as root - so the emulator ran in pure software emulation (-accel off
-no-window -gpu swiftshader_indirect): about 3 minutes to boot, 1-12 s per screenshot.
hide_error_dialogs was switched on and the animation scales off, because under TCG
SystemUI raises an ANR dialog that would otherwise sit in the frame; the 10-inch profile
boots landscape and its portrait rotation is 3, not 0. The scanning frame comes from a
screencap burst started before the tap, since 22 photos scan in seconds, and the tap
coordinates come from uiautomator dumps rather than guesswork.
The results screens are not staged: a finished scan auto-selects all but best, so "14
selected, 43.7 MB" is the default state. 2 duplicate groups, 16 of 22 photos, 43.7 MB
reclaimable, scanned in 39.7 s (7-inch) and 53.1 s (10-inch). Nothing except the demo
library was ever pushed to either AVD and it is deleted again afterwards, so no real
photo can appear in the tablet sets. Screenshots stay out of git, as before.
App pricing: a one-time paid app
--------------------------------
The one irreversible decision in this release. Play's rule: an app can go paid -> free,
but "once your app has been offered for free, the app can't be changed to paid" - a new
package name would be needed - so this had to be settled before the first upload, which
has not happened yet (versionCode 2).
Chosen over free with a Pro unlock because it is the only model that costs the app
nothing: no Billing dependency, no com.android.vending.BILLING permission, no Data
Safety answers, and the "no internet permission at all" claim survives untouched.
Checked rather than assumed: unpacking com.android.billingclient:billing:9.1.0 shows its
manifest declares only com.android.vending.BILLING and the billing service queries, so
in-app purchase would not have added INTERNET either. Billing 8+ is the current
requirement (latest 9.1.0) if that is ever revisited.
Price: base EUR 2.99 with a 14-day 50% launch sale at EUR 1.49. A EUR 1 base price was
considered and rejected: a sale can always be added, but a low anchor cannot be undone,
and a paid app's first transaction is its only transaction. Play's fee for a paid app's
initial purchase is 10% + 5% billing fee (~15%) for the first $1M/year of new-install
transactions in the EEA, so EUR 2.99 nets ~EUR 2.54 against ~EUR 0.85 at EUR 1. The sale
rules shape the launch: 1-14 days live, 30 days between one sale ending and the next
starting, and the app must be published before a sale can be created. The strikethrough
does appear in Germany, which is not one of the 12 EU states and the UK that hide it.
The listing copy had to stop saying "free": the short description is now "Find and remove
duplicate photos. Ad-free, works offline, no uploads." (69/80 chars) and the full
description gains one factual pay-once line plus a "sold through Google Play as a
one-time purchase" bullet (2,426/4,000). The price stays out of the title, icon and
developer name, because Play's metadata policy forbids price and promotional wording
there. PRIVACY.md gains a Purchases section - Google Play processes the payment, the app
has no payment code and no network access at all - and its date moves to 2026-10-14, so
the published copy needs re-pushing to Gitea.
No code changed, so the signed 1.0.1 .aab is untouched (sha256 f8e6a29a...). What is
left is Console work: a payments profile, the price and the launch sale on Products >
App pricing, and a promo-code copy to test the paid listing end to end.
The permission-set audit and the large-library scan test were both done, just not recorded:
aapt2 badging on the release APK matches the documentation table exactly (no INTERNET), and
the scans ran over 5,363 and 5,385 photos. Spell out what actually remains in the ROADMAP
paperwork row too - the keystore and versionCode are in place, so the open items are the
first upload + Play App Signing enrolment, the hosted privacy policy (still no git remote),
Data safety, the photo/video and foreground-service declarations, the App content questions
and the listing itself.
Uploading the bundle shows "This App Bundle contains native code, and you've not uploaded
debug symbols". Record the answer next to the upload step so it is not re-investigated on
every release:
- the bundle's only native code is libandroidx.graphics.path.so (4 ABIs, ~37 KB), which
arrives transitively through androidx.compose.ui:ui-graphics ->
androidx.graphics:graphics-path:1.0.1; this project builds no native code at all,
- ndk.debugSymbolLevel = "FULL" was tried and emits nothing for such a project (no
native-debug-symbols output, nothing added to the bundle), and
- the published AAR ships stripped libs only (symtab=0, no .debug_* sections, no symbol
entries at all), so there is no symbol file in existence to upload.
The warning is advisory and does not block review or rollout. Kotlin/Java stack traces are
already deobfuscated from the R8 mapping that rides inside the bundle, so the only thing
lost is symbolication of frames inside that Compose helper library.
Everything the Play forms ask for that can be settled from the working copy, settled:
- PRIVACY.md now carries the real contact address (softwareapp.hb@gmail.com), so the
policy publishes as-is. Hosting is the one step left that cannot be done from here,
because the repo has no git remote: step 5 of the checklist spells out the push +
GitHub Pages route and the URL it yields.
- Checklist: the screenshot item is done (four captures, 1080x2160, over the demo
library, no permission banner), the keystore and versionCode are recorded as already
in place - including the upload key's SHA-256 for the Play App Signing enrolment - and
the App access / Ads / remaining App content declarations are written down. The
first-upload release-notes question is answered too: versionCode is already 2, so
1.0.1 takes the *initial release* block, not the 1.0.1 one.
- The build facts were verified against the artefacts rather than the source: the
release APK reports versionCode=2, versionName=1.0.1, targetSdk=36 and exactly the
documented permission set, with no INTERNET in what actually ships.
- ROADMAP: the re-shoot row is struck out with the numbers that closed it, M10 no longer
claims the captures predate the demo library, and the demo library's removal is
recorded (MediaStore 5,386 -> 5,364; pruneTo drops the stale index rows next scan).
The staged state and the system confirmation dialog are documented as deliberately not
captured: staging clears the demo group, which would put real photos back in frame.
The three captures no longer show the "Photo access needed" banner, and the results
screen is a picture of the generated demo library instead of the developer's own.
Both caveats in the file are closed, so replace them with a short note on how the set
was actually taken:
- 01-empty: the grant went through the app's own "Grant access" button, so
checkSelfPermission is genuinely satisfied. This one needed no tap at all: results
and the selection live in ScannerViewModel and only the hash index is persisted, so
force-stop + relaunch returns to the empty state with the grant still in place.
- 02-scanning / 03-results: shot after tapping Scan over the demo library. The headline
group is 43.1 MB reclaimable, which out-ranks the biggest group already on the device
(39.8 MB), so the first group in frame is generated.
- 04-options is new: the Options DropdownMenu with the three sensitivity presets (it is
not a bottom sheet) and the trash-vs-permanent toggle.
The tick marks and the "37 selected" bar are the screen's default state, not a staged
tap -- a finished scan auto-selects all but best. MIUI still refuses pm grant and
synthetic taps (INJECT_EVENTS), so Grant access and Scan remain the two taps a human
has to make; the two screens still missing (the staged state and the system consent
dialog) are captured by watching for UI text changes instead, rather than by timing a
scripted tap.
store-assets/screenshots/ is git-ignored now, so the Graphics section and the
two blocker entries were describing a state that no longer exists. Point at
where the captures live instead, and say why screenshots stay out of git at
all: a screenshot of this app is a picture of a photo library.
The results screenshot is a screenshot *of a photo library*, so the committed
capture showed the developer's own photos. tools/make-demo-photos.py draws the
library instead: 22 procedural scenes (sunset, mountains, beach, conifers in
haze, dunes, city skyline, night sky, bokeh) with grain and a vignette, so there
are no people, no third-party content and nothing to license.
The duplicate structure is deliberate, because a folder of unrelated pictures
demonstrates nothing:
- headline group: one scene re-saved at falling quality and size until its
reclaimable size reaches --target-mb (45 MB). Results are ranked by
reclaimable bytes, so this group has to out-rank whatever is already on the
device or a real photo is the first thing back in frame.
- exact pair: two byte-identical files, for the SHA-256 path.
- singles: the other scenes, once each, so the library looks lived-in.
Verified with the app's own engine on the desktop before pushing anything:
22 images -> 2 groups (PERCEPTUAL 14 copies, EXACT 2 copies) with the six
singles left alone. Regeneration is byte-identical, the output path resolves
from __file__ so it runs from any cwd, and nothing is written outside the repo.
demo-photos/ is git-ignored, like fixtures/ and store-assets/raw-screenshots/.
STORE_LISTING.md documents the library, why the headline group has a target
size, the MediaProvider scan_file recipe and the cleanup step; ROADMAP, README
and the release checklist point at it.
An Android duplicate-photo finder. Pure-Kotlin core (SHA-256 + dHash hashing, exact and
perceptual union-find grouping, three sensitivity presets) with MediaStore access, a
persistent SQLite hash index and a tiered ScanEngine behind a Jetpack Compose UI.
No INTERNET permission, no ads, no analytics, no account.
Ships versionCode 2 / versionName 1.0.1, carrying the scan-stall hardening (BoundedCall
breaker) and verified against a 5,363-photo device library. 80 JVM unit tests, lint clean.
Release paperwork is in docs/ (Play checklist, store listing, edge-case testing), the
upload-ready Play graphics and screenshots in store-assets/, and the generators for those
assets in tools/.