commit 6bc1f9a35581ae90116637bf5aeb90cf06bd08f8 Author: AndPictures Date: Wed Oct 14 10:47:43 2026 -0700 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) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..594275b --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..ce50bc6 --- /dev/null +++ b/README.md @@ -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): `\\\\`. +- Advances the cursor only past photos it successfully handled, so a failed + upload is retried next time rather than lost. +- Runs automatically (WorkManager, 15-minute periodic + a one-shot request every + time a Wi-Fi/Ethernet network appears) and on demand via a **Sync now** button. + +"Am I home?" is answered the way you asked: the worker simply tries to reach the +NAS. If it cannot, the run returns `Result.retry()` and tries again later — no +SSID matching. + +## Folder layout + +Configured base directory (default `Home`) → decade → year → month. This is the +layout actually found on the NAS: + +``` +photo/Home/ + 19XX/1999/… X = wildcard digits + 200X/2000/ … 200X/2008/… + 201X/2010/ … 201X/2018/… + 202X/2024/05/ 202X/2025/07/ 202X/2026/10/ ← where the app writes +``` + +- **Decade** is *discovered*: the app lists the existing folders under the base + directory and matches the photo's year against a folder name that uses `X` as a + wildcard (`19XX`, `200X`, `201X`, `202X`); the most specific match wins. A plain + `2020s` / `2020-2029` style name is still understood. Only if nothing fits does + it create one from the **Decade folder** template (default `{prefix}X` → `202X`; + `{start}` and `{end}` are also available). +- **Year** is `YYYY`. +- **Month** uses the **Month folder** template, a printf string applied to the + 1-based month (default `%02d` → `05`). The existing archive's month folders are + hand-made and inconsistent (`March`, `Mar2008`, or none), so a plain numeric + month is used. + +A photo counts as already uploaded only when the file on the NAS has the **same +size**, so a partial (e.g. zero-byte) upload from an interrupted run is retried +instead of blocking that photo forever. + +## Settings (all in the app UI, stored in SharedPreferences) + +| Setting | Default | Notes | +|---|---|---| +| Host | `192.168.123.2` | NAS address | +| Port | `445` | SMB | +| Share | `photo` | SMB share name | +| Base directory | `Home` | folder inside the share | +| Domain | *(empty)* | usually blank for Synology | +| Username / Password | *(empty)* | **required** — the account that can access the `photo` share | +| Decade folder | `{prefix}X` | template used only when creating a missing decade (`{prefix}` = start/10 → `202X`) | +| Month folder | `%02d` | printf format for the month folder | + +## Build + +Requirements: JDK 17 and the Android SDK (compileSdk 36, build-tools available). +The Gradle wrapper pins Gradle 8.14.4; AGP 8.13.2, Kotlin 2.1.0. + +```bash +./gradlew :app:assembleDebug # -> app/build/outputs/apk/debug/app-debug.apk +./gradlew :app:assembleRelease # -> app/build/outputs/apk/release/app-release.apk (signed) +``` + +`local.properties` must point at your SDK (`sdk.dir=/path/to/Android/Sdk`). + +### Release signing + +The release APK is signed with a keystore whose credentials live in +`keystore.properties` at the project root (excluded from version control): + +```properties +storeFile=keystore/andpictures-release.jks +storePassword=… +keyAlias=andpictures +keyPassword=… +``` + +If `keystore.properties` is absent the release build still runs but produces an +unsigned APK, so `assembleDebug` and secret-less CI builds keep working. **Back up +the `.jks` and its passwords** — losing them means you can never update an +already-installed app in place. + +## Install + +With developer mode / USB debugging on: + +```bash +adb install -r app/build/outputs/apk/release/app-release.apk +``` + +**Without developer mode (sideload):** copy `app-release.apk` to the phone (USB +file transfer, cloud, chat, or the LAN server below), open it with the Files app, +and confirm. Android will ask you to allow **Install unknown apps** for whichever +app opens it. + +Then open the app, enter the share and the **photo-share account** (username + +password — both required, since the share is protected), tap **Grant photo +access**, and either wait for an automatic run or tap **Sync now**. Without the +credentials the app refuses to contact the NAS and says so. + +**Test connection** connects with the current settings and prints what is really +on the NAS under the base directory (base → decade folders → a few year and month +subfolders). Use it to confirm the exact folder naming with an account that can +reach the `photo` share. + +### Serving the APK over the LAN + +```bash +cd app/build/outputs/apk/release +python3 -m http.server 8080 +# on the phone's browser: http://:8080/app-release.apk +``` + +Download it in the phone's browser, open it, and allow installs for the browser. + +Note: switching a device from the **debug** build to the **release** build requires +uninstalling the debug app first (they are signed with different keys), which also +clears its saved settings. + +## Architecture + +| File | Responsibility | +|---|---| +| `MainActivity.kt` | Minimal settings + status UI, permission requests, "Sync now", "Test connection" | +| `AndPicturesApp.kt` | Notification channel, schedules periodic work, starts the network monitor | +| `NetworkMonitor.kt` | Fires a sync request when a Wi-Fi/Ethernet network appears | +| `WorkScheduler.kt` | WorkManager periodic + one-shot requests (network-constrained) | +| `SyncWorker.kt` | CoroutineWorker: runs the engine, persists the cursor/status | +| `SyncEngine.kt` | The job: MediaStore query → destination path → SMB upload | +| `SmbUploader.kt` | smbj wrapper: connect, list folders, mkdir -p, stream a file | +| `PhotoStore.kt` | MediaStore queries + permission helper | +| `Prefs.kt` | Typed SharedPreferences incl. the sync cursor | +| `Notifications.kt` | Result notification | + +## Permissions + +- `READ_MEDIA_IMAGES` (API 33+) / `READ_EXTERNAL_STORAGE` (≤ 32) +- `INTERNET`, `ACCESS_NETWORK_STATE`, `ACCESS_WIFI_STATE` +- `POST_NOTIFICATIONS`, `RECEIVE_BOOT_COMPLETED` + +No foreground service is used on purpose: on Android 12+ a background worker may +not start one, and WorkManager's async service start would crash the process. The +worker relies on WorkManager's normal background execution window instead. + +## Testing performed + +- **SMB layer**, against the real NAS (`//192.168.123.2/home`): connect, list, + segment-by-segment `mkdir`, upload, overwrite-truncation check, cleanup — passed. +- **Full app end-to-end** on an API 36 emulator: injected settings, added a photo, + let the network callback schedule the sync, and confirmed the file landed on the + NAS and was **byte-for-byte identical**; the cursor advanced. +- **Real device + real `photo` share**: installed on a Pixel 7 (Android 17), signed + in with the photo-share account, and the app auto-uploaded ~139 photos. This + exposed the real decade naming (`202X`, not `2020s`); the matcher was rewritten + and the 139 files were relocated into `photo/Home/202X/…` (verified: 139 files, + ~488 MB, no zero-byte files, byte-identical). + +## NAS layout (confirmed) + +The destination root is `//192.168.123.2/photo/Home`. Its decade folders use `X` +as a wildcard for the variable digits — `19XX`, `200X`, `201X`, `202X` — and each +year holds month subfolders. The app matches this scheme; the earlier `2020s` +guess (made before the share could be read) has been corrected. + +## Notes + +- The `photo` share is protected, so a **photo-share account** (username + + password) is required; the app will not contact the NAS without one. +- Month naming in the existing archive is hand-made and inconsistent, so the app + writes plain numeric months (`%02d`). Change the **Month folder** setting if you + prefer another convention. diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..150337e --- /dev/null +++ b/app/build.gradle.kts @@ -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") +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..b4bb37b --- /dev/null +++ b/app/proguard-rules.pro @@ -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.** { *; } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..cb80eb1 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/bm/andpictures/AndPicturesApp.kt b/app/src/main/java/com/bm/andpictures/AndPicturesApp.kt new file mode 100644 index 0000000..70489d9 --- /dev/null +++ b/app/src/main/java/com/bm/andpictures/AndPicturesApp.kt @@ -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() } + } +} diff --git a/app/src/main/java/com/bm/andpictures/Bitmaps.kt b/app/src/main/java/com/bm/andpictures/Bitmaps.kt new file mode 100644 index 0000000..457d08c --- /dev/null +++ b/app/src/main/java/com/bm/andpictures/Bitmaps.kt @@ -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 = + object : LruCache(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) + } +} diff --git a/app/src/main/java/com/bm/andpictures/FileBrowserActivity.kt b/app/src/main/java/com/bm/andpictures/FileBrowserActivity.kt new file mode 100644 index 0000000..60f25cb --- /dev/null +++ b/app/src/main/java/com/bm/andpictures/FileBrowserActivity.kt @@ -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() { + + private var items: List = emptyList() + + fun submit(list: List) { + 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 + } +} diff --git a/app/src/main/java/com/bm/andpictures/FileSource.kt b/app/src/main/java/com/bm/andpictures/FileSource.kt new file mode 100644 index 0000000..5d96fa1 --- /dev/null +++ b/app/src/main/java/com/bm/andpictures/FileSource.kt @@ -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 + + /** 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 = setOf( + "jpg", "jpeg", "png", "gif", "webp", "bmp", "heic", "heif", "dng", "avif", + ) + } +} diff --git a/app/src/main/java/com/bm/andpictures/MainActivity.kt b/app/src/main/java/com/bm/andpictures/MainActivity.kt new file mode 100644 index 0000000..3e59639 --- /dev/null +++ b/app/src/main/java/com/bm/andpictures/MainActivity.kt @@ -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