Duy Pham Case study

Android app · Solo project · Dec 2025 to Aug 2026

Shot Clock

Screenshots with an expiry date. Take one, tap how long it should live, and it deletes itself on time.

Role
Everything: product, design, engineering, release
Platform
Android 7.0 to 16, on Pixel and manufacturer skins
Stack
Kotlin, Jetpack Compose, Hilt, Room, WorkManager
Status
Live on Google Play, version 1.3.2
Gradle modules
10
split into public API and hidden implementation
Recovery layers
4
keeping detection alive under Android's background limits
Latest release
1.3.2
eight months of releases through closed testing to production
Languages
5
English, German, Spanish, French and Vietnamese

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

Decide a screenshot's lifespan at the only moment you know it: right after you take it. One tap, under a second, without opening an app.

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. 1 · Capture

    Take a screenshot

    As usual, with any button combination or gesture.

  2. 2 · Decide

    Tap 1 hour, 1 day or keep

    The bubble fades away if you ignore it. Times and colours are configurable.

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

:app wires every implementation
feature:home apiimpl

impl uses: overlay api

feature:onboarding apiimpl

impl uses: home api, overlay api

feature:overlay apiimpl

impl uses: no other feature

:core:data
:design­system
:common
Module graph, taken from the Gradle build files. Implementations depend only on other features' public APIs, never on each other.

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)
}
The filter every MediaStore change goes through before the bubble appears. Simplified from ScreenshotDetectionService.

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.

L0 Foreground service

START_STICKY asks the system to recreate the service after a low-memory kill.

always on
L1 Exact alarm watchdog

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.

every 15 min
L2 WorkManager monitor

A periodic job that survives reboots and app updates, scheduled once and never duplicated.

every 2 h
L3 Alarm clock watchdog

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.

every 8 h
Each layer is cheap on its own and covers a different way the service dies. Intervals are tuned so the app stays out of manufacturers' abuse heuristics.

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

Version 1.3.1 exists because Play Console reported crashes from the watchdogs themselves on Pixel and realme phones. The exact alarm permission can be revoked at any moment, and a background service start can be refused, and both surfaced as crashes in code paths I had only tested with permissions granted. The fix was to check before every call, fall back instead of throwing, and log process exit reasons in debug builds so silent kills stop being a mystery.

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 three options for deleting screenshots on Android 11 and later. The first release used the first option and moved to the third.

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.

  1. 01

    Pull request

    Unit tests, lint, and instrumented tests on emulators at API 27, 30 and 36.

  2. 02

    release/vX.Y.Z

    CI checks the branch matches the version, builds a signed bundle and uploads it to closed testing.

  3. 03

    Verify

    The build is tagged as a release candidate with the exact bundle attached, then tested on devices.

  4. 04

    Promote

    A manual workflow promotes that same build to production, with a staged rollout percentage.

From branch to production. The artifact users get is the exact one that was tested.

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

Cover the watchdog chain with instrumented tests that revoke permissions and kill the process on the emulator matrix, so the next Android release breaks a test before it breaks a phone. After that, a home screen widget for the screenshots due to expire today.