One REST API, Two Clients: The Web UI and the Android App Share Every Endpoint
Photo Sweep's Android client and Vue web UI both talk to the same OCS API with no client-specific endpoint anywhere. A verdict given on the phone shows up in the web review screen, and back.
Photo Sweep ships two front ends: a Vue interface embedded in Nextcloud's web UI, and a native Android app with its own Compose screens and its own offline queue. They share no code. What they share is a contract — one set of OCS endpoints, with nothing behind the scenes that treats one client differently from the other.
A verdict given on the phone shows up in the web review screen, and back.
The surface
GET /ocs/v2.php/apps/photosweep/api/v1/index
POST /ocs/v2.php/apps/photosweep/api/v1/index
GET /ocs/v2.php/apps/photosweep/api/v1/months
GET /ocs/v2.php/apps/photosweep/api/v1/months/2024-07
DELETE /ocs/v2.php/apps/photosweep/api/v1/months/2024-07
GET /ocs/v2.php/apps/photosweep/api/v1/decisions/pending
GET /ocs/v2.php/apps/photosweep/api/v1/decisions/applied
POST /ocs/v2.php/apps/photosweep/api/v1/decisions
DELETE /ocs/v2.php/apps/photosweep/api/v1/decisions/{id}
POST /ocs/v2.php/apps/photosweep/api/v1/apply
POST /ocs/v2.php/apps/photosweep/api/v1/restore
GET /ocs/v2.php/apps/photosweep/api/v1/config
PUT /ocs/v2.php/apps/photosweep/api/v1/configThe web UI's Vue frontend calls these with fetch() over the same origin, riding the browser's existing session. The Android app calls the exact same paths over Basic auth with an app password and OCS-APIRequest: true. Same verbs, same bodies, same responses. There's no /api/v1/mobile/decisions variant, no field that only the phone sends, no capability the phone gets that the web UI doesn't or vice versa.
Why this is harder than it sounds, and worth it anyway
The tempting shortcut when building a second client is to add client-specific endpoints — a bulk sync route "for mobile," a lighter payload "for bandwidth," a session-token flow that skips whatever inconvenience the original API has for a phone. Photo Sweep resists all of that. The one concession the API makes for an offline, bandwidth-constrained client isn't a different endpoint — it's a different shape of request to the same endpoint: POST /decisions accepts either a single verdict or a batch array, and any caller, web or phone, can use either form.
The payoff shows up the moment you stop thinking of the phone as a second product. A verdict recorded offline on the train and synced an hour later lands in exactly the same decisions table the web review screen reads from. Open the web UI a minute after closing the phone app and the review screen already reflects what was swiped on the train — not because anything was built to reconcile two data models, but because there was only ever one.
Thumbnails are the one deliberate exception
Not everything routes through the app's own API. Photo previews come straight from Nextcloud core:
/index.php/core/preview?fileId={id}&x=1024&y=1024&a=1That's not a compromise — it's the same principle applied correctly. Preview generation is Nextcloud's job, already solved, already cached, already handled identically for any client that knows a file id. Photo Sweep's own API doesn't need to wrap it, because wrapping it would just be a second name for the same thing, maintained by this app instead of core. The rule isn't "build one API for everything" — it's "one API per actual responsibility," and thumbnails aren't this app's responsibility.
What this buys the Android client specifically
Building against a REST API that was never designed with a phone in mind, rather than a bespoke mobile backend, meant the Android client had nothing to wait for. There was no server-side work blocking it — the API already existed, already worked, already had a batch form for exactly the offline case a phone needs. The client's job was purely to be a good citizen of an API that already made sense, which is a much smaller job than building a parallel backend would have been.
Series: Photo Sweep.