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:
17
.gitignore
vendored
Normal file
17
.gitignore
vendored
Normal 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
188
README.md
Normal 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
89
app/build.gradle.kts
Normal 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
6
app/proguard-rules.pro
vendored
Normal 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.** { *; }
|
||||||
49
app/src/main/AndroidManifest.xml
Normal file
49
app/src/main/AndroidManifest.xml
Normal 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>
|
||||||
15
app/src/main/java/com/bm/andpictures/AndPicturesApp.kt
Normal file
15
app/src/main/java/com/bm/andpictures/AndPicturesApp.kt
Normal 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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
55
app/src/main/java/com/bm/andpictures/Bitmaps.kt
Normal file
55
app/src/main/java/com/bm/andpictures/Bitmaps.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
262
app/src/main/java/com/bm/andpictures/FileBrowserActivity.kt
Normal file
262
app/src/main/java/com/bm/andpictures/FileBrowserActivity.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
54
app/src/main/java/com/bm/andpictures/FileSource.kt
Normal file
54
app/src/main/java/com/bm/andpictures/FileSource.kt
Normal 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",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
255
app/src/main/java/com/bm/andpictures/MainActivity.kt
Normal file
255
app/src/main/java/com/bm/andpictures/MainActivity.kt
Normal 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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
63
app/src/main/java/com/bm/andpictures/NasFileSource.kt
Normal file
63
app/src/main/java/com/bm/andpictures/NasFileSource.kt
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
40
app/src/main/java/com/bm/andpictures/NetworkMonitor.kt
Normal file
40
app/src/main/java/com/bm/andpictures/NetworkMonitor.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
39
app/src/main/java/com/bm/andpictures/Notifications.kt
Normal file
39
app/src/main/java/com/bm/andpictures/Notifications.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
138
app/src/main/java/com/bm/andpictures/PhoneFileSource.kt
Normal file
138
app/src/main/java/com/bm/andpictures/PhoneFileSource.kt
Normal 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('/', "")
|
||||||
|
}
|
||||||
|
}
|
||||||
88
app/src/main/java/com/bm/andpictures/PhotoStore.kt
Normal file
88
app/src/main/java/com/bm/andpictures/PhotoStore.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
89
app/src/main/java/com/bm/andpictures/PhotoViewActivity.kt
Normal file
89
app/src/main/java/com/bm/andpictures/PhotoViewActivity.kt
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
102
app/src/main/java/com/bm/andpictures/Prefs.kt
Normal file
102
app/src/main/java/com/bm/andpictures/Prefs.kt
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
211
app/src/main/java/com/bm/andpictures/SmbUploader.kt
Normal file
211
app/src/main/java/com/bm/andpictures/SmbUploader.kt
Normal 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('\\')
|
||||||
|
}
|
||||||
|
}
|
||||||
253
app/src/main/java/com/bm/andpictures/SyncEngine.kt
Normal file
253
app/src/main/java/com/bm/andpictures/SyncEngine.kt
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
61
app/src/main/java/com/bm/andpictures/SyncWorker.kt
Normal file
61
app/src/main/java/com/bm/andpictures/SyncWorker.kt
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
48
app/src/main/java/com/bm/andpictures/WorkScheduler.kt
Normal file
48
app/src/main/java/com/bm/andpictures/WorkScheduler.kt
Normal 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()
|
||||||
|
}
|
||||||
9
app/src/main/res/drawable/ic_file.xml
Normal file
9
app/src/main/res/drawable/ic_file.xml
Normal 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>
|
||||||
9
app/src/main/res/drawable/ic_folder.xml
Normal file
9
app/src/main/res/drawable/ic_folder.xml
Normal 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>
|
||||||
9
app/src/main/res/drawable/ic_image.xml
Normal file
9
app/src/main/res/drawable/ic_image.xml
Normal 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>
|
||||||
15
app/src/main/res/drawable/ic_launcher.xml
Normal file
15
app/src/main/res/drawable/ic_launcher.xml
Normal 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>
|
||||||
10
app/src/main/res/drawable/ic_sync.xml
Normal file
10
app/src/main/res/drawable/ic_sync.xml
Normal 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>
|
||||||
29
app/src/main/res/layout/activity_file_browser.xml
Normal file
29
app/src/main/res/layout/activity_file_browser.xml
Normal 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>
|
||||||
174
app/src/main/res/layout/activity_main.xml
Normal file
174
app/src/main/res/layout/activity_main.xml
Normal 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>
|
||||||
28
app/src/main/res/layout/activity_photo_view.xml
Normal file
28
app/src/main/res/layout/activity_photo_view.xml
Normal 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>
|
||||||
25
app/src/main/res/layout/item_tile.xml
Normal file
25
app/src/main/res/layout/item_tile.xml
Normal 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>
|
||||||
14
app/src/main/res/values/strings.xml
Normal file
14
app/src/main/res/values/strings.xml
Normal 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>
|
||||||
7
app/src/main/res/values/themes.xml
Normal file
7
app/src/main/res/values/themes.xml
Normal 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
5
build.gradle.kts
Normal 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
8
gradle.properties
Normal 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
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
251
gradlew
vendored
Executable 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
94
gradlew.bat
vendored
Normal 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
18
settings.gradle.kts
Normal 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")
|
||||||
Reference in New Issue
Block a user