64431d57570f794bd9aa093ba2a5c51b07c95a35
13 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
64431d5757 |
docs: answer the Advertising ID declaration from the dependency graph, not from the source
Play asks whether the app uses advertising ID "including any SDKs that your app imports that use advertising ID", and that clause is the whole difficulty: the ID comes from play-services-ads-identifier, which merges AD_ID into the manifest by itself - so one unrelated dependency could turn this answer into a false statement without a line of app code changing. The answer is No, and the checklist now records how that was established on 2026-10-14 against the artefacts rather than against intentions: - the release runtime classpath contains no gms/firebase/ads/billing/play-services entry at all, so there is no direct *or transitive* SDK that would ask for the ID - aapt2 dump badging on the release APK lists the seven permissions the audit table already documents plus the framework's DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION - no AD_ID, and no Privacy Sandbox permission either - the same check on base/manifest/AndroidManifest.xml inside the .aab finds nothing, which matters because the bundle is the artefact Play's own scanner reads - the shipped classes.dex contains no com.google.android.gms classes, and the app has no INTERNET permission, so an ID could not be transmitted even if one were read The last two rows are the ones to re-run after any dependency change: they are cheap, and this is the declaration a stray library answers wrongly on the app's behalf. The same reasoning answers Data safety's device-identifiers question, which the item now says out loud. ROADMAP: the remaining-paperwork row names the advertising ID question rather than leaving it implied by "ads". |
||
|
|
68dcbc5db6 |
docs: the video's link is its Gitea page, not the raw URL, which its own CSP blocks
Checked the way a browser does it rather than the way curl does, and curl had given a false green: Gitea sends `Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; sandbox` with every /raw/ response. That is deliberate - it is what makes serving raw HTML and SVG safe - but it also blocks the media load when a browser navigates straight to a video, so the tab stays blank with no error. The same URL plays fine as a subresource, which is exactly why the file's page in Gitea shows a player: that page embeds <video controls src=".../raw/branch/main/...mp4">. So the declaration's link is the /src/ page URL, and /raw/ is documented as the curl-and-download URL instead. Everything verified about both URLs is written down in step 5 - including the two ways to get a player with no Gitea chrome around it, if that is ever wanted: an unlisted YouTube upload, or dropping the header for this one path on the Gitea host's Caddy proxy, which must stay scoped to store-assets/ precisely because that header is what protects raw HTML and SVG. The file itself was never the problem: 200, video/mp4, Content-Disposition: inline, bytes sha256-identical to the recording, and Range: bytes=0-99 -> 206 with the matching Content-Range. |
||
|
|
1cb188a56b |
docs: follow the asset publication through the documents that described it
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. |
||
|
|
52cf2fc85a |
feat: record the FGS video on the real device, and fix what the take exposed
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.
|
||
|
|
179bc2cc33 |
fix: prove the FGS video's scan start by notification, not by the service record
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.
|
||
|
|
1ce2f2e1f4 |
feat: record the foreground-service video Play's declaration asks for
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.
|
||
|
|
f45e3b79ab |
docs: cover the tablet screenshot slots and settle app pricing
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. |
||
|
|
89ddf3bb1d | docs: point the privacy-policy step at the published Gitea remote | ||
|
|
f17b40c608 |
docs: tick the two checklist items that were already satisfied
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. |
||
|
|
55101886cb |
docs: finish the Console pre-flight and retire the screenshot blocker
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. |
||
|
|
ca8fdd414d |
docs: stop describing the store screenshots as committed
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. |
||
|
|
3581ffc6bb |
Add a generated demo photo library for store screenshots
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. |
||
|
|
4706e2669c |
Initial commit: Foto Dup Finder 1.0.1
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/. |