Files
SoftwareApp 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.
2026-10-14 13:28:49 +02:00

31 KiB
Raw Permalink Blame History

Play Store listing (M10)

Everything the Main store listing page in the Play Console asks for, ready to copy. The graphics are generated from the app's own vectors, so they can be regenerated after any branding change instead of being edited by hand.

See PLAY_RELEASE_CHECKLIST § 4 for where these fit in the release.

Graphics

Generated into store-assets/ inside the repository, never into $HOME, and committed: the icon, the feature graphic, the twelve normalised screenshots and the foreground-service demonstration video all live in git, versioned with the release they belong to. That is deliberate — Play wants a link to the demonstration video, so an asset has to be reachable without a login, and once one is, it is simpler to keep the whole listing beside the code that produces it.

Publishing them is safe because of where they are shot: every published frame contains the generated demo library and nothing else (tools/make-demo-photos.py), so the screenshots show the app over that library. The phone set was re-checked frame by frame before publication; the sole trace of the test device's own 5,385-photo library in it is a count in the summary card. What stays out of git is the scratch around them — the raw adb captures (store-assets/raw-screenshots/), the demo photos themselves, and the recording script's milestone PNGs. Regenerate any of it with the commands below and upload straight from disk.

Screenshots live one directory per form factor, because Play gives phones, 7-inch tablets and 10-inch tablets a slot each (Main store listing › Graphics › Phone / 7-inch tablet / 10-inch tablet):

store-assets/raw-screenshots/{phone,tablet-7in,tablet-10in}/   git-ignored `adb` captures
store-assets/screenshots/{phone,tablet-7in,tablet-10in}/       the files that get uploaded — committed
./tools/make-store-assets.sh                                # icon + feature graphic -> store-assets/
./tools/make-store-screenshots.sh                           # phone    -> screenshots/phone/
FORM_FACTOR=tablet-7in  ./tools/make-store-screenshots.sh   # 7-inch  -> screenshots/tablet-7in/
FORM_FACTOR=tablet-10in ./tools/make-store-screenshots.sh   # 10-inch -> screenshots/tablet-10in/
python3 tools/make-demo-photos.py                           # synthetic photo library -> demo-photos/ (git-ignored)

Committing store-assets/ is also what publishes it: Gitea serves every committed file without a login, and pushing the commit is the deployment step — there is no separate upload, so a re-shot screenshot or re-recorded video goes live the moment it is pushed. There are two URL shapes, and which one is right depends on what does the reading:

https://gitea.softwareapp-hb.de/fishfacegit/foto-duplicate-finder/raw/branch/main/<path>   the bytes — curl, downloads
https://gitea.softwareapp-hb.de/fishfacegit/foto-duplicate-finder/src/branch/main/<path>   a page — what a browser opens

For media that distinction is not cosmetic: Gitea sends Content-Security-Policy: default-src 'none' with /raw/ responses, so a video opened at a raw URL shows a blank tab, while the same file plays on its src page. Play's foreground-service declaration therefore takes the page URL as its video link; the reasoning and what was verified about both URLs are in PLAY_RELEASE_CHECKLIST step 5.

Asset Play's spec Result Source
App icon 512×512, 32-bit PNG with alpha, ≤ 1 MB store-assets/icon-512.png — 512×512, RGBA, 6 KB tools/store-assets/icon.svg, mirrored from app/src/main/res/drawable/ic_launcher_background.xml + ic_launcher_foreground.xml (paths and group transforms are identical, so the store icon is the launcher icon)
Feature graphic 1024×500, JPEG or 24-bit PNG without alpha store-assets/feature-graphic-1024x500.png — 1024×500, RGB tools/store-assets/feature-graphic.svg
Phone screenshots JPEG or 24-bit PNG without alpha, shorter side ≥ 320 px, longer side ≤ 3840 px, longer side ≤ 2× shorter side store-assets/screenshots/phone/0{1,2,3,4}-*.png — 1080×2160, RGB tools/make-store-screenshots.sh, from raw adb exec-out screencap captures in store-assets/raw-screenshots/phone/
7-inch tablet screenshots same image rules as phone, but Play asks for at least 4 screenshots per large-screen form factor and suggests a 16:9 landscape / 9:16 portrait frame store-assets/screenshots/tablet-7in/0{1,2,3,4}-*.png — 1200×1920, RGB FORM_FACTOR=tablet-7in tools/make-store-screenshots.sh, from Nexus 7 2013 AVD captures in store-assets/raw-screenshots/tablet-7in/
10-inch tablet screenshots same image rules as phone; 4+ recommended store-assets/screenshots/tablet-10in/0{1,2,3,4}-*.png — 1600×2560, RGB FORM_FACTOR=tablet-10in tools/make-store-screenshots.sh, from medium_tablet AVD captures in store-assets/raw-screenshots/tablet-10in/

Three details worth keeping in mind:

  • The icon is full bleed: the adaptive-icon background covers all 108 dp, so every pixel of the PNG ends up opaque and Play's mask can crop to any shape without showing a gap.
  • A phone screenshot at the test device's native 1080×2400 is 2.22:1 and therefore rejected — Play's limit is 2:1. The normaliser crops the status bar (93 px) and the navigation bar (130 px) and clamps to 1080×2160, which removes system chrome only, never app content. Re-measure both insets if you shoot on another device and pass STATUS_BAR_PX / NAV_BAR_PX.
  • A tablet frame has no such problem, which is why the tablet form factors default to 0 px of crop: every tablet profile the SDK ships is between 16:9 and 16:10 — 7" WSVGA is 1024×600, 10.1" WXGA is 1280×800, and Nexus 10 / Medium Tablet / Pixel Tablet are 2560×1600 — so a tablet capture is already inside the 2:1 cap. Cropping the bars there is cosmetic, so it is opt-in via STATUS_BAR_PX / NAV_BAR_PX. The 2:1 clamp still runs, which is what makes the tool safe on a device whose frame is taller than 2:1.

Screenshots

Phone (1080×2160)

Captured on the test device (1080×2400, Android 12 / MIUI), normalised into store-assets/screenshots/phone/ at 1080×2160:

File Screen
01-empty.png Intro / empty state before the first scan — access already granted, so there is no banner and Start scan is enabled
02-scanning.png Scanning progress — “Looking for visually similar photos…” 5353 / 5385, live Cancel
03-results.png Results over the demo library — 17 duplicate groups, 54 of 5385 photos, 83.5 MB reclaimable, the generated 14-copy group first, BEST badge, selection bar
04-options.png Options menu (DropdownMenu off the top bar) — the three sensitivity presets with the ✓ on the active Balanced (recommended), and the trash-vs-permanent toggle

The tick marks and the 37 selected · 83.5 MB bar in 03-results.png are not a staged tap: a finished scan auto-selects all but best across every group (ScannerViewModel.autoSelection), so that is the screen's default state.

The two earlier caveats are closed. The current captures were re-shot through the app's own permission flow and over the generated demo library:

  1. No “Photo access needed” banner. The runtime grant went through the app's Grant access button, so checkSelfPermission is genuinely satisfied. The test device (Xiaomi M2101K7AG / MIUI) still refuses pm grant, install -g and synthetic taps (SecurityException: Injecting to another application requires INJECT_EVENTS), so Grant access and Scan are the two taps a human has to make. Enabling USB debugging (Security settings) in the MIUI developer options is what would make the whole capture run scriptable.
  2. No real photos in frame. 03-results.png was shot over the demo library below, and its headline group (43.1 MB reclaimable) outranks the biggest group already on the device (39.8 MB), so the first thing on screen is a generated sunset instead of somebody's library.

Only Scan needed a tap in the end. 01-empty.png did not: scan results and the selection live in ScannerViewModel (only the hash index is persisted), so force-stopping and relaunching the app returns to the empty state with the grant still in place:

adb shell am force-stop de.softwareapp_hb.fotodup.finder.debug
adb shell am start -n de.softwareapp_hb.fotodup.finder.debug/de.softwareapp_hb.fotodup.finder.MainActivity
adb exec-out screencap -p > store-assets/raw-screenshots/phone/01-empty.png

7-inch and 10-inch tablets (1200×1920 and 1600×2560)

Play keeps a separate screenshot slot per form factor, so the phone set does not fill the tablet rows: Graphics lists Phone, 7-inch tablet and 10-inch tablet (plus Chromebook, Android TV, Wear OS, Automotive and XR, none of which this app ships to). For tablets and Chromebooks Play asks for a minimum of four screenshots that "demonstrate your in-app experience" — up to 8 per device type — under the same image rules as the phone slot (JPEG or 24-bit PNG, no alpha, 320–3840 px, longer side ≤ 2× shorter side), with a 16:9 landscape / 9:16 portrait frame highly recommended rather than required.

Captured on two AOSP Android 12 AVDs, because there is no real tablet here: Nexus 7 2013 for the 7-inch slot (1200×1920 portrait, sw600dp, 320 dpi) and medium_tablet for the 10-inch slot (1600×2560 portrait, sw800dp, 320 dpi). This machine exposes no /dev/kvm, so both ran in pure software emulation (-accel off -no-window -gpu swiftshader_indirect): slow — about 3 minutes to boot and 1–12 s per screenshot — but rendering, MediaStore and touch injection all behave.

File 7-inch — tablet-7in/ 10-inch — tablet-10in/ Screen
01-empty.png 1200×1920 1600×2560 Intro / empty state — access already granted, so there is no banner and Start scan is enabled
02-scanning.png 1200×1920 1600×2560 Scanning — “Looking for visually similar photos…” at 20 / 22, live Cancel
03-results.png 1200×1920 1600×2560 Results over the demo library — 2 duplicate groups, 16 of 22 photos, 43.7 MB reclaimable (scanned in 39.7 s on the 7-inch, 53.1 s on the 10-inch), BEST badge, selection bar
04-options.png 1200×1920 1600×2560 Options menu — the three sensitivity presets with the ✓ on Balanced (recommended), and the trash-vs-permanent toggle

The 14 selected · 43.7 MB bar in 03-results.png is not a staged tap: a finished scan auto-selects all but best across every group (ScannerViewModel.autoSelection), so it is the screen's default state — same as on the phone. The figures are the generated demo library's own (22 photos, 16 of them duplicates), not the phone capture's 5,385-photo ones. Nothing except the demo library was ever pushed to either AVD, and it is deleted again after the captures, so no real photo can appear in the tablet set.

Two things made the tablet captures cheap to take:

  • No chrome maths. No tablet frame is anywhere near 2:1, so both tablet form factors default to 0 px of crop — the four tablet PNGs are exactly the frame screencap returned, only flattened to 24-bit. Set STATUS_BAR_PX / NAV_BAR_PX only if you want the bars gone; measure them on the tablet first, they are not the phone's 93/130.
  • No new screens. The app has no large-screen branching at all: res/ has no values-sw600dp or layout-sw600dp, the UI code uses no WindowSizeClass / BoxWithConstraints, and the activity has no screenOrientation lock — a tablet shows the same single-column scanner, wider. The shot list is therefore the same four files, and portrait keeps the three sets parallel.

One knob needed no attention: a fresh AVD has an empty photo library, so the demo group is the biggest one by construction and --target-mb needs no tuning — that only matters on a tablet that already holds photos (see the demo-library section below).

avdmanager / sdkmanager / emulator live in $ANDROID_SDK_ROOT (cmdline-tools/latest/bin for the first two). The device ids are the ones avdmanager list device reports: Nexus 7 2013 is 1200×1920 xhdpi and medium_tablet is 2560×1600 xhdpi (Pixel Tablet class).

# 1. The two tablet AVDs (AOSP Android 12 images; ~4 GB of SDK downloads).
avdmanager create avd -n tablet7  -d "Nexus 7 2013" -k "system-images;android-31;default;x86_64"
avdmanager create avd -n tablet10 -d medium_tablet  -k "system-images;android-31;default;x86_64"

# 2. Boot it. No KVM here, so: no window, no audio, SwiftShader, no snapshot, software CPU.
emulator -avd tablet7 -no-window -no-audio -no-boot-anim -no-snapshot \
    -gpu swiftshader_indirect -accel off -memory 2048 -cores 4 &

# 3. Emulator housekeeping. Under TCG SystemUI is slow enough to raise an ANR dialog that would sit
#    in frame, and `medium_tablet` boots landscape — portrait is rotation 3 on it, not 0.
adb shell settings put global hide_error_dialogs 1
adb shell settings put global window_animation_scale 0
adb shell settings put global transition_animation_scale 0
adb shell settings put global animator_duration_scale 0
adb shell settings put system accelerometer_rotation 0
adb shell settings put system user_rotation 3    # tablet10 only; the Nexus 7 is portrait-native

# 4. The phone set's prerequisites: the app, the permission, the demo library.
adb install -r app/build/outputs/apk/debug/app-debug.apk
adb shell pm grant de.softwareapp_hb.fotodup.finder.debug android.permission.READ_EXTERNAL_STORAGE
python3 tools/make-demo-photos.py
adb push demo-photos/foto-dup-demo /sdcard/DCIM/foto-dup-demo
adb shell content call --uri content://media --method scan_file --arg /sdcard/DCIM/foto-dup-demo

# 5. Capture. `01` is one screencap. The other three need a tap, so their coordinates come from
#    `uiautomator dump` instead of guesswork, and a screencap burst *started before the tap* is what
#    catches the short scanning screen — 22 photos scan in seconds even under emulation.
adb shell am force-stop de.softwareapp_hb.fotodup.finder.debug
adb shell am start -n de.softwareapp_hb.fotodup.finder.debug/de.softwareapp_hb.fotodup.finder.MainActivity
adb exec-out screencap -p > store-assets/raw-screenshots/tablet-7in/01-empty.png
#    ... burst frames into /tmp, tap "Start scan" (7-inch 600 1155, 10-inch 800 1465), keep the frame
#        showing "Looking for visually similar photos… N / 22" for 02 and the one after it for 03,
#        then tap "Options" (7-inch 1118 112, 10-inch 1518 112) and keep the frame showing the menu.

# 6. Normalise — then repeat everything with FORM_FACTOR=tablet-10in on the tablet10 AVD.
FORM_FACTOR=tablet-7in ./tools/make-store-screenshots.sh

# 7. Take the demo library back off the AVD, exactly as on the phone.
adb shell rm -rf /sdcard/DCIM/foto-dup-demo
adb shell content call --uri content://media --method scan_file --arg /sdcard/DCIM/foto-dup-demo

The 16:10 frame a tablet produces (1200×1920 or 1600×2560) is inside Play's 2:1 cap but is not the 16:9/9:16 shape the highly recommended bullet asks for. Cropping app content to fake 9:16 would be worse than missing a soft recommendation, so the captures go up as shot.

Demo library: generated photos, so none of anyone's real ones are published

03-results.png is a screenshot of a photo library, so whatever is on the test device ends up in the store listing along with it. tools/make-demo-photos.py exists so that library can be generated instead of borrowed: every scene is drawn procedurally — sunset, mountains, beach, conifers in haze, dunes, city skyline, night sky, bokeh — so there are no people, no third-party content and nothing to license. It writes to demo-photos/foto-dup-demo/, which is git-ignored and regenerated on demand, exactly like fixtures/.

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 (default 45 MB),
  • exact pair — two byte-identical files, for the SHA-256 path,
  • singles — the other scenes, once each, so the library looks lived-in.
python3 tools/make-demo-photos.py
adb push demo-photos/foto-dup-demo /sdcard/DCIM/foto-dup-demo
adb shell content call --uri content://media --method scan_file \
    --arg /sdcard/DCIM/foto-dup-demo

Two non-obvious details, both load-bearing:

  • Why the headline group has a target size. Results are ranked by reclaimable bytes, so the first group on screen is the largest one. A demo group smaller than anything already on the device would be pushed below the fold and a real photo would be back in frame. The default --target-mb 45 yields a headline group of 43.1 MB reclaimable, which clears the test device's own 39.8 MB with room to spare; raise --target-mb if you capture on a library whose biggest group is larger.
  • scan_file on the folder, not on the volume. It indexes just that path (API 30+) instead of forcing a MediaStore-wide rescan on a 5,000-photo device. It does recurse into the folder, and re-running it after the folder is deleted drops those rows again — both verified on the test device.

Check the grouping before pushing, rather than reading it off the phone: the desktop harness runs the app's real engine over a scaled copy of the library and reports the two intended groups and nothing else.

magick mogrify -resize 1024x -quality 90 -path build/demo-validate-raw demo-photos/foto-dup-demo/*.jpg
python3 tools/normalise-for-local-grouping.py build/demo-validate-raw build/demo-validate
./gradlew :app:testDebugUnitTest --tests '*LocalGroupingTest*' \
    -DfotoLocal.dir="$PWD/build/demo-validate"
# images=22 groups=2 -> PERCEPTUAL 14 copies, EXACT 2 copies; the six singles stay ungrouped

Cleanup, once the captures are done:

adb shell rm -rf /sdcard/DCIM/foto-dup-demo
adb shell content call --uri content://media --method scan_file --arg /sdcard/DCIM/foto-dup-demo

This was run on the test device as soon as the four captures were taken: MediaStore went from 5,386 to 5,364 image rows and the 22 demo rows are gone. The matching rows in the app's own hash index are dropped by PhotoIndexStore.pruneTo on the next scan — the index is keyed by MediaStore id, so there is nothing else to reset.

Two further screens were weighed up and deliberately left out of the set: the staged state (“All duplicates are ready to go”) after Remove all selected, and Android's confirmation dialog for the batch that Apply launches. Staging every selected row also clears the demo group out of the list, which promotes the device's own 39.8 MB group into frame — so those captures could not be committed or published. (Dismissing the dialog is non-destructive, so nothing is lost by not going that far.)

Captures are taken by hand, because each of these screens needs a tap and MIUI only accepts taps from a human. A loop that takes a uiautomator dump and screencaps whenever the UI text changes is enough, so the capture never depends on tap timing; that is how 04-options.png was taken.

App pricing

Decision: a one-time paid app — a free download is off the table. Foto Dup Finder is sold on Google Play at a single price: no free tier, no trial, no subscription, no in-app purchase. Nothing in this repository changes because of that decision — it lives in the Play Console, and the app carries no Billing code at all (see PLAY_RELEASE_CHECKLIST step 4).

Why paid rather than free-with-a-Pro-unlock:

  • The app is a single-purpose utility with no running costs — no servers, no sync, no accounts — so there is nothing a subscription would fund and nothing to keep charging for.
  • Play Billing would merge com.android.vending.BILLING into the manifest and put a purchase screen into an app whose whole pitch is "no account, no tracking, nothing leaves the device". A price on the listing costs zero permissions and changes zero Data Safety answers — a claim no other monetisation of this app can make.
  • There is no ad SDK and there never will be: an ad SDK adds the INTERNET permission, and the merged-manifest and Data Safety claims below would immediately stop being true.

⚠️ This is the last moment the choice is still reversible. Play's rule, from Set up your app's prices: “You can change your app from paid to free. Once your app has been offered for free, the app can't be changed to paid. If you want to charge for the app, you need to create a new app with a new package name and set a price.” Nothing has been uploaded yet (versionCode 2 is still the first release), so a paid app is still possible; after the first upload to any track it is not. Going free later stays one click.

Price: one-time, base €2.99 EUR, launched with a 14-day 50% sale at €1.49. Play derives every other market from the base price and applies local pricing patterns, so it is one number to maintain rather than a per-country table.

Why the base price is not simply the impulse price. A €1 base was considered and rejected: Play's sales let a paid app be discounted whenever you like, but nothing lets you un-discount it. Starting at €1 spends the only lever this model has — raising the price afterwards looks like gouging to the buyers who told people "it's a euro app" — and there is no free tier or in-app purchase to recover the difference later, because a paid app's first transaction is its only transaction. At €2.99 the launch sale is the €1.49 impulse price, and the real price is waiting when it ends.

The arithmetic, at Play's current fee for a paid app's initial purchase (10% + 5% billing fee ≈ 15% for the first $1M/year of new-install transactions in the EEA):

Price You net (≈) Units needed to match €2.99
€2.99 (base) €2.54 1×
€1.49 (launch sale) €1.27 2×
€1.00 (rejected) €0.85 3×

Three Console details matter:

  • Leave "Default price includes tax" off unless you mean it. Off, VAT is added on top: a German buyer sees €3.56 for the €2.99 base and €1.77 during the sale. On, the sticker price is the gross price and Play takes the tax out of it.
  • A country you do not set a price for is a country where the app is unavailable. Play also enforces a per-country price range — see currencies and price ranges allowed by country.
  • Strikethrough is not shown everywhere. In 12 EU member states (Belgium, Croatia, Czech Republic, Denmark, Estonia, France, Greece, Hungary, Latvia, Netherlands, Poland, Sweden) and the UK, buyers see the discounted price with no reference to the original. Germany is not one of them, so "was €2.99" is real for a German buyer — which is why the sale is worth running.

The launch sale, and the rules that shape it

Play's rules for a paid-app sale are tight enough to design around:

  • 1–14 days per sale, and 30 days must pass between the end of one sale and the start of the next. The launch offer is therefore one 14-day window, not "half price for the first month" — a deadline-driven event rather than the normal price.
  • The app must be published before a sale can be created (Monetise › Products › App pricing › Sales › Create sale), so the order is publish first, then schedule the sale; the start date can be as early as the next day.
  • The discount is a fixed price or a percentage of 10–100%, the discount must be at least 10%, and the sale price must stay inside the accepted price range. A 100% ($0) sale exists if the app should be briefly free instead.
  • The sale name lives only in the Play Console; buyers never see it.

The real cost of this route: no free trial. A paid app cannot be installed before it is bought, so the listing has to do all the convincing. The two instruments that do not require going free are the sale above and promo codes — a giveaway copy for named testers and reviewers, available at any time. Use a promo code, never a real purchase, for your own end-to-end buying test: PLAY_RELEASE_CHECKLIST step 4.

Store listing copy

App name (max 30 characters)

Foto Dup Finder

Short description (max 80 characters)

Find and remove duplicate photos. Ad-free, works offline, no uploads.

Alternative, if the no-account promise should lead:

Find and delete duplicate photos. No ads, no uploads, no account.

Neither option may say "free": the app is paid, so the claim would simply be false. Play's metadata policy also bans price and promotional wording in the app title, icon and developer name ("free for limited time only" is its own example), which is why the price story below lives in the description body and is written as a plain statement of fact — no discounts, no urgency, nothing that reads as a promotion.

Full description (max 4000 characters)

Find and remove duplicate photos, then get the space back. Foto Dup Finder does one
job and does it without ads, without an account and without ever uploading anything.

WHY IT IS DIFFERENT
• Pay once, then own it. No ads, no banners, no interstitials, no subscriptions and no
  in-app purchases — there is nothing else to buy, now or later.
• No internet permission at all, so the app has no way to upload your photos.
• No account, no sign-in, no tracking, no analytics.

WHAT IT DOES
• Finds exact copies: files that are byte-for-byte identical, even when their names
  differ.
• Finds near-duplicates: the same picture saved twice, or two shots of the same
  scene. A perceptual hash compares how the images actually look rather than only
  their bytes, so a re-compressed or slightly re-cropped copy is still caught.
• Suggests what to keep. The best copy of each group is marked BEST, and
  "Select all but best" marks the rest in a single tap.
• Lets you choose how far to go: Exact copies only, Balanced (recommended) or
  Aggressive.
• Deletes safely. Every removal goes through Android's own confirmation dialog, and
  you choose between moving photos to the trash (recoverable) and deleting them
  permanently. Nothing is removed without your confirmation.
• Copes with a messy library. RAW files and unreadable or corrupt images are skipped
  and reported instead of stopping the scan.
• Much faster the second time. File hashes are remembered on the device, so
  re-scanning an unchanged library takes a fraction of the first run.

HOW IT WORKS
1. Grant photo access — everything runs on your device.
2. Tap Start scan. A library of several thousand photos is done in seconds.
3. Review the groups, keep the copies you want and select the rest.
4. Tap "Remove all selected" and confirm once for the whole batch.

YOUR PHOTOS STAY YOURS
Foto Dup Finder declares no internet permission, ships no advertising or analytics
SDKs, and stores nothing besides a local index of file hashes so the next scan can be
quick. No photo ever leaves your phone, and uninstalling the app removes the index
with it.

GOOD TO KNOW
• Requires Android 8.0 or newer.
• Sold through Google Play as a one-time purchase — no subscription, no in-app
  purchases, no account.
• Photos moved to the trash can be restored from your gallery app until the trash is
  emptied (Android 11 and newer).
• Works fully offline, in flight mode, always.

Every claim above is checkable against this repository: no INTERNET permission in the merged manifest, no ad or analytics dependency in gradle/libs.versions.toml, minSdk 26 in app/build.gradle.kts, and the second-run speed-up is the persistent hash index in data/PhotoIndexStore.kt.

The price is the one claim that is not in here, because it is a Play Console setting (Products > App pricing) rather than code. What is checkable is the rest of the monetisation story — the absence of it: no Billing dependency in gradle/libs.versions.toml, and no com.android.vending.BILLING in the merged manifest (see PLAY_RELEASE_CHECKLIST step 5).

Category and tags

  • Category: Tools (alternative: Photography — Tools matches the one-job utility framing better, and Photography is more crowded). Choose the category first: it shapes the Suggested tags list.

  • Tags: a maximum of five, chosen from Play's own list (Suggested tags, then Search tags / the category filter / Other tags) — there is no free text, and using fewer than five is fine: Google asks for the most relevant tags, and a tag a user cannot connect to the listing costs more than a missing one.

    The live list (checked 2026-10-14) contains no cleanup-specific vocabulary at all — no Storage, Photo cleanup, Duplicate photos, Photo management or Photo gallery. Across the whole photo domain Play offers only Photography, Photo editor and Photo frame, so exactly one tag is both available and honest:

    Tag Verdict Reason
    Photography ✅ use The app's subject is the user's photo library, and someone who reads "find duplicate photos" sees immediately why the tag is there. This is the only photo-domain tag that applies. Available while the category is Tools; if the category is Photography it is the category itself and therefore redundant.
    Privacy & security ⚠️ optional Defensible: the listing leads with "no internet permission at all", "no account, no tracking, no analytics", and that is visible to an unfamiliar user — Google's test. But it pulls in people browsing privacy tools who are not after a photo cleaner. Add it only if that discovery surface is wanted.
    anything else ❌ Nothing else in the list describes this app; see below.

    Tags cannot express "duplicate" at all, so search discovery rests on the title and short description keywords, which already say "duplicate photos" — not on this field.

  • Tags deliberately not used: Photo editor, Photo frame, Camera, Video editing, Video players & editors, Network connectivity, Wi-Fi, Virtual private network (VPN). The app cannot edit or take photos and does not scan videos (README › Known limitations), and a connectivity tag would contradict the merged manifest, which declares no INTERNET permission. Photo editor is the tempting one — it is what most duplicate-photo apps tag themselves as — but a user arriving from it expects editing tools, which is a one-star review on a listing that has no ratings to absorb one yet.

  • Accessibility tags: none (yet). They are a compatibility claim and the UI does not earn one: ui/components/PhotoThumbnail.kt holds the only contentDescription in the app, and the scan-progress text has no liveRegion, so progress is not announced automatically. TalkBack support is a follow-up, not a listing decision.

  • Contact details: softwareapp.hb@gmail.com — the same address as in PRIVACY.md, so the policy can be hosted as-is.