01
Screenshots are notes we never clean up
Most screenshots are temporary. A parking bay, a boarding pass, a one-time code, a QR ticket, a product to compare later. They are useful for an hour or a day, then they sit in the gallery forever, burying the photos that matter and filling storage.
Neither Android nor the popular gallery apps lets you give a file an expiry date. Cleaning up means scrolling back through hundreds of images and deciding one by one, long after you remember why you took them.
The goal
02
One tap at the moment of capture
Shot Clock runs quietly in the background. When a screenshot lands, a small floating bubble appears on top of whatever app you are in.
-
1 · Capture
Take a screenshot
As usual, with any button combination or gesture.
-
2 · Decide
Tap 1 hour, 1 day or keep
The bubble fades away if you ignore it. Times and colours are configurable.
-
3 · Forget
It deletes itself on time
The home screen shows what is scheduled and how much space has been freed.
The simplicity is the product. Every hard problem in this project exists to keep that one tap reliable on real phones.
03
Modules with hard boundaries
Even as a solo project, I built it the way I would build a team codebase.
Each feature is split into a small api module that other code may depend on,
and an impl module nobody else can see.
Only the app module knows every implementation, and Hilt wires them together.
impl uses: overlay api
impl uses: home api, overlay api
impl uses: no other feature
The payoff is concrete. Onboarding can start the detection service through the overlay API without knowing a service exists. Changing how the service survives touched one module and recompiled almost nothing else. A script regenerates the module graphs, so the documentation cannot drift from the build.
Data stays simple on purpose: Room holds the scheduled deletions, DataStore holds preferences and the running total of space saved, and a WorkManager job deletes whatever is due.
04 / Hard part 1
Noticing a screenshot within a second
Android has no "screenshot taken" broadcast for third-party apps.
Shot Clock watches the MediaStore image collection with a ContentObserver
from a foreground service, and decides for every change whether it is a new screenshot.
That decision is harder than it looks, because the observer fires for everything and often more than once:
- Half-written files. On Android 10 and later the system inserts the row before the image is finished. Rows still marked pending are skipped until the next change.
- Old images. Deletions, edits and gallery syncs also fire the observer. Only images added in the last 10 seconds count.
- Duplicates. One screenshot can trigger several callbacks. Remembering the highest media ID already handled makes the bubble appear exactly once.
- Manufacturers. Pixel, Samsung and others save to different folders with different names, so the check looks at file name, absolute path and relative path.
if (id <= lastProcessedId) return // seen it already
if (isPending) return // still being written
if (now - dateAdded > 10) return // not a new capture
if (isScreenshot(name, path, relativePath)) {
lastProcessedId = id
overlay.show(path, name, uri)
}
05 / Hard part 2
Staying alive when Android wants you dead
Detection only works while the service runs, and modern Android is built to stop exactly this kind of app. Doze, app standby buckets, manufacturer battery savers and memory pressure all kill background work, and since Android 12 an app in the background is not even allowed to restart its own foreground service.
No single mechanism survives every phone, so the service is guarded by four layers, each catching what the one above misses.
START_STICKY asks the system to recreate the service after a low-memory kill.
Checks every 15 minutes and restarts the service if it is gone. Falls back to an inexact alarm when the user has not granted exact alarms, which Android 13 denies by default.
A periodic job that survives reboots and app updates, scheduled once and never duplicated.
Registered as a real alarm clock, which manufacturers are reluctant to suppress, so it gets through deep sleep. Capped at three a day to avoid looking abusive.
Two details mattered more than the layers themselves. First, every restart path treats a refused start as normal: on Android 12 and later a watchdog may not be allowed to start the service, so it quietly gives up and lets the next layer, or the user opening the app, try again. Second, the home screen offers to exempt the app from battery optimisation and explains why, which helps more than any amount of code on aggressive manufacturer builds.
Lesson from production
06 / Hard part 3
Deleting files without asking every time
Since Android 11, an app cannot delete another app's images without the user's consent, and screenshots belong to the system. Asking for confirmation every time would defeat the point of the app, so I compared the three ways to get that consent.
| Option | What the user sees | Catch |
|---|---|---|
| All files access | One scary system screen granting access to every file on the phone. | Far more access than the app needs, and Google Play restricts it to apps like file managers. |
| MediaStore delete request | A system dialog for every deletion batch. | Needs the user present, so it cannot run on a schedule. It breaks the core promise. |
| Folder grant chosen | During onboarding, pick the Screenshots folder once with the system file picker. | One extra onboarding step, which needed a guided dialog to explain. |
The folder grant is persistent and scoped to exactly one folder, so the deletion job can run unattended while the app never sees the rest of the phone. On Android 10 and earlier the job falls back to a direct file delete, and if a file has already gone it simply drops the schedule entry.
Moving off all-files access meant rewriting onboarding into three steps with clear permission cards, which turned a policy problem into a better first-run experience.
07
Shipping like a team of one
Releases are fully automated, so shipping a fix is a branch and a button rather than an afternoon of manual steps.
-
01
Pull request
Unit tests, lint, and instrumented tests on emulators at API 27, 30 and 36.
-
02
release/vX.Y.Z
CI checks the branch matches the version, builds a signed bundle and uploads it to closed testing.
-
03
Verify
The build is tagged as a release candidate with the exact bundle attached, then tested on devices.
-
04
Promote
A manual workflow promotes that same build to production, with a staged rollout percentage.
Version codes come from the commit count, so any tag rebuilds to the same number and codes always increase. Release notes ship in five languages with every version.
08
Outcome and what is next
- Live on Google Play. The first versions shipped eight days after the first commit, and 1.3.2 eight months later.
- Moved off all-files access to a single folder grant, keeping the app within Play policy and asking for far less.
- Production crashes from the service watchdogs found through Play Console and fixed in the following release.
Next