# 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. ### Serving the APK over the LAN ```bash cd app/build/outputs/apk/release python3 -m http.server 8080 # on the phone's browser: http://: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.