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)
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_ADDEDis 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
Xas a wildcard (19XX,200X,201X,202X); the most specific match wins. A plain2020s/2020-2029style 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_STATEPOST_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-segmentmkdir, 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
photoshare: 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, not2020s); the matcher was rewritten and the 139 files were relocated intophoto/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
photoshare 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.