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)
This commit is contained in:
2026-10-14 10:47:43 -07:00
commit 6bc1f9a355
39 changed files with 2834 additions and 0 deletions

17
.gitignore vendored Normal file
View File

@@ -0,0 +1,17 @@
*.iml
.gradle/
/local.properties
/.idea/
.DS_Store
/build
/app/build
/captures
.externalNativeBuild
.cxx
local.properties
# Release signing secrets — never commit these.
/keystore.properties
/keystore/
*.jks
*.keystore

188
README.md Normal file
View File

@@ -0,0 +1,188 @@
# 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.
```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://<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.

89
app/build.gradle.kts Normal file
View File

@@ -0,0 +1,89 @@
import java.io.FileInputStream
import java.util.Properties
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
// Release signing credentials live in keystore.properties, which is kept out of
// version control. When it is missing the release APK is left unsigned so that
// `assembleDebug` and secret-less CI builds still work.
val keystorePropsFile = rootProject.file("keystore.properties")
val keystoreProps = Properties().apply {
if (keystorePropsFile.exists()) {
FileInputStream(keystorePropsFile).use { load(it) }
}
}
android {
namespace = "com.bm.andpictures"
compileSdk = 36
defaultConfig {
applicationId = "com.bm.andpictures"
minSdk = 26
targetSdk = 34
versionCode = 1
versionName = "1.0"
}
signingConfigs {
if (keystorePropsFile.exists()) {
create("release") {
storeFile = rootProject.file(keystoreProps.getProperty("storeFile"))
storePassword = keystoreProps.getProperty("storePassword")
keyAlias = keystoreProps.getProperty("keyAlias")
keyPassword = keystoreProps.getProperty("keyPassword")
}
}
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
if (keystorePropsFile.exists()) {
signingConfig = signingConfigs.getByName("release")
}
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
// smbj ships several META-INF files that collide inside an APK.
packaging {
resources {
excludes += setOf(
"META-INF/DEPENDENCIES",
"META-INF/LICENSE*",
"META-INF/NOTICE*",
"META-INF/*.kotlin_module",
"META-INF/versions/9/OSGI-INF/MANIFEST.MF"
)
}
}
}
dependencies {
implementation("androidx.core:core-ktx:1.15.0")
implementation("androidx.appcompat:appcompat:1.7.0")
implementation("androidx.activity:activity-ktx:1.10.0")
implementation("androidx.recyclerview:recyclerview:1.3.2")
implementation("androidx.work:work-runtime-ktx:2.10.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
// Pure-Java SMB2/3 client. Works on Android and speaks the SMB dialect the
// Synology exposes (SMB1 is disabled on this NAS).
implementation("com.hierynomus:smbj:0.13.0")
}

6
app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,6 @@
# Keep the SMB client classes used reflectively by smbj / BouncyCastle.
-dontwarn org.slf4j.**
-dontwarn org.bouncycastle.**
-dontwarn javax.**
-keep class com.hierynomus.** { *; }
-keep class net.engio.mbassy.** { *; }

View File

@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Talk to the NAS over SMB (raw TCP) and watch the network state. -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<!-- Read the photo library. READ_MEDIA_IMAGES is the API 33+ replacement. -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<!-- Uploads run in a background WorkManager worker. We intentionally do NOT
start a foreground service (Android 12+ forbids it from the background). -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<application
android:name=".AndPicturesApp"
android:allowBackup="false"
android:icon="@drawable/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.AndPictures">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".FileBrowserActivity"
android:exported="false"
android:label="@string/title_file_browser" />
<activity
android:name=".PhotoViewActivity"
android:exported="false"
android:label="@string/title_photo_view" />
</application>
</manifest>

View File

@@ -0,0 +1,15 @@
package com.bm.andpictures
import android.app.Application
class AndPicturesApp : Application() {
private lateinit var networkMonitor: NetworkMonitor
override fun onCreate() {
super.onCreate()
Notifications.createChannel(this)
WorkScheduler.schedulePeriodic(this)
networkMonitor = NetworkMonitor(this).apply { start() }
}
}

View File

@@ -0,0 +1,55 @@
package com.bm.andpictures
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.util.LruCache
import java.io.InputStream
/**
* Tiny two-pass thumbnail decoder plus an in-memory LRU cache.
*
* Two passes (a bounds-only decode, then a sampled decode) keep memory low
* without needing a seekable stream — important because NAS images arrive over
* SMB, where we simply open the file twice.
*/
object Bitmaps {
private val cache: LruCache<String, Bitmap> =
object : LruCache<String, Bitmap>(cacheBytes()) {
override fun sizeOf(key: String, value: Bitmap): Int = value.byteCount
}
fun cached(key: String): Bitmap? = cache.get(key)
fun put(key: String, bitmap: Bitmap) {
cache.put(key, bitmap)
}
/** Decodes [open] down to roughly [reqSize] px on the longest edge. */
fun decodeSampled(open: () -> InputStream, reqSize: Int): Bitmap? {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
open().use { BitmapFactory.decodeStream(it, null, bounds) }
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
val opts = BitmapFactory.Options().apply {
inSampleSize = sampleSize(bounds.outWidth, bounds.outHeight, reqSize)
}
return open().use { BitmapFactory.decodeStream(it, null, opts) }
}
private fun sampleSize(width: Int, height: Int, req: Int): Int {
var sample = 1
var w = width
var h = height
while (w / 2 >= req && h / 2 >= req) {
w /= 2
h /= 2
sample *= 2
}
return sample
}
private fun cacheBytes(): Int {
val max = Runtime.getRuntime().maxMemory()
return (max / 8).toInt().coerceAtLeast(8 * 1024 * 1024)
}
}

View File

@@ -0,0 +1,262 @@
package com.bm.andpictures
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.ProgressBar
import android.widget.TextView
import android.widget.Toast
import androidx.activity.OnBackPressedCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* A read-only file browser. The same screen serves two sources:
*
* - [MODE_PHONE]: the photos in the device MediaStore, grouped by folder.
* - [MODE_NAS]: the configured SMB share, starting at the base directory.
*
* Folders are shown first, then files. Image files render a preview tile.
*/
class FileBrowserActivity : AppCompatActivity() {
private lateinit var grid: RecyclerView
private lateinit var progress: ProgressBar
private lateinit var message: TextView
private lateinit var adapter: TileAdapter
private var mode: String = MODE_PHONE
private var source: FileSource? = null
private var currentDir: String? = null
private val permissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (granted) startBrowsing() else showMessage(getString(R.string.browse_needs_permission))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_file_browser)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
mode = intent.getStringExtra(EXTRA_MODE) ?: MODE_PHONE
currentDir = savedInstanceState?.getString(STATE_DIR)
grid = findViewById(R.id.browserGrid)
progress = findViewById(R.id.browserProgress)
message = findViewById(R.id.browserMessage)
grid.layoutManager = GridLayoutManager(this, SPAN_COUNT)
adapter = TileAdapter { entry -> onEntryClick(entry) }
grid.adapter = adapter
onBackPressedDispatcher.addCallback(
this,
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() = navigateUp()
},
)
startBrowsing()
}
override fun onSupportNavigateUp(): Boolean {
navigateUp()
return true
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
currentDir?.let { outState.putString(STATE_DIR, it) }
}
override fun onDestroy() {
super.onDestroy()
runCatching { source?.close() }
}
private fun startBrowsing() {
if (mode == MODE_PHONE && !PhotoStore.hasPermission(this)) {
showMessage(getString(R.string.browse_needs_permission))
permissionLauncher.launch(PhotoStore.mediaPermission)
return
}
showLoading()
lifecycleScope.launch {
val opened = openSource()
if (opened == null) return@launch
source = opened
load(currentDir ?: opened.rootKey)
}
}
private suspend fun openSource(): FileSource? = withContext(Dispatchers.IO) {
if (mode == MODE_PHONE) {
return@withContext PhoneFileSource(this@FileBrowserActivity)
}
val p = Prefs(this@FileBrowserActivity)
if (!p.isConfigured) {
bailOut(getString(R.string.browse_needs_config))
return@withContext null
}
val uploader = SmbUploader(
host = p.host,
port = p.port,
shareName = p.share,
domain = p.domain,
user = p.user,
password = p.password,
)
try {
uploader.connect()
} catch (t: Throwable) {
runCatching { uploader.close() }
bailOut("NAS: ${t.message ?: t.javaClass.simpleName}")
return@withContext null
}
NasFileSource(uploader, "${p.host}/${p.share}", p.basePath.trim('\\', '/'))
}
private suspend fun bailOut(text: String) = withContext(Dispatchers.Main) {
Toast.makeText(this@FileBrowserActivity, text, Toast.LENGTH_LONG).show()
finish()
}
private fun load(dirKey: String) {
val src = source ?: return
showLoading()
lifecycleScope.launch {
val result = withContext(Dispatchers.IO) { runCatching { src.list(dirKey) } }
if (result.isFailure) {
showMessage("Error: ${result.exceptionOrNull()?.message ?: "listing failed"}")
return@launch
}
val entries = result.getOrDefault(emptyList())
currentDir = dirKey
adapter.submit(entries)
supportActionBar?.title = src.displayName(dirKey)
supportActionBar?.subtitle = "${src.title} · ${entries.count { !it.isDirectory }} items"
if (entries.isEmpty()) showMessage(getString(R.string.browse_empty)) else showGrid()
}
}
private fun navigateUp() {
val src = source
val cur = currentDir
val parent = if (src != null && cur != null) src.parentOf(cur) else null
if (parent != null) load(parent) else finish()
}
private fun onEntryClick(entry: TileEntry) {
when {
entry.isDirectory -> load(entry.key)
entry.isImage -> startActivity(
Intent(this, PhotoViewActivity::class.java)
.putExtra(PhotoViewActivity.EXTRA_MODE, mode)
.putExtra(PhotoViewActivity.EXTRA_KEY, entry.key)
.putExtra(PhotoViewActivity.EXTRA_NAME, entry.name),
)
else -> Toast.makeText(this, R.string.browse_not_image, Toast.LENGTH_SHORT).show()
}
}
private fun showLoading() {
progress.visibility = View.VISIBLE
grid.visibility = View.GONE
message.visibility = View.GONE
}
private fun showGrid() {
progress.visibility = View.GONE
grid.visibility = View.VISIBLE
message.visibility = View.GONE
}
private fun showMessage(text: String) {
progress.visibility = View.GONE
grid.visibility = View.GONE
message.visibility = View.VISIBLE
message.text = text
}
private inner class TileAdapter(
private val onClick: (TileEntry) -> Unit,
) : RecyclerView.Adapter<TileAdapter.Holder>() {
private var items: List<TileEntry> = emptyList()
fun submit(list: List<TileEntry>) {
items = list
notifyDataSetChanged()
}
override fun getItemCount(): Int = items.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
val view = layoutInflater.inflate(R.layout.item_tile, parent, false)
return Holder(view)
}
override fun onBindViewHolder(holder: Holder, position: Int) {
val entry = items[position]
holder.name.text = entry.name
holder.itemView.setOnClickListener { onClick(entry) }
val image = holder.image
image.tag = entry.key
when {
entry.isDirectory -> {
image.scaleType = ImageView.ScaleType.CENTER_INSIDE
image.setImageResource(R.drawable.ic_folder)
}
entry.isImage -> {
image.scaleType = ImageView.ScaleType.CENTER_CROP
val cached = Bitmaps.cached(entry.key)
if (cached != null) {
image.setImageBitmap(cached)
} else {
image.setImageResource(R.drawable.ic_image)
loadThumbnail(entry, image)
}
}
else -> {
image.scaleType = ImageView.ScaleType.CENTER_INSIDE
image.setImageResource(R.drawable.ic_file)
}
}
}
private fun loadThumbnail(entry: TileEntry, image: ImageView) {
val src = source ?: return
lifecycleScope.launch {
val bitmap = withContext(Dispatchers.IO) { src.loadThumbnail(entry, THUMB_PX) }
if (bitmap != null) {
Bitmaps.put(entry.key, bitmap)
if (image.tag == entry.key) image.setImageBitmap(bitmap)
}
}
}
inner class Holder(view: View) : RecyclerView.ViewHolder(view) {
val image: ImageView = view.findViewById(R.id.tileImage)
val name: TextView = view.findViewById(R.id.tileName)
}
}
companion object {
const val MODE_PHONE = "phone"
const val MODE_NAS = "nas"
const val EXTRA_MODE = "extra_mode"
private const val STATE_DIR = "state_dir"
private const val SPAN_COUNT = 3
private const val THUMB_PX = 320
}
}

View File

@@ -0,0 +1,54 @@
package com.bm.andpictures
import android.graphics.Bitmap
import java.io.Closeable
import java.io.InputStream
/** One row in the file browser: either a folder or a file. */
data class TileEntry(
val name: String,
val key: String,
val isDirectory: Boolean,
val sizeBytes: Long,
val isImage: Boolean,
)
/**
* Read-only listing of a directory tree. Implemented over the phone's
* MediaStore and over an SMB share. Every method blocks, so call them off the
* main thread.
*/
interface FileSource : Closeable {
/** Short label for the source, e.g. "Phone". */
val title: String
/** Directory key the browser opens at. */
val rootKey: String
fun list(dirKey: String): List<TileEntry>
/** Parent of [dirKey], or null when [dirKey] is the root. */
fun parentOf(dirKey: String): String?
/** Human-readable path for [dirKey], used as the screen title. */
fun displayName(dirKey: String): String
/** Opens the file at [key] for reading. */
fun openStream(key: String): InputStream
/** Loads a preview bitmap for [entry]. Default is a sampled two-pass decode. */
fun loadThumbnail(entry: TileEntry, reqSize: Int): Bitmap? =
runCatching { Bitmaps.decodeSampled({ openStream(entry.key) }, reqSize) }.getOrNull()
fun isImageName(name: String): Boolean =
name.substringAfterLast('.', "").lowercase() in IMAGE_EXTENSIONS
override fun close() {}
companion object {
val IMAGE_EXTENSIONS: Set<String> = setOf(
"jpg", "jpeg", "png", "gif", "webp", "bmp", "heic", "heif", "dng", "avif",
)
}
}

View File

@@ -0,0 +1,255 @@
package com.bm.andpictures
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.util.Log
import android.widget.Button
import android.widget.EditText
import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import androidx.work.WorkInfo
import androidx.work.WorkManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.text.DateFormat
import java.util.Date
class MainActivity : AppCompatActivity() {
private lateinit var statusText: TextView
private lateinit var lastSyncText: TextView
private lateinit var etHost: EditText
private lateinit var etPort: EditText
private lateinit var etShare: EditText
private lateinit var etBasePath: EditText
private lateinit var etDomain: EditText
private lateinit var etUser: EditText
private lateinit var etPassword: EditText
private lateinit var etDecadeFormat: EditText
private lateinit var etMonthFormat: EditText
private val mediaPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
private val notificationPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
bindViews()
populate()
requestNotificationPermissionIfNeeded()
findViewById<Button>(R.id.btnSave).setOnClickListener { save() }
findViewById<Button>(R.id.btnSync).setOnClickListener { syncNow() }
findViewById<Button>(R.id.btnPermission).setOnClickListener { requestMediaPermission() }
findViewById<Button>(R.id.btnTest).setOnClickListener { testConnection() }
findViewById<Button>(R.id.btnPhoneFiles).setOnClickListener { openBrowser(FileBrowserActivity.MODE_PHONE) }
findViewById<Button>(R.id.btnNasFiles).setOnClickListener { openBrowser(FileBrowserActivity.MODE_NAS) }
observeWork()
}
override fun onResume() {
super.onResume()
refreshStatus()
}
private fun bindViews() {
statusText = findViewById(R.id.statusText)
lastSyncText = findViewById(R.id.lastSyncText)
etHost = findViewById(R.id.etHost)
etPort = findViewById(R.id.etPort)
etShare = findViewById(R.id.etShare)
etBasePath = findViewById(R.id.etBasePath)
etDomain = findViewById(R.id.etDomain)
etUser = findViewById(R.id.etUser)
etPassword = findViewById(R.id.etPassword)
etDecadeFormat = findViewById(R.id.etDecadeFormat)
etMonthFormat = findViewById(R.id.etMonthFormat)
}
private fun populate() {
val p = Prefs(this)
etHost.setText(p.host)
etPort.setText(p.port.toString())
etShare.setText(p.share)
etBasePath.setText(p.basePath)
etDomain.setText(p.domain)
etUser.setText(p.user)
etPassword.setText(p.password)
etDecadeFormat.setText(p.decadeFormat)
etMonthFormat.setText(p.monthFormat)
refreshStatus()
}
private fun save() {
persistSettings()
WorkScheduler.schedulePeriodic(this)
Toast.makeText(this, "Settings saved", Toast.LENGTH_SHORT).show()
}
/** Writes the on-screen fields to prefs without the toast/scheduling side effects. */
private fun persistSettings() {
val p = Prefs(this)
p.host = etHost.text.toString()
p.port = etPort.text.toString().toIntOrNull() ?: Prefs.DEFAULT_PORT
p.share = etShare.text.toString()
p.basePath = etBasePath.text.toString()
p.domain = etDomain.text.toString()
p.user = etUser.text.toString()
p.password = etPassword.text.toString()
p.decadeFormat = etDecadeFormat.text.toString()
p.monthFormat = etMonthFormat.text.toString()
}
private fun syncNow() {
if (!PhotoStore.hasPermission(this)) {
requestMediaPermission()
Toast.makeText(this, "Grant photo access first", Toast.LENGTH_SHORT).show()
return
}
if (!ensureCredentials()) return
save()
WorkScheduler.requestSync(this, replace = true)
statusText.text = "Syncing…"
}
/**
* The photo share is protected, so nothing is sent to the NAS until the
* username and password are filled in.
*/
private fun ensureCredentials(): Boolean {
val userMissing = etUser.text.toString().isBlank()
if (userMissing || etPassword.text.toString().isBlank()) {
Toast.makeText(
this,
"Enter the photo-share username and password first",
Toast.LENGTH_LONG,
).show()
if (userMissing) etUser.requestFocus() else etPassword.requestFocus()
return false
}
return true
}
/**
* Connects with the current settings and prints what is actually on the NAS
* under the base directory, so the real decade/year/month naming can be
* confirmed without guessing.
*/
private fun testConnection() {
if (!ensureCredentials()) return
save()
statusText.text = "Testing connection…"
lifecycleScope.launch {
statusText.text = withContext(Dispatchers.IO) { probe() }
}
}
private fun probe(): String {
val p = Prefs(this)
if (!p.isConfigured) return "Set the photo-share username and password first"
val uploader = SmbUploader(
host = p.host,
port = p.port,
shareName = p.share,
domain = p.domain,
user = p.user,
password = p.password,
)
return try {
uploader.connect()
val base = p.basePath
val baseLabel = base.ifBlank { "<share root>" }
val sb = StringBuilder("Connected to //${p.host}/${p.share}\n")
val decades = uploader.listFolders(base)
sb.append("$baseLabel: ")
sb.append(if (decades.isEmpty()) "(no subfolders yet)" else decades.joinToString(", "))
for (decade in decades.takeLast(2)) {
val decadePath = joinPath(base, decade)
val years = uploader.listFolders(decadePath)
sb.append("\n $decade: ")
sb.append(if (years.isEmpty()) "(empty)" else years.joinToString(", "))
for (year in years.takeLast(2)) {
val yearPath = joinPath(decadePath, year)
val months = uploader.listFolders(yearPath)
sb.append("\n $year: ")
sb.append(if (months.isEmpty()) "(empty)" else months.joinToString(", "))
}
}
sb.toString().also {
Log.i(SyncWorker.TAG, "test connection -> ${it.replace('\n', ' ')}")
}
} catch (t: Throwable) {
Log.w(SyncWorker.TAG, "test connection failed: ${t.message}", t)
"Connection failed: ${t.message ?: t.javaClass.simpleName}"
} finally {
runCatching { uploader.close() }
}
}
private fun joinPath(parent: String, child: String): String =
if (parent.isBlank()) child else "$parent\\$child"
private fun refreshStatus() {
val p = Prefs(this)
lastSyncText.text = if (p.lastSyncMillis == 0L) {
"Last sync cursor: never"
} else {
"Last sync cursor: " +
DateFormat.getDateTimeInstance().format(Date(p.lastSyncMillis))
}
statusText.text = p.lastStatus.ifBlank { getString(R.string.status_idle) }
}
private fun requestMediaPermission() {
if (!PhotoStore.hasPermission(this)) {
mediaPermissionLauncher.launch(PhotoStore.mediaPermission)
}
}
/** Opens the read-only file browser for the phone library or the NAS share. */
private fun openBrowser(mode: String) {
if (mode == FileBrowserActivity.MODE_NAS) {
// The browser reads prefs directly, so flush the on-screen values first.
persistSettings()
}
startActivity(
Intent(this, FileBrowserActivity::class.java)
.putExtra(FileBrowserActivity.EXTRA_MODE, mode),
)
}
private fun requestNotificationPermissionIfNeeded() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) !=
PackageManager.PERMISSION_GRANTED
) {
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
private fun observeWork() {
val wm = WorkManager.getInstance(this)
fun applyState(infos: List<WorkInfo>) {
val latest = infos.lastOrNull() ?: return
when (latest.state) {
WorkInfo.State.ENQUEUED, WorkInfo.State.RUNNING ->
statusText.text = "Syncing…"
else -> refreshStatus()
}
}
wm.getWorkInfosForUniqueWorkLiveData(Prefs.WORK_ONESHOT).observe(this) { applyState(it) }
wm.getWorkInfosForUniqueWorkLiveData(Prefs.WORK_PERIODIC).observe(this) { applyState(it) }
}
}

View File

@@ -0,0 +1,63 @@
package com.bm.andpictures
import java.io.InputStream
/**
* Browses an SMB share starting at [rootKey]. Keys are share-relative paths
* using backslashes (matching [SmbUploader]); an empty key means the share
* root.
*/
class NasFileSource(
private val uploader: SmbUploader,
private val hostAndShare: String,
override val rootKey: String,
) : FileSource {
override val title: String get() = "NAS"
override fun list(dirKey: String): List<TileEntry> {
val entries = uploader.listEntries(dirKey)
val dirs = entries.asSequence()
.filter { it.isDirectory }
.sortedBy { it.name.lowercase() }
.map { TileEntry(it.name, joinKey(dirKey, it.name), isDirectory = true, sizeBytes = 0L, isImage = false) }
.toList()
val files = entries.asSequence()
.filter { !it.isDirectory }
.sortedBy { it.name.lowercase() }
.map {
TileEntry(
it.name,
joinKey(dirKey, it.name),
isDirectory = false,
sizeBytes = it.sizeBytes,
isImage = isImageName(it.name),
)
}
.toList()
return dirs + files
}
override fun parentOf(dirKey: String): String? {
val norm = dirKey.trim('\\', '/')
if (norm.isEmpty() || norm == rootKey.trim('\\', '/')) return null
val parent = norm.substringBeforeLast('\\', "")
return parent
}
override fun displayName(dirKey: String): String {
val norm = dirKey.trim('\\', '/').replace('\\', '/')
return if (norm.isEmpty()) "//$hostAndShare" else "//$hostAndShare/$norm"
}
override fun openStream(key: String): InputStream = uploader.openRead(key)
override fun close() {
uploader.close()
}
private fun joinKey(dir: String, name: String): String {
val d = dir.trim('\\', '/')
return if (d.isEmpty()) name else "$d\\$name"
}
}

View File

@@ -0,0 +1,40 @@
package com.bm.andpictures
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
/**
* Watches for the device joining a network. When one appears we ask for a sync;
* the worker itself decides whether the NAS is actually reachable, which is the
* "am I home?" signal this app uses.
*/
class NetworkMonitor(private val context: Context) {
private val connectivityManager: ConnectivityManager? =
context.getSystemService(ConnectivityManager::class.java)
private var callback: ConnectivityManager.NetworkCallback? = null
fun start() {
if (callback != null) return
val cb = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
WorkScheduler.requestSync(context, replace = false)
}
}
val request = NetworkRequest.Builder()
.addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
.addTransportType(NetworkCapabilities.TRANSPORT_ETHERNET)
.build()
runCatching { connectivityManager?.registerNetworkCallback(request, cb) }
callback = cb
}
fun stop() {
callback?.let { runCatching { connectivityManager?.unregisterNetworkCallback(it) } }
callback = null
}
}

View File

@@ -0,0 +1,39 @@
package com.bm.andpictures
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
object Notifications {
private const val CHANNEL_ID = "andpictures-sync"
private const val RESULT_ID = 1002
fun createChannel(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
"Photo sync",
NotificationManager.IMPORTANCE_LOW,
).apply { description = "Background photo uploads to the NAS" }
context.getSystemService(NotificationManager::class.java)
.createNotificationChannel(channel)
}
}
fun showResult(context: Context, text: String) {
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_sync)
.setContentTitle("Photo sync")
.setContentText(text)
.setAutoCancel(true)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.build()
runCatching {
NotificationManagerCompat.from(context).notify(RESULT_ID, notification)
}
}
}

View File

@@ -0,0 +1,138 @@
package com.bm.andpictures
import android.content.ContentUris
import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import android.util.Size
import java.io.IOException
import java.io.InputStream
/**
* Read-only browser over the photos in the system MediaStore.
*
* The folders are derived from each photo's storage location, so we only ever
* surface directories the app is already allowed to read — no
* MANAGE_EXTERNAL_STORAGE ("all files access") is needed.
*/
class PhoneFileSource(private val context: Context) : FileSource {
private data class Row(val name: String, val dir: String, val size: Long, val uri: Uri)
private val rows: List<Row>
private val dirs: Set<String>
init {
rows = queryImages()
val allDirs = HashSet<String>()
for (row in rows) {
var cur = row.dir
while (cur.isNotEmpty()) {
allDirs.add(cur)
cur = parentKey(cur)
}
}
dirs = allDirs
}
override val title: String get() = "Phone"
override val rootKey: String = ""
override fun list(dirKey: String): List<TileEntry> {
val prefix = if (dirKey.isEmpty()) "" else "$dirKey/"
val subDirs = dirs.asSequence()
.filter {
it != dirKey && it.startsWith(prefix) &&
!it.removePrefix(prefix).contains('/')
}
.sortedBy { it.lowercase() }
.map { TileEntry(it.substringAfterLast('/'), it, isDirectory = true, sizeBytes = 0L, isImage = false) }
.toList()
val files = rows.asSequence()
.filter { it.dir == dirKey }
.sortedBy { it.name.lowercase() }
.map { TileEntry(it.name, it.uri.toString(), isDirectory = false, sizeBytes = it.size, isImage = isImageName(it.name)) }
.toList()
return subDirs + files
}
override fun parentOf(dirKey: String): String? =
if (dirKey.isEmpty()) null else parentKey(dirKey)
override fun displayName(dirKey: String): String =
if (dirKey.isEmpty()) "Phone storage" else dirKey
override fun openStream(key: String): InputStream =
context.contentResolver.openInputStream(Uri.parse(key))
?: throw IOException("Cannot open $key")
/** On Android 10+ the platform thumbnail is far cheaper than decoding. */
override fun loadThumbnail(entry: TileEntry, reqSize: Int): Bitmap? {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
runCatching {
context.contentResolver.loadThumbnail(
Uri.parse(entry.key),
Size(reqSize, reqSize),
null,
)
}.getOrNull()?.let { return it }
}
return super.loadThumbnail(entry, reqSize)
}
private fun queryImages(): List<Row> {
val collection = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
val hasRelative = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
val projection = ArrayList<String>().apply {
add(MediaStore.Images.Media._ID)
add(MediaStore.Images.Media.DISPLAY_NAME)
add(MediaStore.Images.Media.SIZE)
add(if (hasRelative) MediaStore.Images.Media.RELATIVE_PATH else MediaStore.Images.Media.DATA)
}.toTypedArray()
val out = ArrayList<Row>()
context.contentResolver.query(collection, projection, null, null, null)?.use { c ->
val idCol = c.getColumnIndexOrThrow(MediaStore.Images.Media._ID)
val nameCol = c.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME)
val sizeCol = c.getColumnIndexOrThrow(MediaStore.Images.Media.SIZE)
val dirCol = c.getColumnIndexOrThrow(
if (hasRelative) MediaStore.Images.Media.RELATIVE_PATH else MediaStore.Images.Media.DATA,
)
while (c.moveToNext()) {
val id = c.getLong(idCol)
val name = c.getString(nameCol) ?: "IMG_$id.jpg"
val size = c.getLong(sizeCol)
val raw = c.getString(dirCol)
out += Row(
name = name,
dir = dirKeyFor(raw, hasRelative),
size = size,
uri = ContentUris.withAppendedId(collection, id),
)
}
}
return out
}
private fun dirKeyFor(raw: String?, relative: Boolean): String {
if (raw.isNullOrBlank()) return ""
return if (relative) {
raw.trim('/')
} else {
// DATA is an absolute file path; drop the file name and storage root.
stripStorageRoot(raw.substringBeforeLast('/', ""))
}
}
private fun stripStorageRoot(path: String): String {
val root = runCatching { Environment.getExternalStorageDirectory().absolutePath }.getOrNull()
val trimmed = if (root != null && path.startsWith(root)) path.removePrefix(root) else path
return trimmed.trim('/')
}
private companion object {
fun parentKey(dir: String): String = dir.substringBeforeLast('/', "")
}
}

View File

@@ -0,0 +1,88 @@
package com.bm.andpictures
import android.Manifest
import android.content.ContentUris
import android.content.Context
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import androidx.core.content.ContextCompat
/** A single image row from the system MediaStore. */
data class PhotoItem(
val id: Long,
val displayName: String,
val sizeBytes: Long,
/** Epoch millis the media was added to the device — used as the sync cursor. */
val dateAddedMillis: Long,
/** Epoch millis the shot was taken, or 0 when unknown — used for the folder. */
val dateTakenMillis: Long,
val uri: Uri,
) {
/** Best available timestamp for choosing the destination folder. */
val effectiveDateMillis: Long
get() = if (dateTakenMillis > 0L) dateTakenMillis else dateAddedMillis
}
object PhotoStore {
/** Permission this app needs to read images on the running OS version. */
val mediaPermission: String
get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Manifest.permission.READ_MEDIA_IMAGES
} else {
Manifest.permission.READ_EXTERNAL_STORAGE
}
fun hasPermission(context: Context): Boolean =
ContextCompat.checkSelfPermission(context, mediaPermission) ==
PackageManager.PERMISSION_GRANTED
/**
* Returns every image whose DATE_ADDED is strictly newer than [sinceMillis].
* Results are ordered oldest first so the cursor advances monotonically.
*
* DATE_ADDED is stored by MediaStore in *seconds*, while the caller works in
* millis, so we convert at the boundary.
*/
fun newPhotos(context: Context, sinceMillis: Long): List<PhotoItem> {
val sinceSeconds = sinceMillis / 1000L
val collection = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
val projection = arrayOf(
MediaStore.Images.Media._ID,
MediaStore.Images.Media.DISPLAY_NAME,
MediaStore.Images.Media.SIZE,
MediaStore.Images.Media.DATE_ADDED,
MediaStore.Images.Media.DATE_TAKEN,
)
// '>=' rather than '>' so a run interrupted mid-second is safely
// re-checked instead of skipping photos that share the cursor's second.
val selection = "${MediaStore.Images.Media.DATE_ADDED} >= ?"
val args = arrayOf(sinceSeconds.toString())
val order = "${MediaStore.Images.Media.DATE_ADDED} ASC"
val out = ArrayList<PhotoItem>()
context.contentResolver.query(collection, projection, selection, args, order)?.use { c ->
val idCol = c.getColumnIndexOrThrow(MediaStore.Images.Media._ID)
val nameCol = c.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME)
val sizeCol = c.getColumnIndexOrThrow(MediaStore.Images.Media.SIZE)
val addedCol = c.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_ADDED)
val takenCol = c.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_TAKEN)
while (c.moveToNext()) {
val id = c.getLong(idCol)
out += PhotoItem(
id = id,
displayName = c.getString(nameCol) ?: "IMG_$id.jpg",
sizeBytes = c.getLong(sizeCol),
dateAddedMillis = c.getLong(addedCol) * 1000L,
dateTakenMillis = c.getLong(takenCol),
uri = ContentUris.withAppendedId(collection, id),
)
}
}
return out
}
}

View File

@@ -0,0 +1,89 @@
package com.bm.andpictures
import android.graphics.Bitmap
import android.net.Uri
import android.os.Bundle
import android.view.View
import android.widget.ImageView
import android.widget.ProgressBar
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.IOException
/**
* Full-screen preview for a single photo, loaded from whichever source the
* browser was showing. Tap anywhere to go back.
*/
class PhotoViewActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_photo_view)
val mode = intent.getStringExtra(EXTRA_MODE) ?: FileBrowserActivity.MODE_PHONE
val key = intent.getStringExtra(EXTRA_KEY)
val name = intent.getStringExtra(EXTRA_NAME)
supportActionBar?.title = name ?: getString(R.string.title_photo_view)
val image = findViewById<ImageView>(R.id.photoImage)
val progress = findViewById<ProgressBar>(R.id.photoProgress)
val message = findViewById<TextView>(R.id.photoMessage)
image.setOnClickListener { finish() }
if (key.isNullOrBlank()) {
progress.visibility = View.GONE
message.visibility = View.VISIBLE
message.setText(R.string.photo_view_failed)
return
}
val req = maxOf(displayWidth(), displayHeight())
lifecycleScope.launch {
val bitmap = withContext(Dispatchers.IO) { load(mode, key, req) }
progress.visibility = View.GONE
if (bitmap != null) {
image.setImageBitmap(bitmap)
} else {
message.visibility = View.VISIBLE
message.setText(R.string.photo_view_failed)
}
}
}
private fun load(mode: String, key: String, req: Int): Bitmap? {
if (mode == FileBrowserActivity.MODE_PHONE) {
return runCatching {
Bitmaps.decodeSampled(
{ contentResolver.openInputStream(Uri.parse(key)) ?: throw IOException("open $key") },
req,
)
}.getOrNull()
}
val p = Prefs(this)
if (!p.isConfigured) return null
val uploader = SmbUploader(p.host, p.port, p.share, p.domain, p.user, p.password)
return try {
uploader.connect()
runCatching { Bitmaps.decodeSampled({ uploader.openRead(key) }, req) }.getOrNull()
} catch (t: Throwable) {
null
} finally {
runCatching { uploader.close() }
}
}
private fun displayWidth() = resources.displayMetrics.widthPixels
private fun displayHeight() = resources.displayMetrics.heightPixels
companion object {
const val EXTRA_MODE = "extra_mode"
const val EXTRA_KEY = "extra_key"
const val EXTRA_NAME = "extra_name"
}
}

View File

@@ -0,0 +1,102 @@
package com.bm.andpictures
import android.content.Context
import androidx.core.content.edit
/**
* Thin wrapper over SharedPreferences. Every value the user can change is here,
* plus the single piece of sync state: the timestamp of the last upload.
*/
class Prefs(context: Context) {
private val sp = context.applicationContext
.getSharedPreferences("andpictures", Context.MODE_PRIVATE)
var host: String
get() = sp.getString(KEY_HOST, DEFAULT_HOST)!!
set(v) = sp.edit { putString(KEY_HOST, v.trim()) }
var port: Int
get() = sp.getInt(KEY_PORT, DEFAULT_PORT)
set(v) = sp.edit { putInt(KEY_PORT, v) }
var share: String
get() = sp.getString(KEY_SHARE, DEFAULT_SHARE)!!
set(v) = sp.edit { putString(KEY_SHARE, v.trim()) }
var basePath: String
get() = sp.getString(KEY_BASE, DEFAULT_BASE)!!
set(v) = sp.edit { putString(KEY_BASE, v.trim().trim('\\', '/')) }
var domain: String
get() = sp.getString(KEY_DOMAIN, "")!!
set(v) = sp.edit { putString(KEY_DOMAIN, v.trim()) }
var user: String
get() = sp.getString(KEY_USER, "")!!
set(v) = sp.edit { putString(KEY_USER, v.trim()) }
var password: String
get() = sp.getString(KEY_PASS, "")!!
set(v) = sp.edit { putString(KEY_PASS, v) }
/** Template for a decade folder. {start} is the first year, {end} the last, {prefix} is start/10. */
var decadeFormat: String
get() = sp.getString(KEY_DECADE_FMT, DEFAULT_DECADE_FMT)!!
set(v) = sp.edit { putString(KEY_DECADE_FMT, v.ifBlank { DEFAULT_DECADE_FMT }) }
/** printf-style format for the month folder, given a 1-based month number. */
var monthFormat: String
get() = sp.getString(KEY_MONTH_FMT, DEFAULT_MONTH_FMT)!!
set(v) = sp.edit { putString(KEY_MONTH_FMT, v.ifBlank { DEFAULT_MONTH_FMT }) }
/**
* Cursor: the DATE_ADDED (epoch millis) of the newest photo already handled.
* Anything newer than this gets uploaded on the next run.
*/
var lastSyncMillis: Long
get() = sp.getLong(KEY_LAST_SYNC, 0L)
set(v) = sp.edit { putLong(KEY_LAST_SYNC, v) }
/** Human-readable result of the most recent run, shown in the UI. */
var lastStatus: String
get() = sp.getString(KEY_LAST_STATUS, "")!!
set(v) = sp.edit { putString(KEY_LAST_STATUS, v) }
/**
* The app is only ready once it has a host, a share and the account that can
* open it. The photo share is protected, so a username *and* password are
* required before any connection is attempted.
*/
val isConfigured: Boolean
get() = host.isNotBlank() && share.isNotBlank() &&
user.isNotBlank() && password.isNotBlank()
/** True when the photo-share account (username + password) is present. */
val hasCredentials: Boolean
get() = user.isNotBlank() && password.isNotBlank()
companion object {
const val WORK_PERIODIC = "andpictures-periodic-sync"
const val WORK_ONESHOT = "andpictures-oneshot-sync"
const val DEFAULT_HOST = "192.168.123.2"
const val DEFAULT_PORT = 445
const val DEFAULT_SHARE = "photo"
const val DEFAULT_BASE = "Home"
const val DEFAULT_DECADE_FMT = "{prefix}X"
const val DEFAULT_MONTH_FMT = "%02d"
private const val KEY_HOST = "host"
private const val KEY_PORT = "port"
private const val KEY_SHARE = "share"
private const val KEY_BASE = "base"
private const val KEY_DOMAIN = "domain"
private const val KEY_USER = "user"
private const val KEY_PASS = "pass"
private const val KEY_DECADE_FMT = "decade_fmt"
private const val KEY_MONTH_FMT = "month_fmt"
private const val KEY_LAST_SYNC = "last_sync"
private const val KEY_LAST_STATUS = "last_status"
}
}

View File

@@ -0,0 +1,211 @@
package com.bm.andpictures
import com.hierynomus.msdtyp.AccessMask
import com.hierynomus.mssmb2.SMBApiException
import com.hierynomus.mssmb2.SMB2CreateDisposition
import com.hierynomus.mssmb2.SMB2ShareAccess
import com.hierynomus.smbj.SMBClient
import com.hierynomus.smbj.SmbConfig
import com.hierynomus.smbj.auth.AuthenticationContext
import com.hierynomus.smbj.connection.Connection
import com.hierynomus.smbj.session.Session
import com.hierynomus.smbj.share.DiskShare
import java.io.Closeable
import java.io.FilterInputStream
import java.io.IOException
import java.io.InputStream
import java.util.concurrent.TimeUnit
/**
* Small wrapper around smbj for the three things this app needs: list folders,
* create a folder tree, and stream a file up. Uses SMB2/3 only (SMB1 is off on
* the target NAS). All paths passed in here use the caller's convention; this
* class normalises separators to the backslash form SMB expects.
*/
class SmbUploader(
private val host: String,
private val port: Int,
private val shareName: String,
private val domain: String,
private val user: String,
private val password: String,
) : Closeable {
private val config = SmbConfig.builder()
.withTimeout(CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.withSoTimeout(READ_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.build()
private var client: SMBClient? = null
private var connection: Connection? = null
private var session: Session? = null
private var share: DiskShare? = null
/** Connects, authenticates and opens the share. Throws on any failure. */
fun connect() {
close()
val smb = SMBClient(config)
try {
val conn = smb.connect(host, port)
val auth = AuthenticationContext(
user,
password.toCharArray(),
domain.ifBlank { null },
)
val sess = conn.authenticate(auth)
val opened = sess.connectShare(shareName)
if (opened !is DiskShare) {
throw IOException("'$shareName' is not a disk share")
}
client = smb
connection = conn
session = sess
share = opened
} catch (t: Throwable) {
runCatching { smb.close() }
throw t
}
}
/**
* The share we can talk to right now. A NAS (or the network in between) can
* drop an idle SMB session silently, which leaves [share] closed. Instead of
* failing, reconnect transparently so a browser left open keeps working.
*/
private fun requireShare(): DiskShare {
val current = share
if (current == null || !current.isConnected) {
close()
connect()
}
return share!!
}
fun folderExists(path: String): Boolean = requireShare().folderExists(normalise(path))
fun fileExists(path: String): Boolean = requireShare().fileExists(normalise(path))
/**
* Size in bytes of [path], or null when it does not exist. Used so a partial
* (e.g. zero-byte) upload left behind by an interrupted run is re-uploaded
* instead of being treated as already done.
*/
fun fileSize(path: String): Long? = try {
requireShare().getFileInformation(normalise(path)).standardInformation.endOfFile
} catch (e: SMBApiException) {
null
}
/** Creates every missing segment of [path]. Returns the normalised path. */
fun mkdirs(path: String): String {
val norm = normalise(path)
if (norm.isEmpty()) return norm
var current = ""
for (segment in norm.split('\\')) {
if (segment.isEmpty()) continue
current = if (current.isEmpty()) segment else "$current\\$segment"
if (!requireShare().folderExists(current)) {
requireShare().mkdir(current)
}
}
return current
}
/** Lists the immediate sub-directory names of [path]. */
fun listFolders(path: String): List<String> {
val norm = normalise(path)
return requireShare().list(norm)
.asSequence()
.filter { (it.fileAttributes and FILE_ATTRIBUTE_DIRECTORY) != 0L }
.map { it.fileName }
.filter { it != "." && it != ".." }
.toList()
}
/** Lists the immediate children of [path] — both folders and files. */
fun listEntries(path: String): List<RemoteEntry> {
val norm = normalise(path)
return requireShare().list(norm)
.asSequence()
.filter { it.fileName != "." && it.fileName != ".." }
.map {
RemoteEntry(
name = it.fileName,
isDirectory = (it.fileAttributes and FILE_ATTRIBUTE_DIRECTORY) != 0L,
sizeBytes = it.endOfFile,
)
}
.toList()
}
/**
* Opens [path] for reading. Closing the returned stream also releases the
* underlying SMB handle.
*/
fun openRead(path: String): InputStream {
val file = requireShare().openFile(
normalise(path),
setOf(AccessMask.GENERIC_READ),
null,
SMB2ShareAccess.ALL,
SMB2CreateDisposition.FILE_OPEN,
null,
)
val delegate = file.inputStream
return object : FilterInputStream(delegate) {
override fun close() {
try {
super.close()
} finally {
runCatching { file.close() }
}
}
}
}
/** A single entry returned by [listEntries]. */
data class RemoteEntry(
val name: String,
val isDirectory: Boolean,
val sizeBytes: Long,
)
/** Streams [source] to [destPath], overwriting an existing file. */
fun upload(destPath: String, source: InputStream) {
val norm = normalise(destPath)
val file = requireShare().openFile(
norm,
setOf(AccessMask.GENERIC_WRITE),
null,
SMB2ShareAccess.ALL,
SMB2CreateDisposition.FILE_OVERWRITE_IF,
null,
)
try {
file.outputStream.use { os -> source.copyTo(os, BUFFER_SIZE) }
} finally {
file.close()
}
}
override fun close() {
runCatching { share?.close() }
runCatching { session?.close() }
runCatching { connection?.close() }
runCatching { client?.close() }
share = null
session = null
connection = null
client = null
}
companion object {
private const val CONNECT_TIMEOUT_SECONDS = 15L
private const val READ_TIMEOUT_SECONDS = 60L
private const val BUFFER_SIZE = 64 * 1024
private const val FILE_ATTRIBUTE_DIRECTORY = 0x10L
/** Converts any mix of '/' and '\' into a clean backslash path. */
fun normalise(path: String): String = path.replace('/', '\\').trim('\\')
}
}

View File

@@ -0,0 +1,253 @@
package com.bm.andpictures
import android.content.Context
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.util.Calendar
import java.util.Date
import java.util.Locale
/** Outcome of a single sync pass. */
data class SyncResult(
val uploaded: Int,
val skipped: Int,
val failed: Int,
val considered: Int,
val newCursorMillis: Long,
val connected: Boolean,
val message: String,
)
/**
* The whole job: find photos newer than the stored cursor, work out their
* destination folder on the NAS, and upload the ones that are not already there.
*
* The cursor only advances past photos that were successfully handled, so a
* failed upload is retried on the next run rather than silently dropped.
*/
class SyncEngine(
private val context: Context,
private val prefs: Prefs,
) {
suspend fun run(): SyncResult = runLock.withLock { doRun() }
private suspend fun doRun(): SyncResult = withContext(Dispatchers.IO) {
if (!prefs.isConfigured) {
return@withContext idle(
"Not configured — enter the photo-share username and password, then tap Save.",
)
}
if (!PhotoStore.hasPermission(context)) {
return@withContext idle("No photo access permission")
}
val since = prefs.lastSyncMillis
val photos = PhotoStore.newPhotos(context, since)
if (photos.isEmpty()) {
// Nothing new — move the cursor to now so we don't rescan forever.
return@withContext SyncResult(
0, 0, 0, 0, System.currentTimeMillis(), true, "No new photos"
)
}
val uploader = SmbUploader(
host = prefs.host,
port = prefs.port,
shareName = prefs.share,
domain = prefs.domain,
user = prefs.user,
password = prefs.password,
)
try {
uploader.connect()
} catch (t: Throwable) {
Log.w(SyncWorker.TAG, "NAS connect failed: ${t.message}", t)
return@withContext SyncResult(
uploaded = 0,
skipped = 0,
failed = 0,
considered = photos.size,
newCursorMillis = since,
connected = false,
message = "NAS unreachable: ${t.message ?: t.javaClass.simpleName}",
)
}
var uploaded = 0
var skipped = 0
var failed = 0
var cursor = since
val calendar = Calendar.getInstance()
try {
for (photo in photos) {
try {
val destDir = destinationDirectory(uploader, photo, calendar)
uploader.mkdirs(destDir)
val destPath = "$destDir\\${sanitise(photo.displayName)}"
val remoteSize = uploader.fileSize(destPath)
if (remoteSize != null && remoteSize > 0L && remoteSize == photo.sizeBytes) {
skipped++
Log.d(SyncWorker.TAG, "skip (already on NAS): $destPath")
} else {
if (remoteSize != null) {
Log.w(
SyncWorker.TAG,
"re-upload (size mismatch): $destPath remote=$remoteSize local=${photo.sizeBytes}",
)
}
context.contentResolver.openInputStream(photo.uri).use { input ->
if (input == null) {
failed++
Log.w(SyncWorker.TAG, "no input stream for ${photo.displayName}")
} else {
uploader.upload(destPath, input)
uploaded++
Log.i(SyncWorker.TAG, "uploaded ${photo.displayName} -> $destPath")
}
}
}
// Advance and persist the cursor per photo, so an interrupted
// run resumes from where it stopped instead of rescanning the
// whole library.
if (photo.dateAddedMillis > cursor) {
cursor = photo.dateAddedMillis
prefs.lastSyncMillis = cursor
}
} catch (t: Throwable) {
// A single bad file must not abort the whole batch.
failed++
Log.w(SyncWorker.TAG, "failed ${photo.displayName}: ${t.message}", t)
if (photo.dateAddedMillis > cursor) {
cursor = photo.dateAddedMillis
prefs.lastSyncMillis = cursor
}
}
}
} finally {
uploader.close()
}
val summary = buildString {
append("Uploaded ").append(uploaded)
if (skipped > 0) append(", skipped ").append(skipped).append(" (already on NAS)")
if (failed > 0) append(", failed ").append(failed)
}
SyncResult(
uploaded = uploaded,
skipped = skipped,
failed = failed,
considered = photos.size,
newCursorMillis = cursor,
connected = true,
message = summary,
)
}
private fun idle(message: String) = SyncResult(
uploaded = 0, skipped = 0, failed = 0, considered = 0,
newCursorMillis = prefs.lastSyncMillis, connected = true, message = message,
)
/**
* Picks the decade folder for [year]. The NAS names these with the variable
* digits replaced by `X` (`19XX`, `200X`, `201X`, `202X`); a plain `2020s`
* style is still understood for backwards compatibility. When nothing fits,
* the configured template is used to create a new folder.
*/
private fun resolveDecade(uploader: SmbUploader, base: String, year: Int): String {
val existing = runCatching { uploader.listFolders(base) }.getOrDefault(emptyList())
val y = String.format(Locale.US, "%04d", year)
var best: String? = null
var bestScore = -1
for (name in existing) {
val score = decadeScore(name, y, year)
if (score > bestScore) {
bestScore = score
best = name
}
}
if (best != null) return best
val start = year - year % 10
return prefs.decadeFormat
.replace("{start}", start.toString())
.replace("{end}", (start + 9).toString())
.replace("{prefix}", (start / 10).toString())
}
/**
* How well an existing folder name fits [year], or -1 for no fit. Wildcard
* patterns (`202X`) score by how many literal digits they pin down, so a more
* specific folder wins over a broader one; `2020s` style scores lower so the
* real `X` folders are always preferred.
*/
private fun decadeScore(name: String, y: String, year: Int): Int {
var best = -1
for (m in YEAR_PATTERN.findAll(name)) {
val pat = m.value
if (pat.none { it == 'X' || it == 'x' }) continue // a plain year, not a decade
val fits = pat.indices.all { i -> pat[i] == 'X' || pat[i] == 'x' || pat[i] == y[i] }
if (fits) {
val score = 100 + pat.count { it.isDigit() }
if (score > best) best = score
}
}
DECADE_WORD.find(name)?.let {
val start = it.groupValues[1].toInt()
if (year in start until start + 10 && best < 50) best = 50
}
return best
}
/**
* Builds `base\decade\year\month`. The decade folder is matched against the
* folders that already exist on the NAS (the `202X` style); the template is
* used only when no existing folder fits.
*/
private fun destinationDirectory(
uploader: SmbUploader,
photo: PhotoItem,
calendar: Calendar,
): String {
calendar.time = Date(photo.effectiveDateMillis)
val year = calendar.get(Calendar.YEAR)
val month = calendar.get(Calendar.MONTH) + 1
val base = prefs.basePath
val decade = resolveDecade(uploader, base, year)
val yearFolder = String.format(Locale.US, "%04d", year)
val monthFolder = String.format(Locale.US, prefs.monthFormat, month)
return listOf(base, decade, yearFolder, monthFolder)
.filter { it.isNotBlank() }
.joinToString("\\")
}
/** Strips characters that are illegal in SMB/Windows file names. */
private fun sanitise(name: String): String {
val cleaned = name.map { if (it in ILLEGAL || it.code < 0x20) '_' else it }
.joinToString("")
.trim()
return cleaned.ifBlank { "photo.jpg" }
}
companion object {
/**
* Serialises sync runs within the process so the periodic worker and a
* network-triggered worker can never upload the same photos at once.
*/
private val runLock = Mutex()
private val ILLEGAL = charArrayOf('<', '>', ':', '"', '/', '\\', '|', '?', '*')
/** A 4-char decade pattern such as `202X` or `19XX`. */
private val YEAR_PATTERN = Regex("[0-9Xx]{4}")
/** A `2020s` style decade name. */
private val DECADE_WORD = Regex("(\\d{4})s")
}
}

View File

@@ -0,0 +1,61 @@
package com.bm.andpictures
import android.content.Context
import android.util.Log
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
/**
* Runs [SyncEngine] off the main thread. Scheduled by [WorkScheduler] both
* periodically and on network-connect events. Retries when the NAS is not
* reachable, so a sync that fires before the Wi-Fi association is ready will
* simply try again a little later.
*
* Note: we deliberately do NOT call setForeground(). On Android 12+ a
* background worker may not start a foreground service, and WorkManager's
* async service start would crash the process. WorkManager already gives a
* background worker a generous execution window, which is plenty for the
* incremental uploads this app does.
*/
class SyncWorker(
context: Context,
params: WorkerParameters,
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val prefs = Prefs(applicationContext)
if (!prefs.isConfigured) {
prefs.lastStatus =
"Not configured — open the app and enter the NAS host/share and the " +
"photo-share username and password, then tap Save."
Log.i(TAG, "skip: not configured")
return Result.success()
}
if (!PhotoStore.hasPermission(applicationContext)) {
prefs.lastStatus = "Waiting for photo access permission"
Log.i(TAG, "skip: no photo access permission")
return Result.success()
}
// Show progress immediately so the UI never keeps showing a stale status
// from an earlier run while this one is in flight.
prefs.lastStatus = "Syncing…"
val result = SyncEngine(applicationContext, prefs).run()
prefs.lastSyncMillis = result.newCursorMillis
prefs.lastStatus = result.message
Log.i(
TAG,
"sync done: ${result.message} " +
"(uploaded=${result.uploaded}, skipped=${result.skipped}, " +
"failed=${result.failed}, connected=${result.connected})",
)
Notifications.showResult(applicationContext, result.message)
return if (result.connected) Result.success() else Result.retry()
}
companion object {
const val TAG = "AndPictures"
}
}

View File

@@ -0,0 +1,48 @@
package com.bm.andpictures
import android.content.Context
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import java.util.concurrent.TimeUnit
/** Central place for the WorkManager schedules this app uses. */
object WorkScheduler {
/** Guarantees a periodic safety-net run even if no network event arrives. */
fun schedulePeriodic(context: Context) {
val request = PeriodicWorkRequestBuilder<SyncWorker>(15, TimeUnit.MINUTES)
.setConstraints(onlineConstraints())
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
Prefs.WORK_PERIODIC,
ExistingPeriodicWorkPolicy.KEEP,
request,
)
}
/**
* Asks for an immediate sync. [replace] = true (manual button) restarts a
* running job; false (network event) leaves an in-flight sync alone.
*/
fun requestSync(context: Context, replace: Boolean) {
val request = OneTimeWorkRequestBuilder<SyncWorker>()
.setConstraints(onlineConstraints())
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.build()
WorkManager.getInstance(context).enqueueUniqueWork(
Prefs.WORK_ONESHOT,
if (replace) ExistingWorkPolicy.REPLACE else ExistingWorkPolicy.KEEP,
request,
)
}
private fun onlineConstraints() = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
}

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#90A4AE"
android:pathData="M6,2c-1.1,0 -1.99,0.9 -1.99,2L4,20c0,1.1 0.89,2 1.99,2H18c1.1,0 2,-0.9 2,-2V8l-6,-6H6zM13,9V3.5L18.5,9H13z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFB300"
android:pathData="M10,4H4c-1.1,0 -1.99,0.9 -1.99,2L2,18c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2V8c0,-1.1 -0.9,-2 -2,-2h-8l-2,-2z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#607D8B"
android:pathData="M21,19V5c0,-1.1 -0.9,-2 -2,-2H5c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2zM8.5,13.5l2.5,3.01L14.5,12l4.5,6H5l3.5,-4.5z" />
</vector>

View File

@@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="48"
android:viewportHeight="48">
<path
android:fillColor="#2E7D32"
android:pathData="M0,0h48v48h-48z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M10,14 L24,24 L38,14 L38,18 L24,28 L10,18 Z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M14,30 h20 v4 h-20 z M14,37 h14 v4 h-14 z" />
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path
android:fillColor="#FFFFFF"
android:pathData="M12,4 L4,10 L12,16 L20,10 Z M12,18 l-6,-4 L4,16 l8,6 8,-6 -2,-2 Z" />
</vector>

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/browserGrid"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:padding="4dp"
android:visibility="gone" />
<ProgressBar
android:id="@+id/browserProgress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center" />
<TextView
android:id="@+id/browserMessage"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center"
android:padding="24dp"
android:textSize="15sp"
android:visibility="gone" />
</FrameLayout>

View File

@@ -0,0 +1,174 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/statusText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/status_idle"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:id="@+id/lastSyncText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="Last upload: never"
android:textSize="13sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:orientation="horizontal">
<Button
android:id="@+id/btnSync"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Sync now" />
<Button
android:id="@+id/btnSave"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Save" />
</LinearLayout>
<Button
android:id="@+id/btnPermission"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Grant photo access" />
<Button
android:id="@+id/btnTest"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Test connection" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<Button
android:id="@+id/btnPhoneFiles"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/browse_phone" />
<Button
android:id="@+id/btnNasFiles"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/browse_nas" />
</LinearLayout>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="NAS / SMB settings"
android:textStyle="bold" />
<EditText
android:id="@+id/etHost"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Host (e.g. 192.168.123.2)"
android:inputType="textUri" />
<EditText
android:id="@+id/etPort"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Port (445)"
android:inputType="number" />
<EditText
android:id="@+id/etShare"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Share (e.g. photo)"
android:inputType="text" />
<EditText
android:id="@+id/etBasePath"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Base directory (e.g. Home)"
android:inputType="text" />
<EditText
android:id="@+id/etDomain"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Domain (optional)"
android:inputType="text" />
<EditText
android:id="@+id/etUser"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Photo share username"
android:inputType="text" />
<EditText
android:id="@+id/etPassword"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Photo share password"
android:inputType="textPassword" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textSize="12sp"
android:text="The photo share is protected: enter a username and password that can open it. Both are required." />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="Folder layout"
android:textStyle="bold" />
<EditText
android:id="@+id/etDecadeFormat"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Decade folder (e.g. {prefix}X -> 202X)"
android:inputType="text" />
<EditText
android:id="@+id/etMonthFormat"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Month folder (e.g. %02d -> 05)"
android:inputType="text" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textSize="12sp"
android:text="Existing decade folders are detected automatically; the decade template is only used to create a missing one. Year folders use YYYY." />
</LinearLayout>
</ScrollView>

View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#000000">
<ImageView
android:id="@+id/photoImage"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:contentDescription="@string/title_photo_view"
android:scaleType="fitCenter" />
<ProgressBar
android:id="@+id/photoProgress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center" />
<TextView
android:id="@+id/photoMessage"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:textColor="#FFFFFF"
android:textSize="15sp"
android:visibility="gone" />
</FrameLayout>

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="4dp">
<ImageView
android:id="@+id/tileImage"
android:layout_width="match_parent"
android:layout_height="96dp"
android:background="#14000000"
android:padding="6dp"
android:scaleType="centerCrop" />
<TextView
android:id="@+id/tileName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:ellipsize="end"
android:gravity="center"
android:maxLines="2"
android:textSize="11sp" />
</LinearLayout>

View File

@@ -0,0 +1,14 @@
<resources>
<string name="app_name">AndPictures</string>
<string name="status_idle">Idle — no sync has run yet.</string>
<string name="browse_phone">Phone files</string>
<string name="browse_nas">NAS files</string>
<string name="browse_needs_permission">Grant photo access to browse phone photos.</string>
<string name="browse_needs_config">Configure the NAS connection first (host, share, username and password).</string>
<string name="browse_empty">This folder is empty.</string>
<string name="browse_not_image">Not an image file</string>
<string name="title_file_browser">Files</string>
<string name="title_photo_view">Photo</string>
<string name="photo_view_failed">Could not load this photo.</string>
</resources>

View File

@@ -0,0 +1,7 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="Theme.AndPictures" parent="Theme.AppCompat.DayNight.DarkActionBar">
<item name="colorPrimary">#2E7D32</item>
<item name="colorPrimaryDark">#1B5E20</item>
<item name="colorAccent">#43A047</item>
</style>
</resources>

5
build.gradle.kts Normal file
View File

@@ -0,0 +1,5 @@
// Top-level build file. Plugin versions are declared here and applied in :app.
plugins {
id("com.android.application") version "8.13.2" apply false
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
}

8
gradle.properties Normal file
View File

@@ -0,0 +1,8 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
org.gradle.caching=true
org.gradle.parallel=true
android.useAndroidX=true
android.nonTransitiveRClass=true
kotlin.code.style=official

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.4-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

251
gradlew vendored Executable file
View File

@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
gradlew.bat vendored Normal file
View File

@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

18
settings.gradle.kts Normal file
View File

@@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "AndPictures"
include(":app")