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