Выбор порядка раньше действовал только на серверные альбомы: карточки устройства были закреплены сверху и не сортировались, а направления не было вовсе. - порядок применяется ко всему списку: карточки устройства и серверные упорядочиваются вместе единым компаратором - альбом устройства получает дату последнего изменения (самый свежий элемент, как при выборе обложки), поэтому «по времени изменения» осмыслен и для него - обратный порядок инвертирует выбранный ключ; карточка без даты остаётся последней в любом направлении - пара (ключ, направление) хранится в DataStore за узким портом AlbumSortPreference; меню сортировки получило переключатель «Обратный порядок» - README: раздел Album sorting обновлён
218 lines
11 KiB
Markdown
218 lines
11 KiB
Markdown
# galo Android client
|
|
|
|
Native Android client for a self-hosted [galo](../README.md) 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
|
|
|
|
```sh
|
|
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:
|
|
|
|
```sh
|
|
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:
|
|
|
|
```sh
|
|
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`:
|
|
|
|
```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 the app is signed in the client holds the personal stream
|
|
`GET /api/sse/me` (authorized by the bearer header, not a query token) and applies
|
|
album lifecycle events (`album.created`, `album.updated`, `album.deleted`) straight
|
|
to the local cache, so the album list and an open album screen follow changes made
|
|
elsewhere without a manual refresh. After a reconnect the list is re-synced over
|
|
REST, which also drops albums deleted while the stream was down. When an album is
|
|
deleted or the user is removed from it, the album leaves the list and the open
|
|
album screen returns to it.
|
|
|
|
While an album screen is active the client also holds `GET /api/sse/albums/:id` and
|
|
backfills missed photo changes via `GET /api/albums/:id/photos?since=` after a
|
|
reconnect.
|
|
|
|
The streaming client does not refresh tokens by itself: it is built without the
|
|
authenticator that ordinary API calls use, and the authorization interceptor only
|
|
reads the current access token. A reconnect after expiry therefore restarts with
|
|
whatever token is stored, and recovers once any other request has refreshed it; a
|
|
client that idles with only the stream open keeps retrying with the stale token
|
|
until the next API call renews it.
|
|
|
|
## Attributes and actions
|
|
|
|
The mobile client mirrors the web workspace's attribute/action management.
|
|
|
|
- **Album panel.** Swiping right-to-left on any album screen (server or device)
|
|
opens the «Атрибуты и действия» panel with the sections «Обложка», «Информация»,
|
|
«Навигация» and «Действия» (rename, delete). Cover and navigation are limited to
|
|
owner/admin. For a device album linked to a server album, the panel shows the
|
|
linked server album's real attributes; an unlinked device album shows local
|
|
values (cover, name, item count) without the navigation section.
|
|
- **Photo sheet.** Swiping up in full-screen viewing (server or device) opens the
|
|
«Атрибуты и действия» sheet with «Информация», «Метки» and «Действия» (download,
|
|
move, copy, set as cover, delete) plus rename by tapping the file name. The sheet
|
|
does not open while an image is zoomed, a panorama rotates, or video controls are
|
|
in use. For device media linked to a server photo, the sheet shows that photo's
|
|
server attributes and actions; otherwise it shows local properties.
|
|
- **Attribute settings.** The «Информация» section reaches a screen listing the EXIF
|
|
attributes with visibility checkboxes and label fields («отметить все»/«снять
|
|
все»); the choice is stored in the profile's `photo_info` preferences, using the
|
|
same shape as web, so the two clients stay in sync.
|
|
|
|
API operations backing these panels (all already served by the backend):
|
|
|
|
- `PATCH /api/albums/:id` (`title`, `description`, `cover_photo_id` / `clear_cover`),
|
|
`DELETE /api/albums/:id`, `GET /api/albums/:id/members`,
|
|
`GET|POST|DELETE /api/albums/:id/share`
|
|
- `POST /api/photo-batch/copy`, `POST|DELETE /api/photos/:id/tags`, `GET /api/tags?q=`
|
|
- `PATCH /api/auth/me/preferences`; account payload `GET /api/auth/me` and photo
|
|
payloads expose `preferences`, `exif`, `tags`, `uploader` and `download_url`.
|
|
|
|
## 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.
|
|
|
|
## Album sorting
|
|
|
|
The album list can order every card by most recent change (the default), by title,
|
|
or by the number of items, and the chosen key can be reversed from the same menu.
|
|
Cards of device albums and server albums are ordered together, so one ordering rule
|
|
covers the whole list; a device album's change date is the newest item it holds,
|
|
which makes the default key meaningful for it as well. The choice — key and
|
|
direction — is made from the sort control in the top bar and is remembered on the
|
|
device in DataStore, alongside the other display settings; it is not synced, so each
|
|
device keeps its own ordering.
|
|
|
|
## Testing
|
|
|
|
```sh
|
|
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. Request-level tests cover the album/tag/preferences
|
|
operations behind the attribute/action panels, and DTO tests cover the rich photo
|
|
and account payloads (`exif`, `tags`, `uploader`, `preferences`).
|
|
|
|
Device-only checks (real server E2E, Wi-Fi-only gating, background continuation,
|
|
HEIC conversion of a real asset) require a device or emulator.
|