Native Kotlin Android app (single Activity + WorkManager worker) that copies photos newer than a stored cursor to //192.168.123.2/photo/Home as decade/year/month, deduping by filename and NAS file size. - SMB2/3 via smbj; transparently reconnects when the server drops an idle session (previously surfaced as "DiskShare has already been closed") - MediaStore cursor store, incremental uploads, size-aware dedupe - "Phone files" / "NAS files" browser: grid, thumbnails, full-screen preview - Release signing read from keystore.properties (never committed)
189 lines
8.1 KiB
Markdown
189 lines
8.1 KiB
Markdown
# AndPictures
|
|
|
|
A deliberately small, single-purpose Android app: **when the phone is on the home
|
|
network, copy any photos newer than the last upload to the NAS over SMB**, into a
|
|
`decade / year / month` folder tree.
|
|
|
|
It is native Kotlin (no Flutter), one activity plus a WorkManager worker.
|
|
|
|
The target is `//192.168.123.2/photo/Home` — SMB share `photo`, base directory
|
|
`Home` (both are the app's defaults).
|
|
|
|
## What it does
|
|
|
|
- Keeps one piece of state: the timestamp (MediaStore `DATE_ADDED`, in ms) of the
|
|
newest photo it has already handled.
|
|
- On every run it asks MediaStore for images whose `DATE_ADDED` is newer than that
|
|
cursor and uploads the ones that are not already on the NAS.
|
|
- Decides the destination folder from the photo's date taken (falling back to date
|
|
added): `<base>\<decade>\<year>\<month>\<filename>`.
|
|
- Advances the cursor only past photos it successfully handled, so a failed
|
|
upload is retried next time rather than lost.
|
|
- Runs automatically (WorkManager, 15-minute periodic + a one-shot request every
|
|
time a Wi-Fi/Ethernet network appears) and on demand via a **Sync now** button.
|
|
|
|
"Am I home?" is answered the way you asked: the worker simply tries to reach the
|
|
NAS. If it cannot, the run returns `Result.retry()` and tries again later — no
|
|
SSID matching.
|
|
|
|
## Folder layout
|
|
|
|
Configured base directory (default `Home`) → decade → year → month. This is the
|
|
layout actually found on the NAS:
|
|
|
|
```
|
|
photo/Home/
|
|
19XX/1999/… X = wildcard digits
|
|
200X/2000/ … 200X/2008/…
|
|
201X/2010/ … 201X/2018/…
|
|
202X/2024/05/ 202X/2025/07/ 202X/2026/10/ ← where the app writes
|
|
```
|
|
|
|
- **Decade** is *discovered*: the app lists the existing folders under the base
|
|
directory and matches the photo's year against a folder name that uses `X` as a
|
|
wildcard (`19XX`, `200X`, `201X`, `202X`); the most specific match wins. A plain
|
|
`2020s` / `2020-2029` style name is still understood. Only if nothing fits does
|
|
it create one from the **Decade folder** template (default `{prefix}X` → `202X`;
|
|
`{start}` and `{end}` are also available).
|
|
- **Year** is `YYYY`.
|
|
- **Month** uses the **Month folder** template, a printf string applied to the
|
|
1-based month (default `%02d` → `05`). The existing archive's month folders are
|
|
hand-made and inconsistent (`March`, `Mar2008`, or none), so a plain numeric
|
|
month is used.
|
|
|
|
A photo counts as already uploaded only when the file on the NAS has the **same
|
|
size**, so a partial (e.g. zero-byte) upload from an interrupted run is retried
|
|
instead of blocking that photo forever.
|
|
|
|
## Settings (all in the app UI, stored in SharedPreferences)
|
|
|
|
| Setting | Default | Notes |
|
|
|---|---|---|
|
|
| Host | `192.168.123.2` | NAS address |
|
|
| Port | `445` | SMB |
|
|
| Share | `photo` | SMB share name |
|
|
| Base directory | `Home` | folder inside the share |
|
|
| Domain | *(empty)* | usually blank for Synology |
|
|
| Username / Password | *(empty)* | **required** — the account that can access the `photo` share |
|
|
| Decade folder | `{prefix}X` | template used only when creating a missing decade (`{prefix}` = start/10 → `202X`) |
|
|
| Month folder | `%02d` | printf format for the month folder |
|
|
|
|
## Build
|
|
|
|
Requirements: JDK 17 and the Android SDK (compileSdk 36, build-tools available).
|
|
The Gradle wrapper pins Gradle 8.14.4; AGP 8.13.2, Kotlin 2.1.0.
|
|
|
|
```bash
|
|
./gradlew :app:assembleDebug # -> app/build/outputs/apk/debug/app-debug.apk
|
|
./gradlew :app:assembleRelease # -> app/build/outputs/apk/release/app-release.apk (signed)
|
|
```
|
|
|
|
`local.properties` must point at your SDK (`sdk.dir=/path/to/Android/Sdk`).
|
|
|
|
### Release signing
|
|
|
|
The release APK is signed with a keystore whose credentials live in
|
|
`keystore.properties` at the project root (excluded from version control):
|
|
|
|
```properties
|
|
storeFile=keystore/andpictures-release.jks
|
|
storePassword=…
|
|
keyAlias=andpictures
|
|
keyPassword=…
|
|
```
|
|
|
|
If `keystore.properties` is absent the release build still runs but produces an
|
|
unsigned APK, so `assembleDebug` and secret-less CI builds keep working. **Back up
|
|
the `.jks` and its passwords** — losing them means you can never update an
|
|
already-installed app in place.
|
|
|
|
## Install
|
|
|
|
With developer mode / USB debugging on:
|
|
|
|
```bash
|
|
adb install -r app/build/outputs/apk/release/app-release.apk
|
|
```
|
|
|
|
**Without developer mode (sideload):** copy `app-release.apk` to the phone (USB
|
|
file transfer, cloud, chat, or the LAN server below), open it with the Files app,
|
|
and confirm. Android will ask you to allow **Install unknown apps** for whichever
|
|
app opens it.
|
|
|
|
Then open the app, enter the share and the **photo-share account** (username +
|
|
password — both required, since the share is protected), tap **Grant photo
|
|
access**, and either wait for an automatic run or tap **Sync now**. Without the
|
|
credentials the app refuses to contact the NAS and says so.
|
|
|
|
**Test connection** connects with the current settings and prints what is really
|
|
on the NAS under the base directory (base → decade folders → a few year and month
|
|
subfolders). Use it to confirm the exact folder naming with an account that can
|
|
reach the `photo` share.
|
|
|
|
### Serving the APK over the LAN
|
|
|
|
```bash
|
|
cd app/build/outputs/apk/release
|
|
python3 -m http.server 8080
|
|
# on the phone's browser: http://<this-machine-ip>:8080/app-release.apk
|
|
```
|
|
|
|
Download it in the phone's browser, open it, and allow installs for the browser.
|
|
|
|
Note: switching a device from the **debug** build to the **release** build requires
|
|
uninstalling the debug app first (they are signed with different keys), which also
|
|
clears its saved settings.
|
|
|
|
## Architecture
|
|
|
|
| File | Responsibility |
|
|
|---|---|
|
|
| `MainActivity.kt` | Minimal settings + status UI, permission requests, "Sync now", "Test connection" |
|
|
| `AndPicturesApp.kt` | Notification channel, schedules periodic work, starts the network monitor |
|
|
| `NetworkMonitor.kt` | Fires a sync request when a Wi-Fi/Ethernet network appears |
|
|
| `WorkScheduler.kt` | WorkManager periodic + one-shot requests (network-constrained) |
|
|
| `SyncWorker.kt` | CoroutineWorker: runs the engine, persists the cursor/status |
|
|
| `SyncEngine.kt` | The job: MediaStore query → destination path → SMB upload |
|
|
| `SmbUploader.kt` | smbj wrapper: connect, list folders, mkdir -p, stream a file |
|
|
| `PhotoStore.kt` | MediaStore queries + permission helper |
|
|
| `Prefs.kt` | Typed SharedPreferences incl. the sync cursor |
|
|
| `Notifications.kt` | Result notification |
|
|
|
|
## Permissions
|
|
|
|
- `READ_MEDIA_IMAGES` (API 33+) / `READ_EXTERNAL_STORAGE` (≤ 32)
|
|
- `INTERNET`, `ACCESS_NETWORK_STATE`, `ACCESS_WIFI_STATE`
|
|
- `POST_NOTIFICATIONS`, `RECEIVE_BOOT_COMPLETED`
|
|
|
|
No foreground service is used on purpose: on Android 12+ a background worker may
|
|
not start one, and WorkManager's async service start would crash the process. The
|
|
worker relies on WorkManager's normal background execution window instead.
|
|
|
|
## Testing performed
|
|
|
|
- **SMB layer**, against the real NAS (`//192.168.123.2/home`): connect, list,
|
|
segment-by-segment `mkdir`, upload, overwrite-truncation check, cleanup — passed.
|
|
- **Full app end-to-end** on an API 36 emulator: injected settings, added a photo,
|
|
let the network callback schedule the sync, and confirmed the file landed on the
|
|
NAS and was **byte-for-byte identical**; the cursor advanced.
|
|
- **Real device + real `photo` share**: installed on a Pixel 7 (Android 17), signed
|
|
in with the photo-share account, and the app auto-uploaded ~139 photos. This
|
|
exposed the real decade naming (`202X`, not `2020s`); the matcher was rewritten
|
|
and the 139 files were relocated into `photo/Home/202X/…` (verified: 139 files,
|
|
~488 MB, no zero-byte files, byte-identical).
|
|
|
|
## NAS layout (confirmed)
|
|
|
|
The destination root is `//192.168.123.2/photo/Home`. Its decade folders use `X`
|
|
as a wildcard for the variable digits — `19XX`, `200X`, `201X`, `202X` — and each
|
|
year holds month subfolders. The app matches this scheme; the earlier `2020s`
|
|
guess (made before the share could be read) has been corrected.
|
|
|
|
## Notes
|
|
|
|
- The `photo` share is protected, so a **photo-share account** (username +
|
|
password) is required; the app will not contact the NAS without one.
|
|
- Month naming in the existing archive is hand-made and inconsistent, so the app
|
|
writes plain numeric months (`%02d`). Change the **Month folder** setting if you
|
|
prefer another convention.
|