# 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): `\\\\`.
- 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.
### File browser
Below **Test connection** are two side-by-side buttons — **Phone files** and
**NAS files** — that open the same grid browser over two different sources:
- **Phone files** browses photos through MediaStore (`Phone storage` → `DCIM`,
`Pictures`, …) with no `MANAGE_EXTERNAL_STORAGE` permission.
- **NAS files** browses the share over SMB, starting at the configured base
directory.
Folders come first, then files; thumbnails are decoded in two passes (bounds, then
sampled) and cached. Tap a folder to descend, the up arrow / Back to ascend, and a
photo to open it full-screen (tap to dismiss). If the server drops an idle SMB
session, the browser reconnects automatically on the next tap.
### Download
The signed release APK is published on the local Gitea (public repo):
http://192.168.123.18:3001/umbragit/AndPictures/releases/download/v1.0/AndPictures-1.0-release.apk
Open that link in any phone's browser on the same LAN and install it, or scan
`docs/download-qr.png`. The source lives at
http://192.168.123.18:3001/umbragit/AndPictures
**If the browser says the site can't be reached** (Chrome's
`ERR_LOCAL_NETWORK_PERMISSION_MISSING`): Android 16 and newer block apps from
reaching LAN addresses unless the app holds the *local network* / "Nearby devices"
permission. Tap **Allow** when the browser asks to "find, connect to, and determine
the relative position of nearby devices" (if it was denied earlier, re-enable it
under *Settings -> Apps -> Chrome -> Permissions -> Nearby devices*), then open the
link again. Chrome also warns "File might be harmful" for a direct APK download -
choose **Download anyway**.
### Installing
Download the APK 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 (reconnects if the session drops) |
| `FileBrowserActivity.kt` | Grid browser over a `FileSource`, used for both phone and NAS |
| `PhotoViewActivity.kt` | Full-screen photo preview |
| `FileSource.kt` | Browser model + `FileSource` interface (`list`, `parentOf`, `openStream`, `loadThumbnail`) |
| `PhoneFileSource.kt` | `FileSource` backed by MediaStore |
| `NasFileSource.kt` | `FileSource` backed by SMB |
| `Bitmaps.kt` | Two-pass sampled decode + thumbnail `LruCache` |
| `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.