AndPictures 6bc1f9a355 AndPictures 1.0: NAS photo sync + built-in file browser
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)
2026-10-14 10:47:43 -07:00

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.

./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):

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:

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

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.
Description
Native Kotlin Android app that syncs new photos to the NAS over SMB into a decade/year/month tree, with a built-in phone/NAS file browser.
Readme Apache-2.0 382 KiB
2026-10-14 17:47:54 +00:00
Languages
Kotlin 100%