AndPictures 5ce746d5aa Fix stale cursor line after a sync completes in the foreground
renderStatus() only rewrote the status TextView, so the 'Last sync cursor'
line kept its launch-time value until the next onResume - on a fresh install a
successful sync still displayed 'never'. It now delegates to refreshStatus()
which refreshes both lines.
2026-10-15 07:07:00 -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.

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.
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%