Files
ds-galo/android/README.md
T
iu 38b5b03f59 feat(android): sync device albums with a server album
Add phone album sync to the Android client: MediaStore source (device
albums), a Room index with upload provenance, on-device previews, and a
mixed server + device album list where a linked pair collapses into a
single device card with a link indicator and mode label.

Three modes over one pipeline: A (off), B (upload copies, originals stay
local), C (upload originals and offload them locally to previews). The
sync engine tracks device changes (add/remove/rename/move/content), matches
assets to server photos by sha256 content hash, deletes only this client's
uploads, and reflects server changes without touching local originals.
Offload is automatic; the zoomed viewer pulls the server original.

Backend: expose the original content checksum in photo data (Postgres and
SQLite), additive with no migration.

Also anchor the `.gitignore` `data/` rule to `/data/` so the Android
`com.galo.android.data` package is tracked, and add the halo launcher icon.
2026-10-13 17:10:47 +03:00

7.3 KiB

galo Android client

Native Android client for a self-hosted galo instance: browse albums and media, watch photos/videos/360 panoramas, follow live album updates, and upload from the device with a persistent background queue and offline cache.

Kotlin + Jetpack Compose, minSdk 28, targetSdk 35. The module is standalone: it does not touch backend/ or frontend/, and consumes the existing HTTP API without server changes.

Requirements

  • JDK 17
  • Android SDK (platforms;android-35, build-tools;35.0.0, platform-tools)
  • ANDROID_HOME pointing at the SDK, or android/local.properties with sdk.dir=<path>

Build

cd android
./gradlew :app:assembleDebug        # debug APK
./gradlew test                      # JVM unit + Robolectric UI tests
./gradlew lintDebug                 # Android lint
./gradlew :app:assembleRelease      # unsigned release APK without a keystore

From the repository root:

make android                        # runs :app:assembleDebug (ANDROID_SDK=... to override)
make install-android-app            # build and install the debug APK on a device

install-android-app builds the debug APK and installs it onto the sole connected device. To choose a device explicitly, pass its serial number:

make install-android-app SERIAL=emulator-5554

SERIAL also falls back to $ANDROID_SERIAL. When several devices are connected and SERIAL is not set, the target prints the list of devices (only those in the device state) and stops without installing. ADB=... overrides the adb binary (defaults to $(ANDROID_SDK)/platform-tools/adb).

Debug output: android/app/build/outputs/apk/debug/app-debug.apk.

Signing (release)

The release keystore and credentials live outside the repository. Point the build at a properties file with GALO_KEYSTORE_PROPERTIES=/path/keystore.properties, or place an ignored android/keystore.properties:

storeFile=/secure/galo-release.jks
storePassword=...
keyAlias=galo
keyPassword=...

Without it, assembleRelease still resolves and produces an unsigned APK; no secrets are stored in the repository.

First run

  1. Enter the instance base URL (e.g. https://photos.example.com) and confirm — the app checks GET /api/health.
  2. Plain http:// is blocked by default. For a trusted LAN instance, enable Небезопасное соединение (только LAN); the UI then marks the connection as insecure. The manifest permits cleartext, but the client itself refuses HTTP until you opt in.
  3. Log in or register. Tokens are stored in the platform Keystore (EncryptedSharedPreferences) and refreshed transparently on 401.

Upload notes

  • Uses the system Photo Picker; pick files, then choose the destination album.
  • One file per multipart POST (the server's batch upload is fail-fast and caps the total body size), so each file gets its own status and retry.
  • Queue is persisted in Room and resumes after an app/device restart. Large and video uploads show a foreground progress notification; transient failures retry with exponential backoff (up to 3 attempts).
  • Optional Только Wi-Fi limits uploads to unmetered networks; jobs wait in the queue until Wi-Fi is available.
  • HEIC/HEIF originals are converted to JPEG on-device (the server decodes only JPEG/PNG/GIF/BMP/TIFF). Capture date and geolocation are carried over via EXIF and the base filename is preserved. Note: for HEIC sources the stored "original" is the converted JPEG.

Offline cache

Album metadata and photo pages are cached in Room; media is cached by Coil keyed by the object path without the signed query, so the cache is reused when the signature rotates. Albums and photo grids open from cache when offline.

Live updates

While an album screen is active the client holds GET /api/sse/albums/:id?token= and backfills missed changes via GET /api/albums/:id/photos?since= after a reconnect.

Device albums and synchronization

The album list mixes the server's albums with the device's own albums (MediaStore buckets). Each card is tagged with its source (Сервер / Телефон), and a linked pair shows the connection both ways: the device album shows → <server album>, the server album shows источник: <device albums>.

  • Access. Enumerating device albums needs media read access: READ_MEDIA_IMAGES and READ_MEDIA_VIDEO on Android 13+, or READ_EXTERNAL_STORAGE on Android 12 and lower. The app requests it at runtime; without (or with partial) access the device albums are simply not shown, and server albums keep working.
  • Synchronization modes are chosen per device album from the album screen (swipe right, or the settings icon):
    • Выключена (A) — local only. Previews are generated on the device and cached in the app's private cache; the album makes no server requests.
    • Копии оригиналов на сервере (B) — originals stay on the device, copies are uploaded to a chosen server album.
    • Перенос оригиналов на сервер (C) — originals move to the server; the device keeps previews. Freeing space replaces the original in MediaStore only after the user confirms the OS write request, in batches.
  • Previews are capped at a 1600px long side (JPEG q80) and generated on the device, including a poster frame for videos.
  • Deduplication. A device asset is matched to a server photo by the sha256 of the bytes that leave the device (the server exposes each original's checksum). Content already present is linked instead of re-uploaded; an edited asset is re-uploaded and its previous upload removed; a pure rename is propagated via PATCH /api/photos/:id.
  • Deletions. The server photo is deleted only when this client uploaded it, its content is gone from the device and no other linked album still holds it. Photos the client did not upload are never touched, and a photo already gone from the server is only reflected in the local index.
  • Background. Synchronization runs on app start and while the app is open (a MediaStore content observer), and periodically in the background via WorkManager. The periodic pass honors the Только Wi-Fi setting. Freeing space in mode C is never automatic — it always waits for the user's confirmation.
  • Provenance. Mode C writes a marker into the EXIF of the preview it leaves behind (server album/photo, original hash, mode). After a reinstall the app reads these markers to restore the album link and which uploads are its own; unmarked media is left alone.
  • Reversibility. Leaving mode C warns first and offers to fetch the originals back from the server (space permitting); declining keeps previews only. Unlinking an album drops the local link and never deletes photos on the server.

Testing

cd android && ./gradlew test

Unit tests cover URL resolution, error mapping, token refresh, the offline cache/repository fallback, page merging, the object cache key, SSE event parsing/reconnect, HEIC detection, and the panorama camera math. Robolectric Compose tests cover the albums grid/offline banner, the viewer swipe navigation, and upload statuses.

Device-only checks (real server E2E, Wi-Fi-only gating, background continuation, HEIC conversion of a real asset) require a device or emulator.