Compare commits
87 Commits
c4608e9c0e
...
init
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec1d2f1660 | ||
|
|
b437cbf6e1 | ||
|
|
0042f3b6ba | ||
|
|
694dca8817 | ||
|
|
c34e1201ab | ||
|
|
dc32a3a3d7 | ||
|
|
a8906fa265 | ||
|
|
6912deac14 | ||
|
|
bbb3bb1aae | ||
|
|
3c2ec07bdb | ||
|
|
d128835c0d | ||
|
|
f21e8dbd3a | ||
|
|
7b28c87f2c | ||
|
|
42039bade1 | ||
|
|
4171ff04d2 | ||
|
|
bc3a89e858 | ||
|
|
de240be014 | ||
|
|
8681bddb60 | ||
|
|
8cf8a7a197 | ||
|
|
80eee705a7 | ||
|
|
0139d47d5f | ||
|
|
6863bad7a0 | ||
|
|
f90baa92d4 | ||
|
|
35984b0b85 | ||
|
|
1d239fb60a | ||
|
|
347f182c30 | ||
|
|
595d583f6e | ||
|
|
6876709051 | ||
|
|
e6d27d3ab3 | ||
|
|
716124bff5 | ||
|
|
558a7add89 | ||
|
|
30b600e2a4 | ||
|
|
6ed4362856 | ||
|
|
fbd228b768 | ||
|
|
309031aed7 | ||
|
|
eef4f61050 | ||
|
|
6e3f6aa20e | ||
|
|
b12cb4dd1e | ||
|
|
66faf4c7e5 | ||
|
|
f739114f7d | ||
|
|
fa2e95bb45 | ||
|
|
884dc7ec30 | ||
|
|
6f7dad3296 | ||
|
|
fecdef3acf | ||
|
|
d947018f42 | ||
|
|
7f11a4b7d6 | ||
|
|
1ae16f0ead | ||
|
|
50d6f66116 | ||
|
|
66378b4a02 | ||
|
|
617ce03cf9 | ||
|
|
c84d3426a7 | ||
|
|
a06c77f153 | ||
|
|
fa1a42000e | ||
|
|
361eab792b | ||
|
|
a6b489f133 | ||
|
|
60d72ce0bd | ||
|
|
6b624651fd | ||
|
|
f3e93c2bd0 | ||
|
|
69a5f451d5 | ||
|
|
2eb19a8a5d | ||
|
|
9390b24c7a | ||
|
|
3cbab36e82 | ||
|
|
60421c9f74 | ||
|
|
816d818625 | ||
|
|
3c59638759 | ||
|
|
0be43f2236 | ||
|
|
7aa7e4dae1 | ||
|
|
87241ba1d4 | ||
|
|
8306aa2729 | ||
|
|
9c489306f4 | ||
|
|
edaf4cb0d7 | ||
|
|
fd5753382f | ||
|
|
f6aede99f1 | ||
|
|
409cb3b4d1 | ||
|
|
0e6f0d9c7e | ||
|
|
43086a91f1 | ||
|
|
301291616d | ||
|
|
770aa58dd0 | ||
|
|
ec114aa7ce | ||
|
|
3da4e464d2 | ||
|
|
ff812a9550 | ||
|
|
af36ae5663 | ||
|
|
5b17d28f66 | ||
|
|
35b98e1857 | ||
|
|
05897e6d62 | ||
|
|
105077baba | ||
|
|
bbc764dbcb |
28
README.md
28
README.md
@@ -65,13 +65,35 @@ java -jar build/compose/jars/photophetch-macos-*.jar
|
|||||||
|
|
||||||
## iPhone notes
|
## iPhone notes
|
||||||
|
|
||||||
When you plug in an iPhone, macOS's built-in `PTPCamera` daemon may grab the device before gphoto2 can. If PhotoPhetch finds the device but shows no photos:
|
When you plug in an iPhone, macOS's built-in `PTPCamera` and `AMPDeviceDiscoveryAgent` daemons grab the device before gphoto2 or ADB can. Symptoms: "Could not connect to device" errors, Image Capture showing the phone under DEVICES with nothing listed, or `LOC:something` entries cycling in Image Capture.
|
||||||
|
|
||||||
|
Kill them before scanning:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
killall PTPCamera
|
killall PTPCamera
|
||||||
|
killall AMPDeviceDiscoveryAgent
|
||||||
```
|
```
|
||||||
|
|
||||||
Then rescan in the app.
|
Close Image Capture first if it's open. Then rescan in PhotoPhetch.
|
||||||
|
|
||||||
|
To stop them from re-grabbing the device on every USB connect (recommended if you use PhotoPhetch regularly):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
launchctl unload -w /System/Library/LaunchAgents/com.apple.ptpcamera.plist
|
||||||
|
```
|
||||||
|
|
||||||
|
To re-enable Image Capture later:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
launchctl load -w /System/Library/LaunchAgents/com.apple.ptpcamera.plist
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using iPhone and Android at the same time
|
||||||
|
|
||||||
|
If you have both phones plugged in, the Android phone must be in **Transferring files** mode (not Charging only). An Android phone in Charging mode can block macOS USB enumeration and cause gphoto2 to see 0 folders on the iPhone. Either:
|
||||||
|
|
||||||
|
- Switch the Android to **Transferring files** mode from its notification shade, or
|
||||||
|
- Unplug the Android while working with the iPhone
|
||||||
|
|
||||||
### HEIC settings
|
### HEIC settings
|
||||||
|
|
||||||
@@ -85,6 +107,8 @@ Enable USB debugging:
|
|||||||
|
|
||||||
> Settings → About Phone → tap Build Number 7 times → Developer Options → USB Debugging
|
> Settings → About Phone → tap Build Number 7 times → Developer Options → USB Debugging
|
||||||
|
|
||||||
|
When you plug in the USB cable, a prompt will appear on the phone asking for the connection mode. Choose **"Transferring files / Android Auto"** (also called "File Transfer" on some phones). Do not choose "Transferring images" (PTP) — it restricts file access and breaks ADB browsing.
|
||||||
|
|
||||||
Accept the "Trust this computer?" prompt on the phone when connecting.
|
Accept the "Trust this computer?" prompt on the phone when connecting.
|
||||||
|
|
||||||
## Project structure
|
## Project structure
|
||||||
|
|||||||
@@ -10,7 +10,16 @@ group = "com.bolenpad.photophetch"
|
|||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
jvmToolchain(17)
|
jvmToolchain(21)
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
|
||||||
|
kotlinOptions.jvmTarget = "17"
|
||||||
|
}
|
||||||
|
|
||||||
|
java {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
@@ -34,9 +43,14 @@ compose.desktop {
|
|||||||
packageVersion = "1.0.0"
|
packageVersion = "1.0.0"
|
||||||
description = "Import photos from phones into PhotoPhile"
|
description = "Import photos from phones into PhotoPhile"
|
||||||
vendor = "bolenpad"
|
vendor = "bolenpad"
|
||||||
|
copyright = "© 2026 Kyle Bolen"
|
||||||
macOS {
|
macOS {
|
||||||
bundleID = "com.bolenpad.photophetch"
|
bundleID = "com.bolenpad.photophetch"
|
||||||
iconFile.set(project.file("src/main/resources/icon.icns"))
|
// Uncomment and add icon.icns to src/main/resources/ for a custom icon:
|
||||||
|
// iconFile.set(project.file("src/main/resources/icon.icns"))
|
||||||
|
signing {
|
||||||
|
sign.set(false) // set true + add identity for App Store / Gatekeeper
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
1
gradle.properties
Normal file
1
gradle.properties
Normal file
@@ -0,0 +1 @@
|
|||||||
|
compose.desktop.packaging.checkJdkVendor=false
|
||||||
68
prepare-release.sh
Executable file
68
prepare-release.sh
Executable file
@@ -0,0 +1,68 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# prepare-release.sh — prepare a release commit and tag (run on dev machine)
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./prepare-release.sh 1.0.0 "Release notes here..."
|
||||||
|
#
|
||||||
|
# This script:
|
||||||
|
# 1. Writes release notes to releases/v<version>.md
|
||||||
|
# 2. Updates packageVersion in build.gradle.kts
|
||||||
|
# 3. Commits both
|
||||||
|
# 4. Creates and pushes the annotated tag
|
||||||
|
#
|
||||||
|
# After this, the Mac operator runs ./release.sh to build and upload.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ $# -lt 2 ]; then
|
||||||
|
echo "Usage: $0 <version> <release-notes>"
|
||||||
|
echo " Example: $0 1.0.0 'First release with Android and iPhone support'"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
VERSION="$1"
|
||||||
|
TAG="v${VERSION}"
|
||||||
|
NOTES="$2"
|
||||||
|
|
||||||
|
# ── Pre-flight ────────────────────────────────────────────────────────────────
|
||||||
|
if ! git diff --quiet || ! git diff --cached --quiet; then
|
||||||
|
echo "ERROR: Uncommitted changes. Commit or stash first."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if git tag | grep -q "^${TAG}$"; then
|
||||||
|
echo "ERROR: Tag $TAG already exists."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Write release notes ───────────────────────────────────────────────────────
|
||||||
|
mkdir -p releases
|
||||||
|
NOTES_FILE="releases/${TAG}.md"
|
||||||
|
cat > "$NOTES_FILE" << EOF
|
||||||
|
$NOTES
|
||||||
|
EOF
|
||||||
|
echo "→ Wrote $NOTES_FILE"
|
||||||
|
|
||||||
|
# ── Update version ────────────────────────────────────────────────────────────
|
||||||
|
sed -i "s/packageVersion = \"[^\"]*\"/packageVersion = \"${VERSION}\"/" build.gradle.kts
|
||||||
|
echo "→ Updated packageVersion to $VERSION"
|
||||||
|
|
||||||
|
# ── Commit ────────────────────────────────────────────────────────────────────
|
||||||
|
git add "$NOTES_FILE" build.gradle.kts
|
||||||
|
git commit -m "Release $TAG"
|
||||||
|
echo "→ Committed release files"
|
||||||
|
|
||||||
|
# ── Tag ───────────────────────────────────────────────────────────────────────
|
||||||
|
git tag -a "$TAG" -m "$NOTES"
|
||||||
|
echo "→ Created tag $TAG"
|
||||||
|
|
||||||
|
# ── Push ──────────────────────────────────────────────────────────────────────
|
||||||
|
echo "→ Pushing to origin..."
|
||||||
|
GIT_ASKPASS='' GIT_TERMINAL_PROMPT=0 git push origin HEAD:refs/heads/main 2>&1 || \
|
||||||
|
echo " (push to main may require running from Mac — tag is local)"
|
||||||
|
GIT_ASKPASS='' GIT_TERMINAL_PROMPT=0 git push origin "$TAG" 2>&1 || \
|
||||||
|
echo " (tag push may require running from Mac)"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✓ Release $TAG prepared."
|
||||||
|
echo " On your Mac: git fetch --tags && git checkout $TAG && ./release.sh"
|
||||||
119
release.sh
Executable file
119
release.sh
Executable file
@@ -0,0 +1,119 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# release.sh — build and publish a PhotoPhetch release to Gitea
|
||||||
|
#
|
||||||
|
# Usage (on your Mac after pulling tagged commit):
|
||||||
|
# ./release.sh
|
||||||
|
#
|
||||||
|
# The release version and notes are read from the current git tag and
|
||||||
|
# releases/<tag>.md — both committed by the dev machine before tagging.
|
||||||
|
#
|
||||||
|
# Requires GITEA_TOKEN environment variable.
|
||||||
|
# Get a token: https://git.bolenpad.com/user/settings/applications
|
||||||
|
# Add to ~/.zshrc: export GITEA_TOKEN=your_token_here
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ── Config ───────────────────────────────────────────────────────────────────
|
||||||
|
GITEA_URL="https://git.bolenpad.com"
|
||||||
|
GITEA_USER="kbolen"
|
||||||
|
GITEA_REPO="photophetch"
|
||||||
|
|
||||||
|
# ── Resolve version from current git tag ─────────────────────────────────────
|
||||||
|
TAG=$(git describe --exact-match --tags HEAD 2>/dev/null || echo "")
|
||||||
|
if [ -z "$TAG" ]; then
|
||||||
|
echo "ERROR: HEAD is not on a tag. Pull the latest and checkout the release tag."
|
||||||
|
echo " git fetch --tags && git checkout v1.0.0"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
VERSION="${TAG#v}" # strip leading 'v'
|
||||||
|
echo "→ Building release $TAG (version $VERSION)"
|
||||||
|
|
||||||
|
# ── Read release notes from releases/<tag>.md ────────────────────────────────
|
||||||
|
NOTES_FILE="releases/${TAG}.md"
|
||||||
|
if [ ! -f "$NOTES_FILE" ]; then
|
||||||
|
echo "ERROR: Release notes file not found: $NOTES_FILE"
|
||||||
|
echo " The dev machine should have committed this before tagging."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
NOTES=$(cat "$NOTES_FILE")
|
||||||
|
echo "→ Release notes: $NOTES_FILE"
|
||||||
|
|
||||||
|
# ── Gitea token ───────────────────────────────────────────────────────────────
|
||||||
|
if [ -z "${GITEA_TOKEN:-}" ]; then
|
||||||
|
read -rsp "GITEA_TOKEN not set. Enter token: " GITEA_TOKEN
|
||||||
|
echo
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Check packageVersion matches tag ─────────────────────────────────────────
|
||||||
|
GRADLE_VERSION=$(grep 'packageVersion' build.gradle.kts | sed 's/.*"\(.*\)".*/\1/')
|
||||||
|
if [ "$GRADLE_VERSION" != "$VERSION" ]; then
|
||||||
|
echo "WARNING: build.gradle.kts packageVersion ($GRADLE_VERSION) != tag version ($VERSION)"
|
||||||
|
echo "Continue anyway? [y/N]"
|
||||||
|
read -r CONFIRM
|
||||||
|
[[ "$CONFIRM" == "y" || "$CONFIRM" == "Y" ]] || exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Build DMG ─────────────────────────────────────────────────────────────────
|
||||||
|
echo "→ Building DMG (this takes a few minutes)..."
|
||||||
|
./gradlew packageDmg
|
||||||
|
|
||||||
|
DMG_PATH=$(find build/compose/binaries/main/dmg -name "*.dmg" | head -1)
|
||||||
|
if [ -z "$DMG_PATH" ]; then
|
||||||
|
echo "ERROR: No DMG found in build/compose/binaries/main/dmg/"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
DMG_NAME=$(basename "$DMG_PATH")
|
||||||
|
echo "→ Built: $DMG_PATH"
|
||||||
|
|
||||||
|
# ── Check release doesn't already exist ──────────────────────────────────────
|
||||||
|
EXISTING=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${GITEA_USER}/${GITEA_REPO}/releases/tags/${TAG}" \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}")
|
||||||
|
if [ "$EXISTING" = "200" ]; then
|
||||||
|
echo "ERROR: Release $TAG already exists on Gitea."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Create Gitea release ──────────────────────────────────────────────────────
|
||||||
|
echo "→ Creating Gitea release $TAG..."
|
||||||
|
# Escape notes for JSON
|
||||||
|
NOTES_JSON=$(echo "$NOTES" | python3 -c "import sys,json; print(json.dumps(sys.stdin.read()))")
|
||||||
|
|
||||||
|
RELEASE_RESPONSE=$(curl -s -X POST \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${GITEA_USER}/${GITEA_REPO}/releases" \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{
|
||||||
|
\"tag_name\": \"${TAG}\",
|
||||||
|
\"name\": \"PhotoPhetch ${TAG}\",
|
||||||
|
\"body\": ${NOTES_JSON},
|
||||||
|
\"draft\": false,
|
||||||
|
\"prerelease\": false
|
||||||
|
}")
|
||||||
|
|
||||||
|
RELEASE_ID=$(echo "$RELEASE_RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])" 2>/dev/null)
|
||||||
|
if [ -z "$RELEASE_ID" ]; then
|
||||||
|
echo "ERROR: Failed to create release. Response:"
|
||||||
|
echo "$RELEASE_RESPONSE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "→ Created release ID $RELEASE_ID"
|
||||||
|
|
||||||
|
# ── Upload DMG ────────────────────────────────────────────────────────────────
|
||||||
|
echo "→ Uploading $DMG_NAME..."
|
||||||
|
UPLOAD_RESPONSE=$(curl -s -X POST \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${GITEA_USER}/${GITEA_REPO}/releases/${RELEASE_ID}/assets?name=${DMG_NAME}" \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
--data-binary "@${DMG_PATH}")
|
||||||
|
|
||||||
|
DOWNLOAD_URL=$(echo "$UPLOAD_RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('browser_download_url',''))" 2>/dev/null)
|
||||||
|
if [ -z "$DOWNLOAD_URL" ]; then
|
||||||
|
echo "WARNING: Upload response unexpected:"
|
||||||
|
echo "$UPLOAD_RESPONSE"
|
||||||
|
else
|
||||||
|
echo ""
|
||||||
|
echo "✓ Released: $DOWNLOAD_URL"
|
||||||
|
echo " Release page: ${GITEA_URL}/${GITEA_USER}/${GITEA_REPO}/releases/tag/${TAG}"
|
||||||
|
fi
|
||||||
0
releases/.gitkeep
Normal file
0
releases/.gitkeep
Normal file
@@ -24,17 +24,32 @@ class AndroidConnector(
|
|||||||
|
|
||||||
private val log = LoggerFactory.getLogger(AndroidConnector::class.java)
|
private val log = LoggerFactory.getLogger(AndroidConnector::class.java)
|
||||||
|
|
||||||
// Directories on Android that contain camera-roll originals
|
// Directories on Android that contain camera-roll originals.
|
||||||
private val cameraDirs = listOf(
|
// Used as fallback if dynamic DCIM discovery finds nothing.
|
||||||
"/sdcard/DCIM/Camera",
|
private val fallbackCameraDirs = listOf(
|
||||||
"/sdcard/DCIM/100ANDRO",
|
"/storage/emulated/0/DCIM/Camera",
|
||||||
"/sdcard/Pictures/Camera",
|
"/storage/emulated/0/Pictures/Camera",
|
||||||
)
|
)
|
||||||
|
|
||||||
// Photo/video extensions to include
|
// Photo/video extensions to include — covers all common camera and download formats
|
||||||
private val mediaExtensions = setOf(
|
private val mediaExtensions = setOf(
|
||||||
"jpg", "jpeg", "png", "dng", "raw", // photos
|
// Photos
|
||||||
"mp4", "mov", "3gp", "avi", // videos
|
"jpg", "jpeg",
|
||||||
|
"heic", "heif",
|
||||||
|
"avif",
|
||||||
|
"webp",
|
||||||
|
"png",
|
||||||
|
"dng", "raw", "arw", "cr2", "cr3", "nef", "orf", "rw2",
|
||||||
|
"tiff", "tif",
|
||||||
|
"bmp",
|
||||||
|
// Videos
|
||||||
|
"mp4", "m4v",
|
||||||
|
"mov",
|
||||||
|
"mpeg", "mpg", "mpe",
|
||||||
|
"3gp", "3g2",
|
||||||
|
"avi",
|
||||||
|
"mkv",
|
||||||
|
"webm",
|
||||||
)
|
)
|
||||||
|
|
||||||
override suspend fun detectDevices(): List<DeviceInfo> {
|
override suspend fun detectDevices(): List<DeviceInfo> {
|
||||||
@@ -49,12 +64,17 @@ class AndroidConnector(
|
|||||||
if (parts.size < 2 || parts[1] != "device") return@mapNotNull null
|
if (parts.size < 2 || parts[1] != "device") return@mapNotNull null
|
||||||
val serial = parts[0]
|
val serial = parts[0]
|
||||||
val model = parts.firstOrNull { it.startsWith("model:") }
|
val model = parts.firstOrNull { it.startsWith("model:") }
|
||||||
?.removePrefix("model:") ?: "Android Device"
|
?.removePrefix("model:")
|
||||||
|
?.replace("_", " ")
|
||||||
|
?: "Android Device"
|
||||||
val product = parts.firstOrNull { it.startsWith("product:") }
|
val product = parts.firstOrNull { it.startsWith("product:") }
|
||||||
?.removePrefix("product:")
|
?.removePrefix("product:")
|
||||||
|
?.replace("_", " ")
|
||||||
|
// Prefer model name — it's human-readable (e.g. "SM G991B")
|
||||||
|
// product is often a cryptic codename (e.g. "o1quew")
|
||||||
|
val displayName = model.ifBlank { product ?: "Android Device" }
|
||||||
DeviceInfo(
|
DeviceInfo(
|
||||||
displayName = product?.replace("_", " ")?.replaceFirstChar { it.uppercase() }
|
displayName = displayName.trim(),
|
||||||
?: model.replace("_", " "),
|
|
||||||
type = DeviceType.ANDROID,
|
type = DeviceType.ANDROID,
|
||||||
adbSerial = serial,
|
adbSerial = serial,
|
||||||
)
|
)
|
||||||
@@ -65,45 +85,63 @@ class AndroidConnector(
|
|||||||
val serial = requireSerial(device)
|
val serial = requireSerial(device)
|
||||||
val photos = mutableListOf<PhonePhoto>()
|
val photos = mutableListOf<PhonePhoto>()
|
||||||
|
|
||||||
for (dir in cameraDirs) {
|
// Scan all subdirectories under DCIM and Pictures, plus Downloads root.
|
||||||
// Check if directory exists first
|
// /sdcard is a symlink to /storage/emulated/0 — scan only the canonical path.
|
||||||
|
val scanRoots = listOf(
|
||||||
|
"/storage/emulated/0/DCIM",
|
||||||
|
"/storage/emulated/0/Pictures",
|
||||||
|
)
|
||||||
|
val cameraDirs = mutableListOf<String>()
|
||||||
|
|
||||||
|
for (root in scanRoots) {
|
||||||
|
val lsOutput = runAdb(serial, "shell", "ls", root, "2>/dev/null")
|
||||||
|
if (lsOutput.contains("No such file") || lsOutput.isBlank()) continue
|
||||||
|
lsOutput.lines()
|
||||||
|
.map { it.trim() }
|
||||||
|
.filter { it.isNotBlank() && !it.contains("No such file") }
|
||||||
|
.forEach { subdir -> cameraDirs.add("$root/$subdir") }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Downloads is a flat directory (no subdirs worth scanning), add it directly
|
||||||
|
cameraDirs.add("/storage/emulated/0/Download")
|
||||||
|
|
||||||
|
// Fall back to hardcoded list if dynamic discovery found nothing
|
||||||
|
val dirsToScan = cameraDirs.ifEmpty { fallbackCameraDirs }
|
||||||
|
|
||||||
|
for (dir in dirsToScan) {
|
||||||
val check = runAdb(serial, "shell", "ls", dir, "2>/dev/null")
|
val check = runAdb(serial, "shell", "ls", dir, "2>/dev/null")
|
||||||
if (check.contains("No such file") || check.isBlank()) continue
|
if (check.contains("No such file") || check.isBlank()) continue
|
||||||
|
|
||||||
// List files with metadata: size, date, time, name
|
// Derive the human-readable folder name from the path
|
||||||
// `stat` format: size=%s mtime=%Y path=%n
|
val folderName = dir.substringAfterLast("/")
|
||||||
val statOutput = runAdb(
|
|
||||||
serial, "shell",
|
|
||||||
"find", dir, "-maxdepth", "1", "-type", "f",
|
|
||||||
"-exec", "stat", "-c", "%s %Y %n", "{}", ";"
|
|
||||||
)
|
|
||||||
|
|
||||||
for (line in statOutput.lines()) {
|
val lsOutput = runAdb(serial, "shell", "ls", "-la", dir)
|
||||||
|
|
||||||
|
for (line in lsOutput.lines()) {
|
||||||
val trimmed = line.trim()
|
val trimmed = line.trim()
|
||||||
if (trimmed.isBlank()) continue
|
if (trimmed.isBlank() || trimmed.startsWith("total") || trimmed.startsWith("d")) continue
|
||||||
val parts = trimmed.split(" ", limit = 3)
|
|
||||||
if (parts.size < 3) continue
|
|
||||||
|
|
||||||
val sizeBytes = parts[0].toLongOrNull() ?: continue
|
val parts = trimmed.split(Regex("\\s+"), limit = 8)
|
||||||
val epochSecs = parts[1].toLongOrNull() ?: continue
|
if (parts.size < 8) continue
|
||||||
val path = parts[2]
|
|
||||||
val filename = path.substringAfterLast("/")
|
val filename = parts[7].trim()
|
||||||
val ext = filename.substringAfterLast(".", "").lowercase()
|
val ext = filename.substringAfterLast(".", "").lowercase()
|
||||||
|
|
||||||
if (ext !in mediaExtensions) continue
|
if (ext !in mediaExtensions) continue
|
||||||
|
|
||||||
|
val sizeBytes = parts[4].toLongOrNull() ?: 0L
|
||||||
|
val dateTaken = parseDate(parts[5], parts[6])
|
||||||
|
|
||||||
photos.add(
|
photos.add(
|
||||||
PhonePhoto(
|
PhonePhoto(
|
||||||
devicePath = path,
|
devicePath = "$dir/$filename",
|
||||||
filename = filename,
|
filename = filename,
|
||||||
sizeBytes = sizeBytes,
|
sizeBytes = sizeBytes,
|
||||||
dateTaken = Instant.ofEpochSecond(epochSecs),
|
dateTaken = dateTaken,
|
||||||
// Android stat doesn't give EXIF; we infer Make/Model from
|
|
||||||
// the device itself rather than per-file EXIF
|
|
||||||
exifMake = device.displayName,
|
exifMake = device.displayName,
|
||||||
exifModel = device.displayName,
|
exifModel = device.displayName,
|
||||||
mediaType = if (ext in setOf("mp4", "mov", "3gp", "avi"))
|
mediaType = if (ext in setOf("mp4", "m4v", "mov", "mpeg", "mpg", "mpe", "3gp", "3g2", "avi", "mkv", "webm"))
|
||||||
MediaType.VIDEO else MediaType.PHOTO,
|
MediaType.VIDEO else MediaType.PHOTO,
|
||||||
|
sourceFolder = folderName,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -123,6 +161,14 @@ class AndroidConnector(
|
|||||||
return destFile
|
return destFile
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun pullToFile(device: DeviceInfo, devicePath: String, destinationPath: Path): Boolean {
|
||||||
|
val serial = requireSerial(device)
|
||||||
|
val output = runAdb(serial, "pull", devicePath, destinationPath.absolutePathString())
|
||||||
|
return output.contains("pulled") || output.contains("1 file")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun cacheKey(device: DeviceInfo): String = device.adbSerial ?: device.displayName
|
||||||
|
|
||||||
override suspend fun deletePhoto(device: DeviceInfo, photo: PhonePhoto) {
|
override suspend fun deletePhoto(device: DeviceInfo, photo: PhonePhoto) {
|
||||||
val serial = requireSerial(device)
|
val serial = requireSerial(device)
|
||||||
val output = runAdb(serial, "shell", "rm", photo.devicePath)
|
val output = runAdb(serial, "shell", "rm", photo.devicePath)
|
||||||
@@ -132,23 +178,12 @@ class AndroidConnector(
|
|||||||
log.info("Deleted ${photo.filename} from device")
|
log.info("Deleted ${photo.filename} from device")
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun remoteChecksum(device: DeviceInfo, photo: PhonePhoto): String? {
|
/**
|
||||||
return try {
|
* Remote checksum disabled — Samsung's toybox sha256sum output format is
|
||||||
val serial = requireSerial(device)
|
* inconsistent across Android versions and causes false hash mismatches.
|
||||||
// Android has md5sum or sha256sum depending on version; try sha256sum first
|
* Verification uses file size comparison + local SHA-256 instead.
|
||||||
var output = runAdb(serial, "shell", "sha256sum", photo.devicePath)
|
*/
|
||||||
if (output.contains("not found") || output.isBlank()) {
|
override suspend fun remoteChecksum(device: DeviceInfo, photo: PhonePhoto): String? = null
|
||||||
runAdb(serial, "shell", "md5sum", photo.devicePath)
|
|
||||||
// md5sum isn't SHA-256 — return null so Copier uses its own verification
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
// sha256sum output: "<hash> <path>"
|
|
||||||
output.trim().split(Regex("\\s+")).firstOrNull()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
log.warn("Remote checksum failed for ${photo.filename}: ${e.message}")
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
@@ -157,6 +192,25 @@ class AndroidConnector(
|
|||||||
private fun requireSerial(device: DeviceInfo): String =
|
private fun requireSerial(device: DeviceInfo): String =
|
||||||
device.adbSerial ?: throw DeviceException("No ADB serial for device: ${device.displayName}")
|
device.adbSerial ?: throw DeviceException("No ADB serial for device: ${device.displayName}")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse date + time from ls -la tokens.
|
||||||
|
* Common formats: "2026-07-15" "14:22" or "2026-07-15" "14:22:01"
|
||||||
|
*/
|
||||||
|
private fun parseDate(datePart: String, timePart: String): java.time.Instant {
|
||||||
|
return try {
|
||||||
|
val timeClean = timePart.substringBefore(".") // strip sub-seconds if present
|
||||||
|
val formatter = if (timeClean.length == 5) {
|
||||||
|
java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
|
||||||
|
} else {
|
||||||
|
java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
|
||||||
|
}
|
||||||
|
java.time.LocalDateTime.parse("$datePart $timeClean", formatter)
|
||||||
|
.atZone(java.time.ZoneId.systemDefault()).toInstant()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
java.time.Instant.EPOCH
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun runAdb(serial: String?, vararg args: String): String {
|
private fun runAdb(serial: String?, vararg args: String): String {
|
||||||
val cmd = buildList {
|
val cmd = buildList {
|
||||||
add(adbBin)
|
add(adbBin)
|
||||||
|
|||||||
@@ -26,10 +26,22 @@ interface DeviceConnector {
|
|||||||
/**
|
/**
|
||||||
* Pull a single photo from the device to [destinationDir].
|
* Pull a single photo from the device to [destinationDir].
|
||||||
* Returns the local path of the pulled file.
|
* Returns the local path of the pulled file.
|
||||||
* @throws DeviceException on adb/gphoto2 failure
|
* @throws DeviceException on adb/AFC failure
|
||||||
*/
|
*/
|
||||||
suspend fun pullPhoto(device: DeviceInfo, photo: PhonePhoto, destinationDir: Path): Path
|
suspend fun pullPhoto(device: DeviceInfo, photo: PhonePhoto, destinationDir: Path): Path
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pull a file from the device to an exact [destinationPath].
|
||||||
|
* Used for thumbnail fetching. Returns true on success.
|
||||||
|
*/
|
||||||
|
suspend fun pullToFile(device: DeviceInfo, devicePath: String, destinationPath: Path): Boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A stable cache key for this device (e.g. ADB serial or UDID).
|
||||||
|
* Used by ThumbnailCache to namespace cached thumbnails.
|
||||||
|
*/
|
||||||
|
fun cacheKey(device: DeviceInfo): String
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete a photo from the device.
|
* Delete a photo from the device.
|
||||||
* Only called after the user explicitly confirms deletion.
|
* Only called after the user explicitly confirms deletion.
|
||||||
|
|||||||
@@ -4,6 +4,16 @@ import com.bolenpad.photophetch.model.DeviceInfo
|
|||||||
import com.bolenpad.photophetch.model.DeviceType
|
import com.bolenpad.photophetch.model.DeviceType
|
||||||
import com.bolenpad.photophetch.model.MediaType
|
import com.bolenpad.photophetch.model.MediaType
|
||||||
import com.bolenpad.photophetch.model.PhonePhoto
|
import com.bolenpad.photophetch.model.PhonePhoto
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.contentOrNull
|
||||||
|
import kotlinx.serialization.json.intOrNull
|
||||||
|
import kotlinx.serialization.json.jsonArray
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import kotlinx.serialization.json.longOrNull
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
@@ -13,206 +23,203 @@ import java.time.format.DateTimeFormatter
|
|||||||
import kotlin.io.path.absolutePathString
|
import kotlin.io.path.absolutePathString
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DeviceConnector implementation for iPhones via gphoto2.
|
* DeviceConnector implementation for iPhones via pymobiledevice3.
|
||||||
*
|
*
|
||||||
* Requires `gphoto2` on PATH (or override via [gphoto2Bin]).
|
* Requires pymobiledevice3 installed via pipx:
|
||||||
* Install via: brew install gphoto2
|
* brew install pipx && pipx install pymobiledevice3
|
||||||
*
|
*
|
||||||
* Important macOS note: The built-in Image Capture daemon (PTPCamera) will
|
* The helper script is bundled in the JAR and extracted to a temp file.
|
||||||
* conflict with gphoto2. If listing returns no files, run:
|
* It must be run with the pipx virtualenv Python (not system python3)
|
||||||
* killall PTPCamera
|
* since pymobiledevice3 is installed in the pipx venv.
|
||||||
* PhotoPhetch shows instructions for this when no photos are found on a
|
|
||||||
* connected iPhone.
|
|
||||||
*
|
*
|
||||||
* Live Photo handling: gphoto2 lists both the .HEIC and the companion .MOV
|
* Auto-discovers the pipx Python at:
|
||||||
* with matching base names. We detect the pair by comparing filenames and
|
* ~/Library/Application Support/pipx/venvs/pymobiledevice3/bin/python3
|
||||||
* flag [PhonePhoto.isLivePhotoStill] / [PhonePhoto.isLivePhotoMotion].
|
|
||||||
* The MOV companion is excluded from import by default.
|
|
||||||
*/
|
*/
|
||||||
class IosConnector(
|
class IosConnector(
|
||||||
private val gphoto2Bin: String = "gphoto2",
|
pythonBinOverride: String? = null,
|
||||||
) : DeviceConnector {
|
) : DeviceConnector {
|
||||||
|
|
||||||
private val log = LoggerFactory.getLogger(IosConnector::class.java)
|
private val log = LoggerFactory.getLogger(IosConnector::class.java)
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
// gphoto2 date format in --list-files output
|
internal val pythonBin: String = pythonBinOverride ?: discoverPython()
|
||||||
private val gphotoDateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
|
|
||||||
|
companion object {
|
||||||
|
private val PIPX_PYTHON_CANDIDATES = listOf(
|
||||||
|
// macOS pipx default (Intel and Apple Silicon)
|
||||||
|
"${System.getProperty("user.home")}/Library/Application Support/pipx/venvs/pymobiledevice3/bin/python3",
|
||||||
|
"${System.getProperty("user.home")}/.local/pipx/venvs/pymobiledevice3/bin/python3",
|
||||||
|
"/usr/local/pipx/venvs/pymobiledevice3/bin/python3",
|
||||||
|
)
|
||||||
|
|
||||||
|
fun discoverPython(): String {
|
||||||
|
for (candidate in PIPX_PYTHON_CANDIDATES) {
|
||||||
|
if (java.io.File(candidate).exists()) return candidate
|
||||||
|
}
|
||||||
|
// Fallback to system python3 — will fail with "module not found" but gives a clear error
|
||||||
|
return "python3"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isPythonAvailable(): Boolean {
|
||||||
|
val python = discoverPython()
|
||||||
|
return java.io.File(python).exists() || python == "python3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Path to the bundled helper script, extracted to a temp location on first use */
|
||||||
|
internal val helperScript: String by lazy { extractHelperScript() }
|
||||||
|
|
||||||
override suspend fun detectDevices(): List<DeviceInfo> {
|
override suspend fun detectDevices(): List<DeviceInfo> {
|
||||||
// gphoto2 --auto-detect lists connected cameras/phones
|
return try {
|
||||||
val output = runGphoto2("--auto-detect")
|
val output = runHelper("list-devices")
|
||||||
/*
|
if (output.isBlank() || output.startsWith("Error")) return emptyList()
|
||||||
* Output format:
|
|
||||||
* Model Port
|
|
||||||
* ----------------------------------------------------------
|
|
||||||
* Apple iPhone usb:001,023
|
|
||||||
*/
|
|
||||||
val devices = mutableListOf<DeviceInfo>()
|
|
||||||
var parsingStarted = false
|
|
||||||
for (line in output.lines()) {
|
|
||||||
if (line.startsWith("---")) { parsingStarted = true; continue }
|
|
||||||
if (!parsingStarted || line.isBlank()) continue
|
|
||||||
|
|
||||||
// Split on 2+ spaces to separate model from port
|
val array = json.parseToJsonElement(output).jsonArray
|
||||||
val parts = line.trim().split(Regex("\\s{2,}"), limit = 2)
|
array.map { element ->
|
||||||
if (parts.size < 2) continue
|
val obj = element.jsonObject
|
||||||
val model = parts[0].trim()
|
DeviceInfo(
|
||||||
val port = parts[1].trim()
|
displayName = obj["name"]?.jsonPrimitive?.contentOrNull ?: "iPhone",
|
||||||
|
type = DeviceType.IOS,
|
||||||
if (model.contains("iPhone", ignoreCase = true) ||
|
gphotoPort = obj["udid"]?.jsonPrimitive?.contentOrNull,
|
||||||
model.contains("iPad", ignoreCase = true) ||
|
|
||||||
model.contains("Apple", ignoreCase = true)
|
|
||||||
) {
|
|
||||||
devices.add(
|
|
||||||
DeviceInfo(
|
|
||||||
displayName = model,
|
|
||||||
type = DeviceType.IOS,
|
|
||||||
gphotoPort = port,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log.warn("iOS device detection failed: ${e.message}")
|
||||||
|
emptyList()
|
||||||
}
|
}
|
||||||
return devices
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun listPhotos(device: DeviceInfo): List<PhonePhoto> {
|
override suspend fun listPhotos(device: DeviceInfo): List<PhonePhoto> {
|
||||||
val port = requirePort(device)
|
return try {
|
||||||
|
val output = runHelper("list-photos")
|
||||||
|
if (output.isBlank() || output.startsWith("Error")) return emptyList()
|
||||||
|
|
||||||
// gphoto2 --list-files gives index, name, size, date for each file in DCIM
|
val array = json.parseToJsonElement(output).jsonArray
|
||||||
val output = runGphoto2("--port", port, "--list-files")
|
val photos = array.mapNotNull { element ->
|
||||||
|
try {
|
||||||
|
val obj = element.jsonObject
|
||||||
|
val devicePath = obj["devicePath"]?.jsonPrimitive?.contentOrNull ?: return@mapNotNull null
|
||||||
|
val filename = obj["filename"]?.jsonPrimitive?.contentOrNull ?: return@mapNotNull null
|
||||||
|
val sizeBytes = obj["sizeBytes"]?.jsonPrimitive?.longOrNull ?: 0L
|
||||||
|
val dateIso = obj["dateIso"]?.jsonPrimitive?.contentOrNull
|
||||||
|
val sourceFolder = obj["sourceFolder"]?.jsonPrimitive?.contentOrNull ?: "DCIM"
|
||||||
|
val mediaTypeStr = obj["mediaType"]?.jsonPrimitive?.contentOrNull ?: "PHOTO"
|
||||||
|
|
||||||
/*
|
val dateTaken = if (dateIso != null) {
|
||||||
* Output format (each file):
|
try { Instant.parse(dateIso + "Z") } catch (_: Exception) {
|
||||||
* #1 IMG_0001.HEIC 5906 KB 2026-07-15 14:22:01
|
try { LocalDateTime.parse(dateIso).atZone(ZoneId.systemDefault()).toInstant() }
|
||||||
* #2 IMG_0001.MOV 12344 KB 2026-07-15 14:22:01
|
catch (_: Exception) { Instant.EPOCH }
|
||||||
* #3 IMG_0002.JPG 3021 KB 2026-07-15 14:23:45
|
}
|
||||||
*/
|
} else Instant.EPOCH
|
||||||
val rawPhotos = mutableListOf<PhonePhoto>()
|
|
||||||
for (line in output.lines()) {
|
PhonePhoto(
|
||||||
if (!line.trimStart().startsWith("#")) continue
|
devicePath = devicePath,
|
||||||
val photo = parseGphoto2FileLine(line) ?: continue
|
filename = filename,
|
||||||
rawPhotos.add(photo)
|
sizeBytes = sizeBytes,
|
||||||
|
dateTaken = dateTaken,
|
||||||
|
exifMake = "Apple",
|
||||||
|
exifModel = device.displayName,
|
||||||
|
mediaType = if (mediaTypeStr == "VIDEO") MediaType.VIDEO else MediaType.PHOTO,
|
||||||
|
sourceFolder = sourceFolder,
|
||||||
|
isLivePhotoMotion = obj["isLivePhotoMotion"]?.jsonPrimitive?.contentOrNull == "true",
|
||||||
|
isLivePhotoStill = obj["companionDevicePath"]?.jsonPrimitive?.contentOrNull != null,
|
||||||
|
companionDevicePath = obj["companionDevicePath"]?.jsonPrimitive?.contentOrNull,
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log.debug("Failed to parse photo entry: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tag Live Photo pairs (HEIC + MOV with same base name)
|
||||||
|
tagLivePhotoPairs(photos).sortedByDescending { it.dateTaken }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log.error("iOS photo listing failed: ${e.message}")
|
||||||
|
emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect Live Photo pairs: HEIC + MOV with the same base name and close timestamps
|
|
||||||
return tagLivePhotoPairs(rawPhotos)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun pullPhoto(device: DeviceInfo, photo: PhonePhoto, destinationDir: Path): Path {
|
override suspend fun pullPhoto(device: DeviceInfo, photo: PhonePhoto, destinationDir: Path): Path {
|
||||||
val port = requirePort(device)
|
|
||||||
// gphoto2 uses 1-based file index; devicePath stores the index as "#N"
|
|
||||||
val fileIndex = photo.devicePath.removePrefix("#")
|
|
||||||
val destFile = destinationDir.resolve(photo.filename)
|
val destFile = destinationDir.resolve(photo.filename)
|
||||||
|
val output = runHelper("pull", photo.devicePath, destFile.absolutePathString())
|
||||||
// --get-file downloads to current dir; we set working dir to destinationDir
|
if (output.contains("Error") || !destFile.toFile().exists()) {
|
||||||
val output = runGphoto2InDir(
|
throw DeviceException("AFC pull failed for ${photo.filename}: $output")
|
||||||
destinationDir,
|
|
||||||
"--port", port,
|
|
||||||
"--get-file", fileIndex,
|
|
||||||
"--filename", photo.filename,
|
|
||||||
"--force-overwrite",
|
|
||||||
)
|
|
||||||
|
|
||||||
if (output.contains("Error") && !output.contains("Saving file as")) {
|
|
||||||
throw DeviceException("gphoto2 pull failed for ${photo.filename}: $output")
|
|
||||||
}
|
}
|
||||||
log.info("Pulled ${photo.filename} → ${destFile.absolutePathString()}")
|
log.info("Pulled ${photo.filename} → ${destFile.absolutePathString()}")
|
||||||
return destFile
|
return destFile
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun pullToFile(device: DeviceInfo, devicePath: String, destinationPath: Path): Boolean {
|
||||||
|
// Use pull-thumb (partial 512KB read) for speed — sips can extract EXIF thumbnail from partial file
|
||||||
|
val output = runHelper("pull-thumb", devicePath, destinationPath.absolutePathString())
|
||||||
|
return !output.contains("Error") && destinationPath.toFile().exists()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun cacheKey(device: DeviceInfo): String = device.gphotoPort ?: device.displayName
|
||||||
|
|
||||||
override suspend fun deletePhoto(device: DeviceInfo, photo: PhonePhoto) {
|
override suspend fun deletePhoto(device: DeviceInfo, photo: PhonePhoto) {
|
||||||
val port = requirePort(device)
|
val output = runHelper("delete", photo.devicePath)
|
||||||
val fileIndex = photo.devicePath.removePrefix("#")
|
|
||||||
val output = runGphoto2("--port", port, "--delete-file", fileIndex)
|
|
||||||
if (output.contains("Error")) {
|
if (output.contains("Error")) {
|
||||||
throw DeviceException("Failed to delete ${photo.filename}: $output")
|
throw DeviceException("AFC delete failed for ${photo.filename}: $output")
|
||||||
}
|
}
|
||||||
log.info("Deleted ${photo.filename} from device")
|
log.info("Deleted ${photo.filename} from device")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** AFC doesn't provide remote checksums — verified by size + local SHA-256 in Copier */
|
||||||
* gphoto2 doesn't provide a remote hash command, so we return null.
|
|
||||||
* The Copier will verify by comparing the local copy against the pulled bytes.
|
|
||||||
*/
|
|
||||||
override suspend fun remoteChecksum(device: DeviceInfo, photo: PhonePhoto): String? = null
|
override suspend fun remoteChecksum(device: DeviceInfo, photo: PhonePhoto): String? = null
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Internal helpers
|
// Helpers
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
private fun runHelper(vararg args: String): String {
|
||||||
* Parse a single line from `gphoto2 --list-files`.
|
val cmd = listOf(pythonBin, helperScript) + args.toList()
|
||||||
* Example: "#1 IMG_0001.HEIC 5906 KB 2026-07-15 14:22:01"
|
log.debug("Running: ${cmd.joinToString(" ")}")
|
||||||
*/
|
|
||||||
private fun parseGphoto2FileLine(line: String): PhonePhoto? {
|
|
||||||
return try {
|
|
||||||
val trimmed = line.trim()
|
|
||||||
val indexEnd = trimmed.indexOf(' ')
|
|
||||||
val index = trimmed.substring(0, indexEnd).trimStart('#')
|
|
||||||
|
|
||||||
// Split remainder on multiple spaces
|
val process = ProcessBuilder(cmd)
|
||||||
val rest = trimmed.substring(indexEnd).trim().split(Regex("\\s{2,}"))
|
.redirectErrorStream(false)
|
||||||
if (rest.size < 3) return null
|
.start()
|
||||||
|
|
||||||
val filename = rest[0].trim()
|
val stdout = process.inputStream.bufferedReader().readText()
|
||||||
// rest[1] = "5906 KB" or "5906 kB"
|
val stderr = process.errorStream.bufferedReader().readText()
|
||||||
val sizeStr = rest[1].trim().split(" ").firstOrNull() ?: "0"
|
process.waitFor()
|
||||||
val sizeKb = sizeStr.toLongOrNull() ?: 0L
|
|
||||||
// rest[2] could be "2026-07-15 14:22:01" (split might be further)
|
|
||||||
val datePart = rest.drop(2).joinToString(" ").trim()
|
|
||||||
val dateTaken = try {
|
|
||||||
LocalDateTime.parse(datePart, gphotoDateFormatter)
|
|
||||||
.atZone(ZoneId.systemDefault()).toInstant()
|
|
||||||
} catch (_: Exception) {
|
|
||||||
Instant.EPOCH
|
|
||||||
}
|
|
||||||
|
|
||||||
val ext = filename.substringAfterLast(".", "").lowercase()
|
if (stderr.isNotBlank()) log.warn("iOS helper stderr: $stderr")
|
||||||
val mediaType = when (ext) {
|
return stdout.trim()
|
||||||
"mp4", "mov", "3gp", "avi" -> MediaType.VIDEO
|
|
||||||
"jpg", "jpeg", "heic", "heif", "png", "dng" -> MediaType.PHOTO
|
|
||||||
else -> MediaType.UNKNOWN
|
|
||||||
}
|
|
||||||
|
|
||||||
PhonePhoto(
|
|
||||||
devicePath = "#$index",
|
|
||||||
filename = filename,
|
|
||||||
sizeBytes = sizeKb * 1024,
|
|
||||||
dateTaken = dateTaken,
|
|
||||||
// iPhones always have camera Make/Model EXIF — we set it from device name
|
|
||||||
// since gphoto2 --list-files doesn't expose EXIF inline
|
|
||||||
exifMake = "Apple",
|
|
||||||
exifModel = null, // populated later if needed
|
|
||||||
mediaType = mediaType,
|
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
log.debug("Could not parse gphoto2 line: $line — ${e.message}")
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detect Live Photo pairs by matching HEIC + MOV files with the same base name
|
* Extract the bundled helper script from the JAR resources to a temp file.
|
||||||
* (e.g. IMG_0001.HEIC + IMG_0001.MOV). Tags both sides appropriately.
|
* Returns the absolute path to the extracted script.
|
||||||
|
*/
|
||||||
|
private fun extractHelperScript(): String {
|
||||||
|
val resourcePath = "/scripts/photophetch-ios-helper.py"
|
||||||
|
val stream = IosConnector::class.java.getResourceAsStream(resourcePath)
|
||||||
|
?: throw IllegalStateException(
|
||||||
|
"Bundled iOS helper script not found in JAR. " +
|
||||||
|
"Expected resource: $resourcePath"
|
||||||
|
)
|
||||||
|
|
||||||
|
val tempFile = java.io.File.createTempFile("photophetch-ios-helper", ".py")
|
||||||
|
tempFile.deleteOnExit()
|
||||||
|
tempFile.outputStream().use { out -> stream.copyTo(out) }
|
||||||
|
tempFile.setExecutable(true)
|
||||||
|
|
||||||
|
log.info("Extracted iOS helper script to ${tempFile.absolutePath}")
|
||||||
|
return tempFile.absolutePath
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect Live Photo pairs by matching HEIC/HEIF + MOV files with the same base name.
|
||||||
*/
|
*/
|
||||||
private fun tagLivePhotoPairs(photos: List<PhonePhoto>): List<PhonePhoto> {
|
private fun tagLivePhotoPairs(photos: List<PhonePhoto>): List<PhonePhoto> {
|
||||||
// Build a map from base name (no extension) → list of photos
|
|
||||||
val byBaseName = photos.groupBy { it.filename.substringBeforeLast(".", it.filename) }
|
val byBaseName = photos.groupBy { it.filename.substringBeforeLast(".", it.filename) }
|
||||||
|
|
||||||
return photos.map { photo ->
|
return photos.map { photo ->
|
||||||
val baseName = photo.filename.substringBeforeLast(".", photo.filename)
|
val baseName = photo.filename.substringBeforeLast(".", photo.filename)
|
||||||
val siblings = byBaseName[baseName] ?: return@map photo
|
val siblings = byBaseName[baseName] ?: return@map photo
|
||||||
|
val hasHeic = siblings.any { it.filename.endsWith(".HEIC", ignoreCase = true) || it.filename.endsWith(".HEIF", ignoreCase = true) }
|
||||||
val hasHeic = siblings.any {
|
val hasMov = siblings.any { it.filename.endsWith(".MOV", ignoreCase = true) }
|
||||||
it.filename.endsWith(".HEIC", ignoreCase = true) ||
|
|
||||||
it.filename.endsWith(".HEIF", ignoreCase = true)
|
|
||||||
}
|
|
||||||
val hasMov = siblings.any {
|
|
||||||
it.filename.endsWith(".MOV", ignoreCase = true)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasHeic || !hasMov) return@map photo
|
if (!hasHeic || !hasMov) return@map photo
|
||||||
|
|
||||||
val ext = photo.filename.substringAfterLast(".", "").lowercase()
|
val ext = photo.filename.substringAfterLast(".", "").lowercase()
|
||||||
when (ext) {
|
when (ext) {
|
||||||
"heic", "heif" -> photo.copy(isLivePhotoStill = true)
|
"heic", "heif" -> photo.copy(isLivePhotoStill = true)
|
||||||
@@ -221,26 +228,4 @@ class IosConnector(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun requirePort(device: DeviceInfo): String =
|
|
||||||
device.gphotoPort ?: throw DeviceException("No gphoto2 port for device: ${device.displayName}")
|
|
||||||
|
|
||||||
private fun runGphoto2(vararg args: String): String = runGphoto2InDir(null, *args)
|
|
||||||
|
|
||||||
private fun runGphoto2InDir(workingDir: Path?, vararg args: String): String {
|
|
||||||
val cmd = buildList {
|
|
||||||
add(gphoto2Bin)
|
|
||||||
addAll(args)
|
|
||||||
}
|
|
||||||
log.debug("Running: ${cmd.joinToString(" ")}")
|
|
||||||
|
|
||||||
val builder = ProcessBuilder(cmd).redirectErrorStream(true)
|
|
||||||
if (workingDir != null) builder.directory(workingDir.toFile())
|
|
||||||
|
|
||||||
val process = builder.start()
|
|
||||||
val output = process.inputStream.bufferedReader().readText()
|
|
||||||
process.waitFor()
|
|
||||||
|
|
||||||
return output
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,10 +17,12 @@ data class AppConfig(
|
|||||||
val defaultStripLivePhotoMotion: Boolean = true,
|
val defaultStripLivePhotoMotion: Boolean = true,
|
||||||
/** Default: do not delete from phone (safer default) */
|
/** Default: do not delete from phone (safer default) */
|
||||||
val defaultDeleteAfterVerify: Boolean = false,
|
val defaultDeleteAfterVerify: Boolean = false,
|
||||||
|
/** Default: preserve folder structure on import */
|
||||||
|
val defaultPreserveFolderStructure: Boolean = true,
|
||||||
/** Whether the user has been warned about HEIC phone settings */
|
/** Whether the user has been warned about HEIC phone settings */
|
||||||
val heicWarningDismissed: Boolean = false,
|
val heicWarningDismissed: Boolean = false,
|
||||||
/** Path to adb binary, or null to use PATH */
|
/** Path to adb binary, or null to use PATH */
|
||||||
val adbPath: String? = null,
|
val adbPath: String? = null,
|
||||||
/** Path to gphoto2 binary, or null to use PATH */
|
/** Path to python3 binary, or null to use PATH (needed for iOS via pymobiledevice3) */
|
||||||
val gphoto2Path: String? = null,
|
val python3Path: String? = null,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -19,6 +19,18 @@ data class ImportJob(
|
|||||||
val stripLivePhotoMotion: Boolean = true,
|
val stripLivePhotoMotion: Boolean = true,
|
||||||
/** Whether to delete photos from the phone after successful verification */
|
/** Whether to delete photos from the phone after successful verification */
|
||||||
val deleteAfterVerify: Boolean = false,
|
val deleteAfterVerify: Boolean = false,
|
||||||
|
/**
|
||||||
|
* Whether to import Live Photo .MOV companions alongside their HEIC stills.
|
||||||
|
* Default false — you said you don't want them.
|
||||||
|
*/
|
||||||
|
val importLivePhotoCompanions: Boolean = false,
|
||||||
|
/**
|
||||||
|
* Whether to preserve the device's subfolder structure under the staging folder.
|
||||||
|
* e.g. _staging/2026-07-28_Samsung/Camera/IMG_001.jpg
|
||||||
|
* _staging/2026-07-28_Samsung/Screenshots/ (excluded by filter normally)
|
||||||
|
* Used when importing in "show all" mode.
|
||||||
|
*/
|
||||||
|
val preserveFolderStructure: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -31,6 +43,14 @@ data class CopyResult(
|
|||||||
val destSha256: String,
|
val destSha256: String,
|
||||||
val verified: Boolean,
|
val verified: Boolean,
|
||||||
val convertedFromHeic: Boolean = false,
|
val convertedFromHeic: Boolean = false,
|
||||||
|
/**
|
||||||
|
* True if exiftool confirmed Make=Apple (or expected camera make) on the pulled file.
|
||||||
|
* False means EXIF was missing or Make was unexpected — file may not be a camera original.
|
||||||
|
* Null means exiftool was not available or not run.
|
||||||
|
*/
|
||||||
|
val exifVerified: Boolean? = null,
|
||||||
|
/** The Make value exiftool found, e.g. "Apple", "samsung", or null if absent */
|
||||||
|
val exifMake: String? = null,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -20,10 +20,17 @@ data class PhonePhoto(
|
|||||||
val exifModel: String?,
|
val exifModel: String?,
|
||||||
/** Media type */
|
/** Media type */
|
||||||
val mediaType: MediaType,
|
val mediaType: MediaType,
|
||||||
|
/** Source folder name on the device (e.g. "Camera", "OpenCamera", "PeakLens") */
|
||||||
|
val sourceFolder: String = "Camera",
|
||||||
/** True if this is the still component of an Apple Live Photo (paired with a .MOV) */
|
/** True if this is the still component of an Apple Live Photo (paired with a .MOV) */
|
||||||
val isLivePhotoStill: Boolean = false,
|
val isLivePhotoStill: Boolean = false,
|
||||||
/** True if this is the motion component of an Apple Live Photo (.MOV companion) */
|
/** True if this is the motion component of an Apple Live Photo (.MOV companion) */
|
||||||
val isLivePhotoMotion: Boolean = false,
|
val isLivePhotoMotion: Boolean = false,
|
||||||
|
/**
|
||||||
|
* Device path of the paired MOV companion (set on the HEIC when isLivePhotoStill=true).
|
||||||
|
* Used by the Copier to pull both files atomically and by deletePhoto to delete both.
|
||||||
|
*/
|
||||||
|
val companionDevicePath: String? = null,
|
||||||
/** True if the format is HEIC — user may want to convert to JPEG */
|
/** True if the format is HEIC — user may want to convert to JPEG */
|
||||||
val isHeic: Boolean = filename.endsWith(".HEIC", ignoreCase = true) ||
|
val isHeic: Boolean = filename.endsWith(".HEIC", ignoreCase = true) ||
|
||||||
filename.endsWith(".HEIF", ignoreCase = true),
|
filename.endsWith(".HEIF", ignoreCase = true),
|
||||||
|
|||||||
@@ -63,11 +63,37 @@ class Copier(
|
|||||||
|
|
||||||
photosToImport.forEachIndexed { idx, photo ->
|
photosToImport.forEachIndexed { idx, photo ->
|
||||||
onProgress(idx, photosToImport.size, photo.filename)
|
onProgress(idx, photosToImport.size, photo.filename)
|
||||||
val result = copyOne(photo, stagingDir, job.convertHeicToJpeg)
|
val destDir = if (job.preserveFolderStructure) {
|
||||||
|
val subFolder = photo.sourceFolder.ifBlank {
|
||||||
|
photo.devicePath.substringAfter("/DCIM/", "")
|
||||||
|
.substringBeforeLast("/", "").ifBlank { "Camera" }
|
||||||
|
}
|
||||||
|
stagingDir.resolve(subFolder).also { it.toFile().mkdirs() }
|
||||||
|
} else {
|
||||||
|
stagingDir
|
||||||
|
}
|
||||||
|
val result = copyOne(photo, destDir, job.convertHeicToJpeg)
|
||||||
results.add(result)
|
results.add(result)
|
||||||
if (result.error != null) {
|
if (result.error != null) {
|
||||||
log.error("Failed to copy ${photo.filename}: ${result.error}")
|
log.error("Failed to copy ${photo.filename}: ${result.error}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pull Live Photo companion MOV if requested
|
||||||
|
if (job.importLivePhotoCompanions && photo.isLivePhotoStill && photo.companionDevicePath != null) {
|
||||||
|
val companionFilename = photo.companionDevicePath.substringAfterLast("/")
|
||||||
|
val companionPhoto = photo.copy(
|
||||||
|
devicePath = photo.companionDevicePath,
|
||||||
|
filename = companionFilename,
|
||||||
|
isLivePhotoStill = false,
|
||||||
|
isLivePhotoMotion = true,
|
||||||
|
companionDevicePath = null,
|
||||||
|
)
|
||||||
|
val companionResult = copyOne(companionPhoto, destDir, convertHeic = false)
|
||||||
|
results.add(companionResult)
|
||||||
|
if (companionResult.error != null) {
|
||||||
|
log.error("Failed to copy companion ${companionFilename}: ${companionResult.error}")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
onProgress(photosToImport.size, photosToImport.size, "")
|
onProgress(photosToImport.size, photosToImport.size, "")
|
||||||
|
|
||||||
@@ -101,29 +127,35 @@ class Copier(
|
|||||||
pulledFile to false
|
pulledFile to false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Hash the destination file
|
// Step 3: Hash the destination file (always — gives us a local checksum on record)
|
||||||
val destHash = sha256(finalFile.inputStream())
|
val destHash = sha256(finalFile.inputStream())
|
||||||
|
val destSize = finalFile.toFile().length()
|
||||||
|
|
||||||
// Step 4: Get remote hash for cross-check (Android only; iOS returns null)
|
// Step 4: Get remote hash for cross-check (Android only; iOS returns null)
|
||||||
val remoteHash = connector.remoteChecksum(device, photo)
|
val remoteHash = connector.remoteChecksum(device, photo)
|
||||||
|
|
||||||
// Verification: if we have a remote hash, compare; otherwise mark verified
|
val verified: Boolean
|
||||||
// (the copy itself is the verification for iOS since we have no remote hash)
|
|
||||||
val verified = when {
|
when {
|
||||||
wasConverted -> {
|
wasConverted -> {
|
||||||
// HEIC was converted — we can't compare against original hash.
|
// HEIC converted — can't compare hashes, check file is non-empty
|
||||||
// We accept the file if it exists and is non-zero.
|
verified = destSize > 0
|
||||||
finalFile.exists() && finalFile.toFile().length() > 0
|
|
||||||
}
|
}
|
||||||
remoteHash != null -> {
|
remoteHash != null -> {
|
||||||
remoteHash.equals(destHash, ignoreCase = true)
|
// Full hash comparison
|
||||||
|
verified = remoteHash.equals(destHash, ignoreCase = true)
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
// No remote hash — verify that file is non-empty and readable
|
// No remote hash — verify size matches what ADB reported
|
||||||
finalFile.exists() && finalFile.toFile().length() > 0
|
// (sizeBytes from ls -la, so we know what the device said)
|
||||||
|
val sizeMatch = photo.sizeBytes == 0L || // unknown size is OK
|
||||||
|
destSize == photo.sizeBytes
|
||||||
|
verified = destSize > 0 && sizeMatch
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val exifMake = readExifMake(finalFile)
|
||||||
|
|
||||||
CopyResult(
|
CopyResult(
|
||||||
photo = photo,
|
photo = photo,
|
||||||
destinationPath = finalFile,
|
destinationPath = finalFile,
|
||||||
@@ -131,7 +163,13 @@ class Copier(
|
|||||||
destSha256 = destHash,
|
destSha256 = destHash,
|
||||||
verified = verified,
|
verified = verified,
|
||||||
convertedFromHeic = wasConverted,
|
convertedFromHeic = wasConverted,
|
||||||
error = if (!verified) "Hash mismatch — file may be corrupt" else null,
|
exifVerified = exifMake?.let { it.isNotBlank() },
|
||||||
|
exifMake = exifMake,
|
||||||
|
error = when {
|
||||||
|
!verified && remoteHash != null -> "Hash mismatch — file may be corrupt"
|
||||||
|
!verified -> "Copy failed — file missing or size mismatch"
|
||||||
|
else -> null
|
||||||
|
},
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
pulledFile?.deleteIfExists()
|
pulledFile?.deleteIfExists()
|
||||||
@@ -201,4 +239,32 @@ class Copier(
|
|||||||
}
|
}
|
||||||
return digest.digest().joinToString("") { "%02x".format(it) }
|
return digest.digest().joinToString("") { "%02x".format(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the EXIF Make tag from a local file using exiftool.
|
||||||
|
* Returns the Make value (e.g. "Apple"), empty string if Make tag absent,
|
||||||
|
* or null if exiftool is not installed or fails.
|
||||||
|
*/
|
||||||
|
private fun readExifMake(file: Path): String? {
|
||||||
|
return try {
|
||||||
|
val process = ProcessBuilder("exiftool", "-Make", "-s3", file.absolutePathString())
|
||||||
|
.redirectErrorStream(false) // keep stderr separate
|
||||||
|
.start()
|
||||||
|
val stdout = process.inputStream.bufferedReader().readText()
|
||||||
|
process.errorStream.readBytes() // drain stderr
|
||||||
|
process.waitFor()
|
||||||
|
|
||||||
|
// Strip warning lines (exiftool prints "Warning: ..." to stdout with -s3)
|
||||||
|
val make = stdout.lines()
|
||||||
|
.filter { !it.startsWith("Warning:") && !it.startsWith("Error:") && it.isNotBlank() }
|
||||||
|
.firstOrNull()
|
||||||
|
?.trim()
|
||||||
|
?: "" // empty string = tag absent, exiftool ran fine
|
||||||
|
|
||||||
|
make
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log.debug("exiftool not available: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import com.bolenpad.photophetch.device.DeviceConnector
|
|||||||
import com.bolenpad.photophetch.device.DeviceException
|
import com.bolenpad.photophetch.device.DeviceException
|
||||||
import com.bolenpad.photophetch.device.IosConnector
|
import com.bolenpad.photophetch.device.IosConnector
|
||||||
import com.bolenpad.photophetch.model.AppConfig
|
import com.bolenpad.photophetch.model.AppConfig
|
||||||
import com.bolenpad.photophetch.model.CopyResult
|
|
||||||
import com.bolenpad.photophetch.model.DeviceInfo
|
import com.bolenpad.photophetch.model.DeviceInfo
|
||||||
import com.bolenpad.photophetch.model.DeviceType
|
import com.bolenpad.photophetch.model.DeviceType
|
||||||
import com.bolenpad.photophetch.model.ImportJob
|
import com.bolenpad.photophetch.model.ImportJob
|
||||||
@@ -31,15 +30,9 @@ import java.time.LocalDate
|
|||||||
|
|
||||||
/** Top-level navigation destinations */
|
/** Top-level navigation destinations */
|
||||||
enum class Screen {
|
enum class Screen {
|
||||||
CONNECT,
|
CONNECT, BROWSE, IMPORT_CONFIG, IMPORTING, VERIFY, SETTINGS,
|
||||||
BROWSE,
|
|
||||||
IMPORT_CONFIG,
|
|
||||||
IMPORTING,
|
|
||||||
VERIFY,
|
|
||||||
SETTINGS,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sealed state for the device scan */
|
|
||||||
sealed class DeviceScanState {
|
sealed class DeviceScanState {
|
||||||
object Idle : DeviceScanState()
|
object Idle : DeviceScanState()
|
||||||
object Scanning : DeviceScanState()
|
object Scanning : DeviceScanState()
|
||||||
@@ -47,37 +40,40 @@ sealed class DeviceScanState {
|
|||||||
data class Error(val message: String) : DeviceScanState()
|
data class Error(val message: String) : DeviceScanState()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sealed state for the photo listing */
|
|
||||||
sealed class PhotoListState {
|
sealed class PhotoListState {
|
||||||
object Idle : PhotoListState()
|
object Idle : PhotoListState()
|
||||||
object Loading : PhotoListState()
|
object Loading : PhotoListState()
|
||||||
data class Loaded(
|
data class Loaded(
|
||||||
|
/** All photos from the device, unfiltered */
|
||||||
val all: List<PhonePhoto>,
|
val all: List<PhonePhoto>,
|
||||||
val filtered: List<PhonePhoto>,
|
/** Folders derived from all photos, sorted: camera-roll folders first then alphabetical */
|
||||||
val shootingInHeic: Boolean,
|
val folders: List<FolderInfo>,
|
||||||
) : PhotoListState()
|
) : PhotoListState()
|
||||||
data class Error(val message: String) : PhotoListState()
|
data class Error(val message: String) : PhotoListState()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Progress during active import */
|
/** Summary of a DCIM subfolder */
|
||||||
|
data class FolderInfo(
|
||||||
|
val name: String,
|
||||||
|
val photoCount: Int,
|
||||||
|
val earliestDate: LocalDate,
|
||||||
|
val latestDate: LocalDate,
|
||||||
|
/** True if this folder passes the camera-roll heuristic (not screenshots etc.) */
|
||||||
|
val isCameraRoll: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
data class ImportProgress(
|
data class ImportProgress(
|
||||||
val completed: Int,
|
val completed: Int,
|
||||||
val total: Int,
|
val total: Int,
|
||||||
val currentFile: String,
|
val currentFile: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
class AppState(private val configStore: ConfigStore) {
|
||||||
* Application state container — the single source of truth for all UI state.
|
|
||||||
* All mutations happen via public methods; UI observes via StateFlows.
|
|
||||||
*/
|
|
||||||
class AppState(
|
|
||||||
private val configStore: ConfigStore,
|
|
||||||
) {
|
|
||||||
private val log = LoggerFactory.getLogger(AppState::class.java)
|
private val log = LoggerFactory.getLogger(AppState::class.java)
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Persisted config
|
// Config
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
private val _config = MutableStateFlow(configStore.load())
|
private val _config = MutableStateFlow(configStore.load())
|
||||||
val config: StateFlow<AppConfig> = _config.asStateFlow()
|
val config: StateFlow<AppConfig> = _config.asStateFlow()
|
||||||
@@ -87,7 +83,6 @@ class AppState(
|
|||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
private val _currentScreen = MutableStateFlow(Screen.CONNECT)
|
private val _currentScreen = MutableStateFlow(Screen.CONNECT)
|
||||||
val currentScreen: StateFlow<Screen> = _currentScreen.asStateFlow()
|
val currentScreen: StateFlow<Screen> = _currentScreen.asStateFlow()
|
||||||
|
|
||||||
fun navigateTo(screen: Screen) { _currentScreen.value = screen }
|
fun navigateTo(screen: Screen) { _currentScreen.value = screen }
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -99,20 +94,20 @@ class AppState(
|
|||||||
private val _selectedDevice = MutableStateFlow<DeviceInfo?>(null)
|
private val _selectedDevice = MutableStateFlow<DeviceInfo?>(null)
|
||||||
val selectedDevice: StateFlow<DeviceInfo?> = _selectedDevice.asStateFlow()
|
val selectedDevice: StateFlow<DeviceInfo?> = _selectedDevice.asStateFlow()
|
||||||
|
|
||||||
|
/** Incremented after each thumbnail batch completes — DenseThumb observes this to retry */
|
||||||
|
private val _thumbnailVersion = MutableStateFlow(0)
|
||||||
|
val thumbnailVersion: StateFlow<Int> = _thumbnailVersion.asStateFlow()
|
||||||
|
|
||||||
fun scanForDevices() {
|
fun scanForDevices() {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
_deviceScan.value = DeviceScanState.Scanning
|
_deviceScan.value = DeviceScanState.Scanning
|
||||||
try {
|
try {
|
||||||
val devices = withContext(Dispatchers.IO) {
|
val devices = withContext(Dispatchers.IO) {
|
||||||
val android = AndroidConnector(_config.value.adbPath ?: "adb")
|
val android = AndroidConnector(_config.value.adbPath ?: "adb")
|
||||||
val ios = IosConnector(_config.value.gphoto2Path ?: "gphoto2")
|
val ios = IosConnector(_config.value.python3Path.takeIf { !it.isNullOrBlank() })
|
||||||
android.detectDevices() + ios.detectDevices()
|
android.detectDevices() + ios.detectDevices()
|
||||||
}
|
}
|
||||||
_deviceScan.value = if (devices.isEmpty()) {
|
_deviceScan.value = DeviceScanState.Found(devices)
|
||||||
DeviceScanState.Found(emptyList())
|
|
||||||
} else {
|
|
||||||
DeviceScanState.Found(devices)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
log.error("Device scan failed", e)
|
log.error("Device scan failed", e)
|
||||||
_deviceScan.value = DeviceScanState.Error(e.message ?: "Unknown error")
|
_deviceScan.value = DeviceScanState.Error(e.message ?: "Unknown error")
|
||||||
@@ -127,30 +122,52 @@ class AppState(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Photo listing
|
// Photo listing + folder state
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
private val _photoList = MutableStateFlow<PhotoListState>(PhotoListState.Idle)
|
private val _photoList = MutableStateFlow<PhotoListState>(PhotoListState.Idle)
|
||||||
val photoList: StateFlow<PhotoListState> = _photoList.asStateFlow()
|
val photoList: StateFlow<PhotoListState> = _photoList.asStateFlow()
|
||||||
|
|
||||||
|
/** Currently active folder name — null means "all camera-roll folders" */
|
||||||
|
private val _selectedFolder = MutableStateFlow<String?>(null)
|
||||||
|
val selectedFolder: StateFlow<String?> = _selectedFolder.asStateFlow()
|
||||||
|
|
||||||
|
/** Photos currently visible in the grid (filtered by folder) */
|
||||||
|
private val _visiblePhotos = MutableStateFlow<List<PhonePhoto>>(emptyList())
|
||||||
|
val visiblePhotos: StateFlow<List<PhonePhoto>> = _visiblePhotos.asStateFlow()
|
||||||
|
|
||||||
|
/** Selected photo set */
|
||||||
private val _selectedPhotos = MutableStateFlow<Set<PhonePhoto>>(emptySet())
|
private val _selectedPhotos = MutableStateFlow<Set<PhonePhoto>>(emptySet())
|
||||||
val selectedPhotos: StateFlow<Set<PhonePhoto>> = _selectedPhotos.asStateFlow()
|
val selectedPhotos: StateFlow<Set<PhonePhoto>> = _selectedPhotos.asStateFlow()
|
||||||
|
|
||||||
private val _dateFilter = MutableStateFlow<Pair<LocalDate?, LocalDate?>>(null to null)
|
/** Whether HEIC warning should be shown */
|
||||||
val dateFilter: StateFlow<Pair<LocalDate?, LocalDate?>> = _dateFilter.asStateFlow()
|
val shootingInHeic: Boolean
|
||||||
|
get() = (_photoList.value as? PhotoListState.Loaded)
|
||||||
|
?.all?.let { ExifFilter.isShootingInHeic(it) } ?: false
|
||||||
|
|
||||||
fun loadPhotos(device: DeviceInfo) {
|
fun loadPhotos(device: DeviceInfo) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
_photoList.value = PhotoListState.Loading
|
_photoList.value = PhotoListState.Loading
|
||||||
|
_selectedFolder.value = null
|
||||||
|
_selectedPhotos.value = emptySet()
|
||||||
try {
|
try {
|
||||||
val connector = connectorFor(device)
|
val connector = connectorFor(device)
|
||||||
val raw = withContext(Dispatchers.IO) { connector.listPhotos(device) }
|
val raw = withContext(Dispatchers.IO) { connector.listPhotos(device) }
|
||||||
val filtered = ExifFilter.filter(raw, _config.value.defaultStripLivePhotoMotion)
|
|
||||||
val shootingInHeic = ExifFilter.isShootingInHeic(filtered)
|
// Build folder summaries
|
||||||
_photoList.value = PhotoListState.Loaded(
|
val folders = buildFolderList(raw)
|
||||||
all = raw,
|
|
||||||
filtered = filtered,
|
_photoList.value = PhotoListState.Loaded(all = raw, folders = folders)
|
||||||
shootingInHeic = shootingInHeic,
|
|
||||||
)
|
// Default: select the first camera-roll folder
|
||||||
|
val defaultFolder = folders.firstOrNull { it.isCameraRoll }?.name
|
||||||
|
selectFolder(defaultFolder)
|
||||||
|
|
||||||
|
// For iOS: batch pre-fetch thumbnails in background (amortises Python startup cost)
|
||||||
|
if (device.type == DeviceType.IOS && connector is com.bolenpad.photophetch.device.IosConnector) {
|
||||||
|
launch(Dispatchers.IO) {
|
||||||
|
prefetchIosThumbnails(device, raw, connector)
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
log.error("Photo listing failed", e)
|
log.error("Photo listing failed", e)
|
||||||
_photoList.value = PhotoListState.Error(e.message ?: "Unknown error")
|
_photoList.value = PhotoListState.Error(e.message ?: "Unknown error")
|
||||||
@@ -158,40 +175,161 @@ class AppState(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setDateFilter(from: LocalDate?, to: LocalDate?) {
|
private suspend fun prefetchIosThumbnails(
|
||||||
_dateFilter.value = from to to
|
device: DeviceInfo,
|
||||||
// Re-apply filter over already-loaded photos
|
photos: List<PhonePhoto>,
|
||||||
val current = _photoList.value as? PhotoListState.Loaded ?: return
|
connector: com.bolenpad.photophetch.device.IosConnector,
|
||||||
val filtered = ExifFilter.filter(current.all, _config.value.defaultStripLivePhotoMotion)
|
) {
|
||||||
.let { applyDateFilter(it, from, to) }
|
val cacheKey = connector.cacheKey(device)
|
||||||
_photoList.value = current.copy(filtered = filtered)
|
val cacheRoot = java.nio.file.Paths.get(System.getProperty("user.home"), ".photophetch", "thumbcache")
|
||||||
|
val cacheDir = cacheRoot.resolve(cacheKey.replace("/", "_").replace(":", "_"))
|
||||||
|
if (!cacheDir.toFile().exists()) cacheDir.toFile().mkdirs()
|
||||||
|
val tmpDir = java.nio.file.Paths.get(System.getProperty("java.io.tmpdir"), "photophetch-thumbs")
|
||||||
|
if (!tmpDir.toFile().exists()) tmpDir.toFile().mkdirs()
|
||||||
|
|
||||||
|
// Only fetch photos not already cached, sorted newest first
|
||||||
|
val needed = photos
|
||||||
|
.sortedByDescending { it.dateTaken }
|
||||||
|
.filter { photo ->
|
||||||
|
val ext = photo.filename.substringAfterLast(".", "").lowercase()
|
||||||
|
if (ext in setOf("mp4", "mov", "3gp", "avi", "mkv", "m4v", "webm")) return@filter false
|
||||||
|
val hash = (cacheKey + photo.devicePath).hashCode().toString(16)
|
||||||
|
val cachePath = cacheDir.resolve("$hash.jpg")
|
||||||
|
!cachePath.toFile().exists() || cachePath.toFile().length() == 0L
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needed.isEmpty()) {
|
||||||
|
log.info("iOS thumbnails: all ${photos.size} already cached")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.info("iOS thumbnails: pre-fetching ${needed.size} / ${photos.size} (${photos.size - needed.size} already cached)")
|
||||||
|
|
||||||
|
var loaded = 0
|
||||||
|
val batchSize = 30
|
||||||
|
|
||||||
|
// Build pairs JSON and run batch-thumbs command
|
||||||
|
needed.chunked(batchSize).forEachIndexed { batchIdx, batch ->
|
||||||
|
val pairs = batch.joinToString(",") { photo ->
|
||||||
|
val ext = photo.filename.substringAfterLast(".", "").lowercase()
|
||||||
|
val hash = (cacheKey + photo.devicePath).hashCode().toString(16)
|
||||||
|
val tmpPath = tmpDir.resolve("$hash.$ext")
|
||||||
|
val src = photo.devicePath.replace("\"", "\\\"")
|
||||||
|
val dst = tmpPath.toString().replace("\"", "\\\"")
|
||||||
|
"[\"$src\",\"$dst\"]"
|
||||||
|
}
|
||||||
|
val pairsJson = "[$pairs]"
|
||||||
|
|
||||||
|
try {
|
||||||
|
val process = ProcessBuilder(connector.pythonBin, connector.helperScript, "batch-thumbs", pairsJson)
|
||||||
|
.redirectErrorStream(false).start()
|
||||||
|
process.inputStream.readBytes()
|
||||||
|
process.errorStream.readBytes()
|
||||||
|
process.waitFor()
|
||||||
|
|
||||||
|
// Run sips on each successfully pulled file
|
||||||
|
var batchLoaded = 0
|
||||||
|
batch.forEach { photo ->
|
||||||
|
val ext = photo.filename.substringAfterLast(".", "").lowercase()
|
||||||
|
val hash = (cacheKey + photo.devicePath).hashCode().toString(16)
|
||||||
|
val tmpFile = tmpDir.resolve("$hash.$ext")
|
||||||
|
val cachePath = cacheDir.resolve("$hash.jpg")
|
||||||
|
if (tmpFile.toFile().exists() && tmpFile.toFile().length() > 0 && !cachePath.toFile().exists()) {
|
||||||
|
val success = if (ext in setOf("heic", "heif")) {
|
||||||
|
// Convert partial HEIC to JPEG via sips
|
||||||
|
// sips can decode HEIC even from a partial file if the header is intact
|
||||||
|
val sips = ProcessBuilder(
|
||||||
|
"sips", "--resampleWidth", "240",
|
||||||
|
"--setProperty", "format", "jpeg",
|
||||||
|
tmpFile.toString(), "--out", cachePath.toString()
|
||||||
|
).redirectErrorStream(true).start()
|
||||||
|
sips.inputStream.readBytes()
|
||||||
|
sips.waitFor()
|
||||||
|
cachePath.toFile().exists() && cachePath.toFile().length() > 0
|
||||||
|
} else {
|
||||||
|
// JPEG/PNG: simple resample
|
||||||
|
val sips = ProcessBuilder(
|
||||||
|
"sips", "--resampleWidth", "240",
|
||||||
|
"--setProperty", "format", "jpeg",
|
||||||
|
tmpFile.toString(), "--out", cachePath.toString()
|
||||||
|
).redirectErrorStream(true).start()
|
||||||
|
sips.inputStream.readBytes()
|
||||||
|
sips.waitFor()
|
||||||
|
cachePath.toFile().exists() && cachePath.toFile().length() > 0
|
||||||
|
}
|
||||||
|
tmpFile.toFile().delete()
|
||||||
|
if (success) batchLoaded++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loaded += batchLoaded
|
||||||
|
if (batchLoaded > 0) {
|
||||||
|
// Notify UI that new thumbnails are available
|
||||||
|
_thumbnailVersion.value++
|
||||||
|
}
|
||||||
|
log.info("iOS thumbnails: $loaded / ${needed.size} loaded (batch ${batchIdx + 1}/${(needed.size + batchSize - 1) / batchSize})")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log.warn("iOS thumbnail batch ${batchIdx + 1} failed: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.info("iOS thumbnails: done — $loaded / ${needed.size} loaded successfully")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun togglePhotoSelection(photo: PhonePhoto) {
|
fun selectFolder(folderName: String?) {
|
||||||
_selectedPhotos.update { current ->
|
_selectedFolder.value = folderName
|
||||||
if (photo in current) current - photo else current + photo
|
_selectedPhotos.value = emptySet()
|
||||||
|
refreshVisiblePhotos()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refreshVisiblePhotos() {
|
||||||
|
val loaded = _photoList.value as? PhotoListState.Loaded ?: return
|
||||||
|
val folder = _selectedFolder.value
|
||||||
|
_visiblePhotos.value = if (folder == null) {
|
||||||
|
// All camera-roll folders merged
|
||||||
|
ExifFilter.filter(loaded.all, _config.value.defaultStripLivePhotoMotion)
|
||||||
|
.sortedByDescending { it.dateTaken }
|
||||||
|
} else {
|
||||||
|
loaded.all.filter { it.sourceFolder == folder }
|
||||||
|
.sortedByDescending { it.dateTaken }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun selectAll() {
|
private fun buildFolderList(photos: List<PhonePhoto>): List<FolderInfo> {
|
||||||
val photos = (_photoList.value as? PhotoListState.Loaded)?.filtered ?: return
|
val zoneId = java.time.ZoneId.systemDefault()
|
||||||
_selectedPhotos.value = photos.toSet()
|
return photos
|
||||||
|
.groupBy { it.sourceFolder }
|
||||||
|
.filterKeys { !ExifFilter.isHiddenFolder(it) } // hide Live Photo MOV folder
|
||||||
|
.map { (name, folderPhotos) ->
|
||||||
|
val dates = folderPhotos.map {
|
||||||
|
it.dateTaken.atZone(zoneId).toLocalDate()
|
||||||
|
}
|
||||||
|
FolderInfo(
|
||||||
|
name = name,
|
||||||
|
photoCount = folderPhotos.size,
|
||||||
|
earliestDate = dates.min(),
|
||||||
|
latestDate = dates.max(),
|
||||||
|
isCameraRoll = ExifFilter.isCameraRollFolder(name),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Camera-roll folders first, then alphabetical
|
||||||
|
.sortedWith(compareByDescending<FolderInfo> { it.isCameraRoll }.thenBy { it.name })
|
||||||
|
}
|
||||||
|
|
||||||
|
fun togglePhotoSelection(photo: PhonePhoto) {
|
||||||
|
_selectedPhotos.update { if (photo in it) it - photo else it + photo }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun selectAllVisible() {
|
||||||
|
_selectedPhotos.value = _visiblePhotos.value.toSet()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun selectNone() {
|
fun selectNone() {
|
||||||
_selectedPhotos.value = emptySet()
|
_selectedPhotos.value = emptySet()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun applyDateFilter(
|
fun selectFolder_All() {
|
||||||
photos: List<PhonePhoto>,
|
val loaded = _photoList.value as? PhotoListState.Loaded ?: return
|
||||||
from: LocalDate?,
|
val folder = _selectedFolder.value ?: return
|
||||||
to: LocalDate?,
|
val folderPhotos = loaded.all.filter { it.sourceFolder == folder }.toSet()
|
||||||
): List<PhonePhoto> {
|
_selectedPhotos.update { it + folderPhotos }
|
||||||
if (from == null && to == null) return photos
|
|
||||||
return photos.filter { photo ->
|
|
||||||
val date = photo.dateTaken.atZone(java.time.ZoneId.systemDefault()).toLocalDate()
|
|
||||||
(from == null || !date.isBefore(from)) && (to == null || !date.isAfter(to))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -202,6 +340,7 @@ class AppState(
|
|||||||
convertHeicToJpeg = true,
|
convertHeicToJpeg = true,
|
||||||
stripLivePhotoMotion = true,
|
stripLivePhotoMotion = true,
|
||||||
deleteAfterVerify = false,
|
deleteAfterVerify = false,
|
||||||
|
preserveFolderStructure = true,
|
||||||
folderName = "",
|
folderName = "",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -211,6 +350,8 @@ class AppState(
|
|||||||
val convertHeicToJpeg: Boolean,
|
val convertHeicToJpeg: Boolean,
|
||||||
val stripLivePhotoMotion: Boolean,
|
val stripLivePhotoMotion: Boolean,
|
||||||
val deleteAfterVerify: Boolean,
|
val deleteAfterVerify: Boolean,
|
||||||
|
val preserveFolderStructure: Boolean,
|
||||||
|
val importLivePhotoCompanions: Boolean = false,
|
||||||
val folderName: String,
|
val folderName: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -220,11 +361,22 @@ class AppState(
|
|||||||
|
|
||||||
fun prepareImport() {
|
fun prepareImport() {
|
||||||
val device = _selectedDevice.value ?: return
|
val device = _selectedDevice.value ?: return
|
||||||
|
val cfg = _config.value
|
||||||
val today = LocalDate.now()
|
val today = LocalDate.now()
|
||||||
val folderName = _config.value.folderNameTemplate
|
val baseName = cfg.folderNameTemplate
|
||||||
.replace("{date}", today.toString())
|
.replace("{date}", today.toString())
|
||||||
.replace("{device}", StagingPath.deviceLabel(device))
|
.replace("{device}", StagingPath.deviceLabel(device))
|
||||||
_importConfig.update { it.copy(folderName = folderName) }
|
|
||||||
|
val folderName = if (cfg.stagingRoot.isNotBlank()) {
|
||||||
|
StagingPath.uniqueFolderName(Paths.get(cfg.stagingRoot), baseName)
|
||||||
|
} else baseName
|
||||||
|
|
||||||
|
// Multi-folder selection → preserve structure; otherwise use saved config default
|
||||||
|
val multipleSourceFolders = _selectedPhotos.value.map { it.sourceFolder }.distinct().size > 1
|
||||||
|
_importConfig.update { it.copy(
|
||||||
|
folderName = folderName,
|
||||||
|
preserveFolderStructure = if (multipleSourceFolders) true else cfg.defaultPreserveFolderStructure,
|
||||||
|
) }
|
||||||
navigateTo(Screen.IMPORT_CONFIG)
|
navigateTo(Screen.IMPORT_CONFIG)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,6 +403,8 @@ class AppState(
|
|||||||
convertHeicToJpeg = importCfg.convertHeicToJpeg,
|
convertHeicToJpeg = importCfg.convertHeicToJpeg,
|
||||||
stripLivePhotoMotion = importCfg.stripLivePhotoMotion,
|
stripLivePhotoMotion = importCfg.stripLivePhotoMotion,
|
||||||
deleteAfterVerify = importCfg.deleteAfterVerify,
|
deleteAfterVerify = importCfg.deleteAfterVerify,
|
||||||
|
preserveFolderStructure = importCfg.preserveFolderStructure,
|
||||||
|
importLivePhotoCompanions = importCfg.importLivePhotoCompanions,
|
||||||
)
|
)
|
||||||
|
|
||||||
navigateTo(Screen.IMPORTING)
|
navigateTo(Screen.IMPORTING)
|
||||||
@@ -272,19 +426,38 @@ class AppState(
|
|||||||
// Post-import deletion
|
// Post-import deletion
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
fun deleteVerifiedFromDevice() {
|
fun deleteVerifiedFromDevice() {
|
||||||
|
deleteSpecificFromDevice(
|
||||||
|
_importResult.value?.results
|
||||||
|
?.filter { it.verified && it.exifVerified != false }
|
||||||
|
?.map { it.photo.devicePath }
|
||||||
|
?.toSet() ?: emptySet()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteSpecificFromDevice(devicePaths: Set<String>) {
|
||||||
val device = _selectedDevice.value ?: return
|
val device = _selectedDevice.value ?: return
|
||||||
val result = _importResult.value ?: return
|
val result = _importResult.value ?: return
|
||||||
val toDelete = result.results.filter { it.verified }.map { it.photo }
|
|
||||||
|
|
||||||
scope.launch(Dispatchers.IO) {
|
scope.launch(Dispatchers.IO) {
|
||||||
val connector = connectorFor(device)
|
val connector = connectorFor(device)
|
||||||
toDelete.forEach { photo ->
|
result.results
|
||||||
try {
|
.filter { it.photo.devicePath in devicePaths }
|
||||||
connector.deletePhoto(device, photo)
|
.forEach { r ->
|
||||||
} catch (e: DeviceException) {
|
try {
|
||||||
log.error("Delete failed for ${photo.filename}: ${e.message}")
|
connector.deletePhoto(device, r.photo)
|
||||||
|
if (r.photo.isLivePhotoStill && r.photo.companionDevicePath != null) {
|
||||||
|
val companion = r.photo.copy(
|
||||||
|
devicePath = r.photo.companionDevicePath,
|
||||||
|
filename = r.photo.companionDevicePath.substringAfterLast("/"),
|
||||||
|
)
|
||||||
|
try { connector.deletePhoto(device, companion) }
|
||||||
|
catch (e: DeviceException) {
|
||||||
|
log.warn("Companion delete failed for ${r.photo.filename}: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: DeviceException) {
|
||||||
|
log.error("Delete failed for ${r.photo.filename}: ${e.message}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,20 +469,23 @@ class AppState(
|
|||||||
saveConfig()
|
saveConfig()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun updateDefaultPreserveFolderStructure(value: Boolean) {
|
||||||
|
_config.update { it.copy(defaultPreserveFolderStructure = value) }
|
||||||
|
saveConfig()
|
||||||
|
}
|
||||||
|
|
||||||
fun dismissHeicWarning() {
|
fun dismissHeicWarning() {
|
||||||
_config.update { it.copy(heicWarningDismissed = true) }
|
_config.update { it.copy(heicWarningDismissed = true) }
|
||||||
saveConfig()
|
saveConfig()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun saveConfig() {
|
fun saveConfig() { configStore.save(_config.value) }
|
||||||
configStore.save(_config.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
private fun connectorFor(device: DeviceInfo): DeviceConnector = when (device.type) {
|
internal fun connectorFor(device: DeviceInfo): DeviceConnector = when (device.type) {
|
||||||
DeviceType.ANDROID -> AndroidConnector(_config.value.adbPath ?: "adb")
|
DeviceType.ANDROID -> AndroidConnector(_config.value.adbPath ?: "adb")
|
||||||
DeviceType.IOS -> IosConnector(_config.value.gphoto2Path ?: "gphoto2")
|
DeviceType.IOS -> IosConnector(_config.value.python3Path.takeIf { !it.isNullOrBlank() })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,51 +1,72 @@
|
|||||||
package com.bolenpad.photophetch.ui
|
package com.bolenpad.photophetch.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.lazy.grid.items
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.CheckCircle
|
|
||||||
import androidx.compose.material.icons.filled.DateRange
|
|
||||||
import androidx.compose.material.icons.filled.RadioButtonUnchecked
|
|
||||||
import androidx.compose.material.icons.filled.Warning
|
import androidx.compose.material.icons.filled.Warning
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
|
import androidx.compose.ui.graphics.toComposeImageBitmap
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.bolenpad.photophetch.model.DeviceInfo
|
import androidx.compose.ui.window.Dialog
|
||||||
import com.bolenpad.photophetch.model.MediaType
|
|
||||||
import com.bolenpad.photophetch.model.PhonePhoto
|
import com.bolenpad.photophetch.model.PhonePhoto
|
||||||
import java.time.LocalDate
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.jetbrains.skia.Image as SkiaImage
|
||||||
|
import java.nio.file.Path
|
||||||
|
import java.nio.file.Paths
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.format.DateTimeFormatter
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun BrowseScreen(state: AppState) {
|
fun BrowseScreen(state: AppState) {
|
||||||
val photoList by state.photoList.collectAsState()
|
val photoList by state.photoList.collectAsState()
|
||||||
val selected by state.selectedPhotos.collectAsState()
|
val selectedPhotos by state.selectedPhotos.collectAsState()
|
||||||
val device by state.selectedDevice.collectAsState()
|
val device by state.selectedDevice.collectAsState()
|
||||||
val config by state.config.collectAsState()
|
val config by state.config.collectAsState()
|
||||||
val dateFilter by state.dateFilter.collectAsState()
|
|
||||||
|
var previewPhoto by remember { mutableStateOf<PhonePhoto?>(null) }
|
||||||
|
|
||||||
|
// Preview dialog
|
||||||
|
previewPhoto?.let { photo ->
|
||||||
|
PhotoPreviewDialog(
|
||||||
|
photo = photo,
|
||||||
|
cacheKey = device?.let { state.connectorFor(it).cacheKey(it) } ?: "unknown",
|
||||||
|
pullFn = { devicePath, localPath ->
|
||||||
|
device?.let { d -> state.connectorFor(d).pullToFile(d, devicePath, localPath) } ?: false
|
||||||
|
},
|
||||||
|
onDismiss = { previewPhoto = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
// Top bar
|
var columnCount by remember { mutableStateOf(4) }
|
||||||
|
|
||||||
BrowseTopBar(
|
BrowseTopBar(
|
||||||
device = device,
|
deviceName = device?.displayName ?: "Browse Photos",
|
||||||
selected = selected,
|
selectedCount = selectedPhotos.size,
|
||||||
|
columnCount = columnCount,
|
||||||
onBack = { state.navigateTo(Screen.CONNECT) },
|
onBack = { state.navigateTo(Screen.CONNECT) },
|
||||||
onSelectAll = { state.selectAll() },
|
|
||||||
onSelectNone = { state.selectNone() },
|
onSelectNone = { state.selectNone() },
|
||||||
onImport = { state.prepareImport() },
|
onImport = { state.prepareImport() },
|
||||||
|
onColumnChange = { columnCount = (columnCount + it).coerceIn(2, 8) },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (state.shootingInHeic && !config.heicWarningDismissed) {
|
||||||
|
HeicWarningBanner(onDismiss = { state.dismissHeicWarning() })
|
||||||
|
}
|
||||||
|
|
||||||
when (val pl = photoList) {
|
when (val pl = photoList) {
|
||||||
is PhotoListState.Idle, is PhotoListState.Loading -> {
|
is PhotoListState.Idle, is PhotoListState.Loading -> {
|
||||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
@@ -57,52 +78,68 @@ fun BrowseScreen(state: AppState) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
is PhotoListState.Loaded -> {
|
is PhotoListState.Loaded -> {
|
||||||
// HEIC warning banner
|
val folderSections = buildFolderSections(pl)
|
||||||
if (pl.shootingInHeic && !config.heicWarningDismissed) {
|
val listState = rememberLazyListState()
|
||||||
HeicWarningBanner(
|
val scope = rememberCoroutineScope()
|
||||||
convertByDefault = config.defaultConvertHeicToJpeg,
|
|
||||||
onDismiss = { state.dismissHeicWarning() },
|
// Folder scroll index for jump list
|
||||||
)
|
val folderScrollIndex = remember(folderSections, columnCount) {
|
||||||
|
var idx = 0
|
||||||
|
folderSections.associate { section ->
|
||||||
|
val startIdx = idx
|
||||||
|
idx += 1 // folder header
|
||||||
|
idx += ((section.allPhotos.size + columnCount - 1) / columnCount)
|
||||||
|
section.folder.name to startIdx
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Date filter row
|
val activeFolder by remember(folderScrollIndex) {
|
||||||
DateFilterRow(
|
derivedStateOf {
|
||||||
from = dateFilter.first,
|
val firstVisible = listState.firstVisibleItemIndex
|
||||||
to = dateFilter.second,
|
folderScrollIndex.entries
|
||||||
onFilterChange = { from, to -> state.setDateFilter(from, to) },
|
.filter { it.value <= firstVisible }
|
||||||
)
|
.maxByOrNull { it.value }?.key
|
||||||
|
?: folderSections.firstOrNull()?.folder?.name
|
||||||
// Photo count summary
|
}
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"${pl.filtered.size} photos • ${selected.size} selected",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pl.filtered.isEmpty()) {
|
Row(modifier = Modifier.fillMaxSize()) {
|
||||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
FolderJumpList(
|
||||||
Text("No photos match the current filter", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
folders = pl.folders,
|
||||||
}
|
activeFolder = activeFolder,
|
||||||
} else {
|
selectedPhotos = selectedPhotos,
|
||||||
LazyVerticalGrid(
|
onJump = { name ->
|
||||||
columns = GridCells.Adaptive(minSize = 110.dp),
|
val idx = folderScrollIndex[name] ?: return@FolderJumpList
|
||||||
modifier = Modifier.fillMaxSize().padding(4.dp),
|
scope.launch { listState.animateScrollToItem(idx) }
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
},
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
modifier = Modifier.width(190.dp).fillMaxHeight(),
|
||||||
) {
|
)
|
||||||
items(pl.filtered, key = { it.devicePath }) { photo ->
|
|
||||||
PhotoCell(
|
VerticalDivider()
|
||||||
photo = photo,
|
|
||||||
isSelected = photo in selected,
|
val thumbnailVersion by state.thumbnailVersion.collectAsState()
|
||||||
onToggle = { state.togglePhotoSelection(photo) },
|
DensePhotoGrid(
|
||||||
)
|
folderSections = folderSections,
|
||||||
}
|
selectedPhotos = selectedPhotos,
|
||||||
}
|
cacheKey = device?.let { state.connectorFor(it).cacheKey(it) } ?: "unknown",
|
||||||
|
pullFn = { devicePath, localPath ->
|
||||||
|
device?.let { d ->
|
||||||
|
state.connectorFor(d).pullToFile(d, devicePath, localPath)
|
||||||
|
} ?: false
|
||||||
|
},
|
||||||
|
thumbnailVersion = thumbnailVersion,
|
||||||
|
columnCount = columnCount,
|
||||||
|
listState = listState,
|
||||||
|
onTogglePhoto = { state.togglePhotoSelection(it) },
|
||||||
|
onSelectAllFolder = { folder ->
|
||||||
|
folder.forEach { p -> if (p !in selectedPhotos) state.togglePhotoSelection(p) }
|
||||||
|
},
|
||||||
|
onDeselectAllFolder = { folder ->
|
||||||
|
folder.forEach { p -> if (p in selectedPhotos) state.togglePhotoSelection(p) }
|
||||||
|
},
|
||||||
|
onPreviewPhoto = { previewPhoto = it },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is PhotoListState.Error -> {
|
is PhotoListState.Error -> {
|
||||||
@@ -119,160 +156,284 @@ fun BrowseScreen(state: AppState) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Data helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun buildFolderSections(pl: PhotoListState.Loaded): List<FolderSection> {
|
||||||
|
val zoneId = ZoneId.systemDefault()
|
||||||
|
return pl.folders // already filtered by buildFolderList (no hidden folders)
|
||||||
|
.map { folder ->
|
||||||
|
val photos = pl.all
|
||||||
|
.filter { it.sourceFolder == folder.name }
|
||||||
|
.sortedByDescending { it.dateTaken }
|
||||||
|
val byDay = photos
|
||||||
|
.groupBy { it.dateTaken.atZone(zoneId).toLocalDate() }
|
||||||
|
.entries
|
||||||
|
.sortedByDescending { it.key }
|
||||||
|
.map { it.key to it.value.sortedByDescending { p -> p.dateTaken } }
|
||||||
|
FolderSection(folder, byDay, photos)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Folder jump list ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun FolderJumpList(
|
||||||
|
folders: List<FolderInfo>,
|
||||||
|
activeFolder: String?,
|
||||||
|
selectedPhotos: Set<PhonePhoto>,
|
||||||
|
onJump: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Surface(tonalElevation = 1.dp, modifier = modifier) {
|
||||||
|
androidx.compose.foundation.lazy.LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(vertical = 8.dp),
|
||||||
|
) {
|
||||||
|
val (cameraRoll, other) = folders.partition { it.isCameraRoll }
|
||||||
|
|
||||||
|
if (cameraRoll.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
Text("CAMERA", style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(start = 12.dp, top = 8.dp, bottom = 2.dp))
|
||||||
|
}
|
||||||
|
items(cameraRoll.size) { i ->
|
||||||
|
JumpRow(cameraRoll[i], cameraRoll[i].name == activeFolder,
|
||||||
|
selectedPhotos.count { it.sourceFolder == cameraRoll[i].name }) { onJump(cameraRoll[i].name) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (other.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
Text("OTHER", style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(start = 12.dp, top = 12.dp, bottom = 2.dp))
|
||||||
|
}
|
||||||
|
items(other.size) { i ->
|
||||||
|
JumpRow(other[i], other[i].name == activeFolder,
|
||||||
|
selectedPhotos.count { it.sourceFolder == other[i].name }) { onJump(other[i].name) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun JumpRow(folder: FolderInfo, isActive: Boolean, selectedCount: Int, onClick: () -> Unit) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick)
|
||||||
|
.background(if (isActive) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surface)
|
||||||
|
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(folder.name, style = MaterialTheme.typography.bodySmall,
|
||||||
|
fontWeight = if (isActive) FontWeight.SemiBold else FontWeight.Normal,
|
||||||
|
maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
Text("${folder.photoCount}", style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
if (selectedCount > 0) {
|
||||||
|
Badge(containerColor = MaterialTheme.colorScheme.primary) {
|
||||||
|
Text("$selectedCount", style = MaterialTheme.typography.labelSmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Folder divider header ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun FolderDividerHeader(
|
||||||
|
name: String,
|
||||||
|
total: Int,
|
||||||
|
selected: Int,
|
||||||
|
allSelected: Boolean,
|
||||||
|
onSelectAll: () -> Unit,
|
||||||
|
onDeselectAll: () -> Unit,
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
HorizontalDivider(thickness = 2.dp, modifier = Modifier.padding(top = 8.dp))
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||||
|
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Text(name, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold)
|
||||||
|
Text(
|
||||||
|
if (selected > 0) "$selected / $total selected" else "$total photos",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = if (selected > 0) MaterialTheme.colorScheme.primary
|
||||||
|
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
TextButton(onClick = onSelectAll, enabled = !allSelected,
|
||||||
|
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp)) {
|
||||||
|
Text("Select all", style = MaterialTheme.typography.labelMedium)
|
||||||
|
}
|
||||||
|
if (selected > 0) {
|
||||||
|
TextButton(onClick = onDeselectAll,
|
||||||
|
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp)) {
|
||||||
|
Text("Deselect all", style = MaterialTheme.typography.labelMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Top bar ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun BrowseTopBar(
|
private fun BrowseTopBar(
|
||||||
device: DeviceInfo?,
|
deviceName: String,
|
||||||
selected: Set<PhonePhoto>,
|
selectedCount: Int,
|
||||||
|
columnCount: Int,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onSelectAll: () -> Unit,
|
|
||||||
onSelectNone: () -> Unit,
|
onSelectNone: () -> Unit,
|
||||||
onImport: () -> Unit,
|
onImport: () -> Unit,
|
||||||
|
onColumnChange: (Int) -> Unit,
|
||||||
) {
|
) {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text(device?.displayName ?: "Browse Photos") },
|
title = { Text(deviceName) },
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = onBack) {
|
IconButton(onClick = onBack) {
|
||||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
TextButton(onClick = onSelectAll) { Text("All") }
|
IconButton(onClick = { onColumnChange(-1) }, enabled = columnCount > 2) {
|
||||||
TextButton(onClick = onSelectNone) { Text("None") }
|
Text("−", style = MaterialTheme.typography.titleMedium)
|
||||||
|
}
|
||||||
|
Text("$columnCount", style = MaterialTheme.typography.bodySmall,
|
||||||
|
modifier = Modifier.padding(horizontal = 2.dp))
|
||||||
|
IconButton(onClick = { onColumnChange(1) }, enabled = columnCount < 8) {
|
||||||
|
Text("+", style = MaterialTheme.typography.titleMedium)
|
||||||
|
}
|
||||||
|
if (selectedCount > 0) {
|
||||||
|
TextButton(onClick = onSelectNone) { Text("Clear") }
|
||||||
|
}
|
||||||
Button(
|
Button(
|
||||||
onClick = onImport,
|
onClick = onImport,
|
||||||
enabled = selected.isNotEmpty(),
|
enabled = selectedCount > 0,
|
||||||
modifier = Modifier.padding(end = 8.dp),
|
modifier = Modifier.padding(end = 8.dp),
|
||||||
) {
|
) {
|
||||||
Text("Import ${if (selected.isNotEmpty()) "(${selected.size})" else ""}")
|
Text(if (selectedCount > 0) "Import ($selectedCount)" else "Import")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun PhotoCell(
|
private fun HeicWarningBanner(onDismiss: () -> Unit) {
|
||||||
photo: PhonePhoto,
|
|
||||||
isSelected: Boolean,
|
|
||||||
onToggle: () -> Unit,
|
|
||||||
) {
|
|
||||||
val date = photo.dateTaken.atZone(ZoneId.systemDefault()).toLocalDate()
|
|
||||||
val dateStr = date.format(DateTimeFormatter.ofPattern("MMM d"))
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.aspectRatio(1f)
|
|
||||||
.clickable(onClick = onToggle)
|
|
||||||
.background(
|
|
||||||
if (isSelected) MaterialTheme.colorScheme.primaryContainer
|
|
||||||
else MaterialTheme.colorScheme.surfaceVariant
|
|
||||||
)
|
|
||||||
.then(
|
|
||||||
if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary)
|
|
||||||
else Modifier
|
|
||||||
)
|
|
||||||
.padding(4.dp),
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
verticalArrangement = Arrangement.SpaceBetween,
|
|
||||||
) {
|
|
||||||
// Type badge
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) {
|
|
||||||
if (photo.isHeic) {
|
|
||||||
Badge { Text("HEIC", style = MaterialTheme.typography.labelSmall) }
|
|
||||||
}
|
|
||||||
if (photo.mediaType == MediaType.VIDEO) {
|
|
||||||
Badge(containerColor = MaterialTheme.colorScheme.tertiary) {
|
|
||||||
Text("VID", style = MaterialTheme.typography.labelSmall)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (photo.isLivePhotoStill) {
|
|
||||||
Badge(containerColor = MaterialTheme.colorScheme.secondary) {
|
|
||||||
Text("LIVE", style = MaterialTheme.typography.labelSmall)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filename and date at bottom
|
|
||||||
Column {
|
|
||||||
Text(
|
|
||||||
photo.filename,
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
Text(dateStr, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Selection checkmark
|
|
||||||
Icon(
|
|
||||||
if (isSelected) Icons.Default.CheckCircle else Icons.Default.RadioButtonUnchecked,
|
|
||||||
contentDescription = if (isSelected) "Selected" else "Not selected",
|
|
||||||
tint = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline,
|
|
||||||
modifier = Modifier.align(Alignment.TopEnd).size(20.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun DateFilterRow(
|
|
||||||
from: LocalDate?,
|
|
||||||
to: LocalDate?,
|
|
||||||
onFilterChange: (from: LocalDate?, to: LocalDate?) -> Unit,
|
|
||||||
) {
|
|
||||||
// Simple quick-filter buttons: Last 7 days, Last 30 days, All
|
|
||||||
val today = LocalDate.now()
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Icon(Icons.Default.DateRange, contentDescription = null, modifier = Modifier.size(16.dp))
|
|
||||||
Text("Filter:", style = MaterialTheme.typography.labelMedium)
|
|
||||||
FilterChip(
|
|
||||||
selected = from == null && to == null,
|
|
||||||
onClick = { onFilterChange(null, null) },
|
|
||||||
label = { Text("All") },
|
|
||||||
)
|
|
||||||
FilterChip(
|
|
||||||
selected = from == today.minusDays(7) && to == null,
|
|
||||||
onClick = { onFilterChange(today.minusDays(7), null) },
|
|
||||||
label = { Text("Last 7 days") },
|
|
||||||
)
|
|
||||||
FilterChip(
|
|
||||||
selected = from == today.minusDays(30) && to == null,
|
|
||||||
onClick = { onFilterChange(today.minusDays(30), null) },
|
|
||||||
label = { Text("Last 30 days") },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun HeicWarningBanner(convertByDefault: Boolean, onDismiss: () -> Unit) {
|
|
||||||
Card(
|
Card(
|
||||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.tertiaryContainer),
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.tertiaryContainer),
|
||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(modifier = Modifier.padding(12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
modifier = Modifier.padding(12.dp),
|
verticalAlignment = Alignment.CenterVertically) {
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Icon(Icons.Default.Warning, contentDescription = null, modifier = Modifier.size(20.dp))
|
Icon(Icons.Default.Warning, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Text("Phone is shooting in HEIC", fontWeight = FontWeight.SemiBold, style = MaterialTheme.typography.bodySmall)
|
Text("Phone is shooting in HEIC", fontWeight = FontWeight.SemiBold,
|
||||||
Text(
|
style = MaterialTheme.typography.bodySmall)
|
||||||
if (convertByDefault)
|
Text("Files will be converted to JPEG. To avoid: Settings → Camera → Formats → Most Compatible.",
|
||||||
"Files will be converted to JPEG during import. To avoid this, go to Settings → Camera → Formats → Most Compatible on your iPhone."
|
|
||||||
else
|
|
||||||
"HEIC files will be kept as-is. PhotoPhile may not handle them on Linux. Consider Settings → Camera → Formats → Most Compatible.",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onTertiaryContainer,
|
color = MaterialTheme.colorScheme.onTertiaryContainer)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
TextButton(onClick = onDismiss) { Text("Dismiss") }
|
TextButton(onClick = onDismiss) { Text("Dismiss") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Photo preview dialog ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PhotoPreviewDialog(
|
||||||
|
photo: PhonePhoto,
|
||||||
|
cacheKey: String,
|
||||||
|
pullFn: suspend (devicePath: String, localDestPath: Path) -> Boolean,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
var bitmap by remember(photo.devicePath) { mutableStateOf<ImageBitmap?>(null) }
|
||||||
|
val cacheRoot = Paths.get(System.getProperty("user.home"), ".photophetch", "thumbcache")
|
||||||
|
val cacheDir = cacheRoot.resolve(cacheKey.replace("/", "_").replace(":", "_"))
|
||||||
|
val hash = (cacheKey + photo.devicePath).hashCode().toString(16)
|
||||||
|
val cachePath = cacheDir.resolve("$hash.jpg")
|
||||||
|
|
||||||
|
LaunchedEffect(photo.devicePath) {
|
||||||
|
val bytes = withContext(Dispatchers.IO) {
|
||||||
|
if (cachePath.toFile().exists() && cachePath.toFile().length() > 0) {
|
||||||
|
java.nio.file.Files.readAllBytes(cachePath)
|
||||||
|
} else {
|
||||||
|
val tmpDir = Paths.get(System.getProperty("java.io.tmpdir"), "photophetch-preview")
|
||||||
|
tmpDir.toFile().mkdirs()
|
||||||
|
val tmpFile = tmpDir.resolve("preview-${photo.filename}")
|
||||||
|
val ok = pullFn(photo.devicePath, tmpFile)
|
||||||
|
if (ok && tmpFile.toFile().exists()) {
|
||||||
|
val sips = ProcessBuilder("sips", "--resampleWidth", "800",
|
||||||
|
"--setProperty", "format", "jpeg",
|
||||||
|
tmpFile.toString(), "--out", cachePath.toString())
|
||||||
|
.redirectErrorStream(true).start()
|
||||||
|
sips.inputStream.readBytes()
|
||||||
|
sips.waitFor()
|
||||||
|
tmpFile.toFile().delete()
|
||||||
|
if (cachePath.toFile().exists()) java.nio.file.Files.readAllBytes(cachePath) else null
|
||||||
|
} else null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bytes != null) runCatching { bitmap = SkiaImage.makeFromEncoded(bytes).toComposeImageBitmap() }
|
||||||
|
}
|
||||||
|
|
||||||
|
Dialog(onDismissRequest = onDismiss) {
|
||||||
|
Card(modifier = Modifier.fillMaxWidth(0.85f)) {
|
||||||
|
Column(modifier = Modifier.padding(16.dp)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column {
|
||||||
|
Text(photo.filename, fontWeight = FontWeight.SemiBold,
|
||||||
|
style = MaterialTheme.typography.bodyMedium)
|
||||||
|
val date = photo.dateTaken.atZone(ZoneId.systemDefault())
|
||||||
|
Text("${date.toLocalDate()} ${date.toLocalTime().toString().take(5)}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
if (photo.isLivePhotoStill) {
|
||||||
|
Box(modifier = Modifier
|
||||||
|
.background(androidx.compose.ui.graphics.Color(0xFF00C853),
|
||||||
|
shape = MaterialTheme.shapes.extraSmall)
|
||||||
|
.padding(horizontal = 6.dp, vertical = 2.dp)) {
|
||||||
|
Text("LIVE", style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = androidx.compose.ui.graphics.Color.White,
|
||||||
|
fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(modifier = Modifier.fillMaxWidth().aspectRatio(4f / 3f),
|
||||||
|
contentAlignment = Alignment.Center) {
|
||||||
|
if (bitmap != null) {
|
||||||
|
androidx.compose.foundation.Image(bitmap = bitmap!!,
|
||||||
|
contentDescription = photo.filename,
|
||||||
|
contentScale = ContentScale.Fit, modifier = Modifier.fillMaxSize())
|
||||||
|
} else {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("${photo.sizeBytes / 1024} KB · ${photo.sourceFolder}",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
|
||||||
|
Button(onClick = onDismiss, modifier = Modifier.fillMaxWidth()) { Text("Close") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -46,6 +46,18 @@ fun ConnectScreen(state: AppState) {
|
|||||||
StagingNotConfiguredBanner(onConfigureClick = { state.navigateTo(Screen.SETTINGS) })
|
StagingNotConfiguredBanner(onConfigureClick = { state.navigateTo(Screen.SETTINGS) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// exiftool warning — checked once per session
|
||||||
|
val exiftoolMissing = remember {
|
||||||
|
try {
|
||||||
|
val p = ProcessBuilder("exiftool", "-ver").redirectErrorStream(true).start()
|
||||||
|
p.inputStream.readBytes()
|
||||||
|
p.waitFor() != 0
|
||||||
|
} catch (_: Exception) { true }
|
||||||
|
}
|
||||||
|
if (exiftoolMissing) {
|
||||||
|
ExiftoolMissingBanner()
|
||||||
|
}
|
||||||
|
|
||||||
// Device scan area
|
// Device scan area
|
||||||
when (val scan = scanState) {
|
when (val scan = scanState) {
|
||||||
is DeviceScanState.Idle -> {
|
is DeviceScanState.Idle -> {
|
||||||
@@ -200,3 +212,25 @@ private fun StagingNotConfiguredBanner(onConfigureClick: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ExiftoolMissingBanner() {
|
||||||
|
Card(
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.tertiaryContainer),
|
||||||
|
modifier = Modifier.width(400.dp),
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(12.dp)) {
|
||||||
|
Text(
|
||||||
|
"exiftool not installed",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold,
|
||||||
|
color = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"EXIF verification will be skipped during import. Install with: brew install exiftool",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
325
src/main/kotlin/com/bolenpad/photophetch/ui/DensePhotoGrid.kt
Normal file
325
src/main/kotlin/com/bolenpad/photophetch/ui/DensePhotoGrid.kt
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
package com.bolenpad.photophetch.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
|
import androidx.compose.material.icons.filled.Image
|
||||||
|
import androidx.compose.material.icons.filled.RadioButtonUnchecked
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
|
import androidx.compose.ui.graphics.toComposeImageBitmap
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.bolenpad.photophetch.model.MediaType
|
||||||
|
import com.bolenpad.photophetch.model.PhonePhoto
|
||||||
|
import com.bolenpad.photophetch.util.ThumbnailCache
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.jetbrains.skia.Image as SkiaImage
|
||||||
|
import java.nio.file.Path
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
private val thumbLog = org.slf4j.LoggerFactory.getLogger("DenseThumb")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dense grid: folder headers + flat photo grid per folder.
|
||||||
|
* Each photo that is the first of its day gets a small date label
|
||||||
|
* ("Jul 28") above its thumbnail only — within its column.
|
||||||
|
* Other photos in the same row get an invisible spacer so thumbnails stay aligned.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun DensePhotoGrid(
|
||||||
|
folderSections: List<FolderSection>,
|
||||||
|
selectedPhotos: Set<PhonePhoto>,
|
||||||
|
cacheKey: String,
|
||||||
|
pullFn: suspend (devicePath: String, localDestPath: Path) -> Boolean,
|
||||||
|
thumbnailVersion: Int = 0,
|
||||||
|
columnCount: Int,
|
||||||
|
listState: LazyListState,
|
||||||
|
onTogglePhoto: (PhonePhoto) -> Unit,
|
||||||
|
onSelectAllFolder: (List<PhonePhoto>) -> Unit,
|
||||||
|
onDeselectAllFolder: (List<PhonePhoto>) -> Unit,
|
||||||
|
onPreviewPhoto: ((PhonePhoto) -> Unit)? = null,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
// In-memory bitmap cache — survives cell recycling during scroll
|
||||||
|
// keyed on cacheKey so it resets when device changes
|
||||||
|
val bitmapCache = remember(cacheKey) {
|
||||||
|
mutableMapOf<String, ImageBitmap>()
|
||||||
|
}
|
||||||
|
if (folderSections.isEmpty() || folderSections.all { it.allPhotos.isEmpty() }) {
|
||||||
|
Box(modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.padding(32.dp),
|
||||||
|
) {
|
||||||
|
Text("No photos found", style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(
|
||||||
|
"• Make sure the iPhone screen is unlocked\n" +
|
||||||
|
"• If another phone is plugged in, switch it to \"Transferring files\" mode\n" +
|
||||||
|
"• Run: killall PTPCamera",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val zoneId = ZoneId.systemDefault()
|
||||||
|
val dayFmt = remember { DateTimeFormatter.ofPattern("MMM d") }
|
||||||
|
|
||||||
|
// Each photo carries a date label if it is the first of its day within its folder
|
||||||
|
data class LabeledPhoto(val photo: PhonePhoto, val dateLabel: String?)
|
||||||
|
data class PhotoRow(val photos: List<LabeledPhoto>)
|
||||||
|
data class Item(
|
||||||
|
val isFolderHeader: Boolean,
|
||||||
|
val folderSection: FolderSection? = null,
|
||||||
|
val photoRow: PhotoRow? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
val items: List<Item> = buildList {
|
||||||
|
folderSections.forEach { section ->
|
||||||
|
if (section.allPhotos.isEmpty()) return@forEach
|
||||||
|
add(Item(isFolderHeader = true, folderSection = section))
|
||||||
|
|
||||||
|
val sorted = section.allPhotos.sortedByDescending { it.dateTaken }
|
||||||
|
var lastDate: LocalDate? = null
|
||||||
|
|
||||||
|
val labeled: List<LabeledPhoto> = sorted.map { photo ->
|
||||||
|
val date = photo.dateTaken.atZone(zoneId).toLocalDate()
|
||||||
|
val label = if (date != lastDate) { lastDate = date; dayFmt.format(date) } else null
|
||||||
|
LabeledPhoto(photo, label)
|
||||||
|
}
|
||||||
|
|
||||||
|
labeled.chunked(columnCount).forEach { rowPhotos ->
|
||||||
|
add(Item(isFolderHeader = false, photoRow = PhotoRow(rowPhotos)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
|
modifier = modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(bottom = 32.dp),
|
||||||
|
) {
|
||||||
|
items(
|
||||||
|
count = items.size,
|
||||||
|
key = { idx ->
|
||||||
|
val item = items[idx]
|
||||||
|
if (item.isFolderHeader) "hdr-${item.folderSection!!.folder.name}"
|
||||||
|
else "row-${item.photoRow!!.photos.first().photo.devicePath}"
|
||||||
|
},
|
||||||
|
) { idx ->
|
||||||
|
val item = items[idx]
|
||||||
|
if (item.isFolderHeader) {
|
||||||
|
val section = item.folderSection!!
|
||||||
|
val folderSelected = section.allPhotos.count { it in selectedPhotos }
|
||||||
|
FolderDividerHeader(
|
||||||
|
name = section.folder.name,
|
||||||
|
total = section.allPhotos.size,
|
||||||
|
selected = folderSelected,
|
||||||
|
allSelected = folderSelected == section.allPhotos.size,
|
||||||
|
onSelectAll = { onSelectAllFolder(section.allPhotos) },
|
||||||
|
onDeselectAll = { onDeselectAllFolder(section.allPhotos) },
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val row = item.photoRow!!
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
|
verticalAlignment = Alignment.Bottom,
|
||||||
|
) {
|
||||||
|
row.photos.forEach { labeled ->
|
||||||
|
DenseThumb(
|
||||||
|
photo = labeled.photo,
|
||||||
|
isSelected = labeled.photo in selectedPhotos,
|
||||||
|
cacheKey = cacheKey,
|
||||||
|
pullFn = pullFn,
|
||||||
|
thumbnailVersion = thumbnailVersion,
|
||||||
|
bitmapCache = bitmapCache,
|
||||||
|
onToggle = { onTogglePhoto(labeled.photo) },
|
||||||
|
onPreview = onPreviewPhoto,
|
||||||
|
dateLabel = labeled.dateLabel,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
repeat(columnCount - row.photos.size) {
|
||||||
|
// Empty spacer — needs the same height as a thumb cell with label
|
||||||
|
Spacer(Modifier.weight(1f).aspectRatio(1f).padding(top = 16.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(2.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DenseThumb(
|
||||||
|
photo: PhonePhoto,
|
||||||
|
isSelected: Boolean,
|
||||||
|
cacheKey: String,
|
||||||
|
pullFn: suspend (devicePath: String, localDestPath: Path) -> Boolean,
|
||||||
|
thumbnailVersion: Int,
|
||||||
|
bitmapCache: MutableMap<String, ImageBitmap>,
|
||||||
|
onToggle: () -> Unit,
|
||||||
|
onPreview: ((PhonePhoto) -> Unit)?,
|
||||||
|
dateLabel: String?,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
// Check in-memory cache first (survives Compose cell recycling during scroll)
|
||||||
|
var thumbnail by remember(photo.devicePath) {
|
||||||
|
mutableStateOf(bitmapCache[photo.devicePath])
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(photo.devicePath, thumbnailVersion) {
|
||||||
|
if (thumbnail == null) {
|
||||||
|
val bytes = withContext(Dispatchers.IO) {
|
||||||
|
ThumbnailCache.get(
|
||||||
|
cacheKey = cacheKey,
|
||||||
|
devicePath = photo.devicePath,
|
||||||
|
filename = photo.filename,
|
||||||
|
pullFn = pullFn,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (bytes != null && bytes.isNotEmpty()) {
|
||||||
|
runCatching {
|
||||||
|
val bitmap = SkiaImage.makeFromEncoded(bytes).toComposeImageBitmap()
|
||||||
|
bitmapCache[photo.devicePath] = bitmap // store in memory cache
|
||||||
|
thumbnail = bitmap
|
||||||
|
}.onFailure { e ->
|
||||||
|
thumbLog.warn("Decode failed for ${photo.filename} (${bytes.size} bytes): ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(modifier = modifier) {
|
||||||
|
if (dateLabel != null) {
|
||||||
|
// Date label with trailing rule — sits above this specific column
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 6.dp, bottom = 2.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
dateLabel,
|
||||||
|
fontSize = 10.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
HorizontalDivider(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
thickness = 0.5.dp,
|
||||||
|
color = MaterialTheme.colorScheme.outlineVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// No spacer for cells without a label — rows collapse tightly.
|
||||||
|
// Alignment within a row is handled by the Row using Alignment.Bottom
|
||||||
|
// so thumbnails sit flush even when some cells have a label header.
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.aspectRatio(1f)
|
||||||
|
.clickable(onClick = onToggle)
|
||||||
|
.then(if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary) else Modifier),
|
||||||
|
) {
|
||||||
|
if (thumbnail != null) {
|
||||||
|
Image(bitmap = thumbnail!!, contentDescription = null,
|
||||||
|
contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize())
|
||||||
|
} else {
|
||||||
|
Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surfaceVariant),
|
||||||
|
contentAlignment = Alignment.Center) {
|
||||||
|
Icon(Icons.Default.Image, contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.outline, modifier = Modifier.size(20.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Top-right: high-contrast selection indicator
|
||||||
|
// White circle with drop shadow so it's visible on both light and dark thumbnails
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.padding(4.dp)
|
||||||
|
.size(20.dp)
|
||||||
|
.background(
|
||||||
|
if (isSelected) MaterialTheme.colorScheme.primary
|
||||||
|
else Color.White.copy(alpha = 0.9f),
|
||||||
|
shape = androidx.compose.foundation.shape.CircleShape,
|
||||||
|
)
|
||||||
|
.border(
|
||||||
|
width = 1.5.dp,
|
||||||
|
color = if (isSelected) MaterialTheme.colorScheme.primary
|
||||||
|
else Color.Black.copy(alpha = 0.3f),
|
||||||
|
shape = androidx.compose.foundation.shape.CircleShape,
|
||||||
|
)
|
||||||
|
.clickable(onClick = onToggle),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
if (isSelected) {
|
||||||
|
Icon(Icons.Default.CheckCircle, contentDescription = null,
|
||||||
|
tint = Color.White, modifier = Modifier.size(18.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bottom-left: LIVE badge or video size badge
|
||||||
|
if (photo.isLivePhotoStill) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomStart)
|
||||||
|
.padding(3.dp)
|
||||||
|
.background(Color(0xFF00C853), shape = MaterialTheme.shapes.extraSmall)
|
||||||
|
.padding(horizontal = 4.dp, vertical = 1.dp),
|
||||||
|
) {
|
||||||
|
Text("LIVE", fontSize = 9.sp, fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.White)
|
||||||
|
}
|
||||||
|
} else if (photo.mediaType == MediaType.VIDEO) {
|
||||||
|
val sizeLabel = if (photo.sizeBytes > 1024 * 1024)
|
||||||
|
"▶ ${photo.sizeBytes / (1024 * 1024)}MB"
|
||||||
|
else if (photo.sizeBytes > 0) "▶ ${photo.sizeBytes / 1024}KB"
|
||||||
|
else "▶"
|
||||||
|
Badge(containerColor = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.85f),
|
||||||
|
modifier = Modifier.align(Alignment.BottomStart).padding(2.dp)) {
|
||||||
|
Text(sizeLabel, style = MaterialTheme.typography.labelSmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bottom-right: + preview button
|
||||||
|
if (onPreview != null) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomEnd)
|
||||||
|
.padding(2.dp)
|
||||||
|
.size(20.dp)
|
||||||
|
.background(
|
||||||
|
MaterialTheme.colorScheme.surface.copy(alpha = 0.75f),
|
||||||
|
shape = MaterialTheme.shapes.extraSmall,
|
||||||
|
)
|
||||||
|
.clickable { onPreview(photo) },
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text("+", style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/main/kotlin/com/bolenpad/photophetch/ui/GridModels.kt
Normal file
14
src/main/kotlin/com/bolenpad/photophetch/ui/GridModels.kt
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
package com.bolenpad.photophetch.ui
|
||||||
|
|
||||||
|
import com.bolenpad.photophetch.model.PhonePhoto
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
/** A folder's photos grouped by day */
|
||||||
|
data class FolderSection(
|
||||||
|
val folder: FolderInfo,
|
||||||
|
val byDay: List<Pair<LocalDate, List<PhonePhoto>>>,
|
||||||
|
val allPhotos: List<PhonePhoto>,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Item types used in DensePhotoGrid flat list */
|
||||||
|
enum class GridItemType { FOLDER_HEADER, PHOTO_ROW }
|
||||||
@@ -1,22 +1,22 @@
|
|||||||
package com.bolenpad.photophetch.ui
|
package com.bolenpad.photophetch.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.FolderOpen
|
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.drawWithContent
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.bolenpad.photophetch.util.StagingPath
|
import com.bolenpad.photophetch.util.StagingPath
|
||||||
import java.nio.file.Paths
|
import java.nio.file.Paths
|
||||||
|
|
||||||
/**
|
|
||||||
* Import configuration screen — shown after photo selection, before starting the copy.
|
|
||||||
* User confirms destination folder name, conversion settings, and delete-after-verify preference.
|
|
||||||
*/
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ImportScreen(state: AppState) {
|
fun ImportScreen(state: AppState) {
|
||||||
val importConfig by state.importConfig.collectAsState()
|
val importConfig by state.importConfig.collectAsState()
|
||||||
@@ -24,131 +24,190 @@ fun ImportScreen(state: AppState) {
|
|||||||
val selected by state.selectedPhotos.collectAsState()
|
val selected by state.selectedPhotos.collectAsState()
|
||||||
val device by state.selectedDevice.collectAsState()
|
val device by state.selectedDevice.collectAsState()
|
||||||
|
|
||||||
// Validate staging root
|
|
||||||
val stagingError = if (config.stagingRoot.isNotBlank()) {
|
val stagingError = if (config.stagingRoot.isNotBlank()) {
|
||||||
StagingPath.validateStagingRoot(Paths.get(config.stagingRoot))
|
StagingPath.validateStagingRoot(Paths.get(config.stagingRoot))
|
||||||
} else "Staging directory not configured"
|
} else "Staging directory not configured"
|
||||||
|
|
||||||
Column(
|
val canImport = stagingError == null && importConfig.folderName.isNotBlank() && selected.isNotEmpty()
|
||||||
modifier = Modifier.fillMaxSize().padding(32.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
// Outer column: header + scrollable body + pinned button
|
||||||
) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
// Header
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
// ── Header (always visible) ──────────────────────────────────────────
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(start = 4.dp, top = 8.dp, end = 16.dp, bottom = 4.dp),
|
||||||
|
) {
|
||||||
IconButton(onClick = { state.navigateTo(Screen.BROWSE) }) {
|
IconButton(onClick = { state.navigateTo(Screen.BROWSE) }) {
|
||||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||||
}
|
}
|
||||||
Text("Import Configuration", style = MaterialTheme.typography.headlineSmall)
|
Text("Import Configuration", style = MaterialTheme.typography.headlineSmall)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Summary card
|
HorizontalDivider()
|
||||||
Card {
|
|
||||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
// ── Scrollable body with bottom shadow indicator ─────────────────────
|
||||||
Text("${selected.size} files selected", fontWeight = FontWeight.SemiBold)
|
val scrollState = rememberScrollState()
|
||||||
device?.let {
|
// shadowAlpha: 1.0 when far from bottom, fades to 0.0 over the last 80px
|
||||||
Text("From: ${it.displayName}", style = MaterialTheme.typography.bodySmall)
|
val shadowAlpha by remember {
|
||||||
}
|
derivedStateOf {
|
||||||
|
val remaining = scrollState.maxValue - scrollState.value
|
||||||
|
(remaining / 80f).coerceIn(0f, 1f)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
HorizontalDivider()
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
// Destination folder name
|
.weight(1f)
|
||||||
Text("Destination Folder", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
|
.drawWithContent {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
drawContent()
|
||||||
Text(
|
if (shadowAlpha > 0f) {
|
||||||
"${config.stagingRoot}/",
|
drawRect(
|
||||||
style = MaterialTheme.typography.bodySmall,
|
brush = Brush.verticalGradient(
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
colors = listOf(
|
||||||
)
|
Color.Transparent,
|
||||||
OutlinedTextField(
|
Color.Black.copy(alpha = 0.10f * shadowAlpha),
|
||||||
value = importConfig.folderName,
|
),
|
||||||
onValueChange = { state.updateImportConfig { copy(folderName = it) } },
|
startY = size.height - 24.dp.toPx(),
|
||||||
label = { Text("Folder name") },
|
endY = size.height,
|
||||||
modifier = Modifier.weight(1f),
|
)
|
||||||
singleLine = true,
|
)
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
if (stagingError != null) {
|
|
||||||
Text(stagingError, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
|
|
||||||
}
|
|
||||||
|
|
||||||
HorizontalDivider()
|
|
||||||
|
|
||||||
// Conversion options
|
|
||||||
Text("Conversion", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(
|
||||||
Text("Convert HEIC → JPEG")
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(scrollState)
|
||||||
|
.padding(horizontal = 32.dp, vertical = 20.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||||
|
) {
|
||||||
|
// Summary card
|
||||||
|
Card {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
Text("${selected.size} files selected", fontWeight = FontWeight.SemiBold)
|
||||||
|
device?.let {
|
||||||
|
Text("From: ${it.displayName}", style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
// Show source folders breakdown if multi-folder selection
|
||||||
|
val folders = selected.map { it.sourceFolder }.distinct().sorted()
|
||||||
|
if (folders.size > 1) {
|
||||||
|
Text(
|
||||||
|
"Folders: ${folders.joinToString(", ")}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider()
|
||||||
|
|
||||||
|
// Destination
|
||||||
|
Text("Destination Folder", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
Text(
|
Text(
|
||||||
"Recommended for PhotoPhile on Linux. Uses macOS sips (built-in).",
|
"${config.stagingRoot}/",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = importConfig.folderName,
|
||||||
|
onValueChange = { state.updateImportConfig { copy(folderName = it) } },
|
||||||
|
label = { Text("Folder name") },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
Switch(
|
if (stagingError != null) {
|
||||||
|
Text(
|
||||||
|
stagingError,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider()
|
||||||
|
|
||||||
|
// Options
|
||||||
|
Text("Options", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
|
||||||
|
|
||||||
|
ToggleRow(
|
||||||
|
label = "Convert HEIC → JPEG",
|
||||||
|
description = "Recommended for PhotoPhile on Linux. Uses macOS sips (built-in).",
|
||||||
checked = importConfig.convertHeicToJpeg,
|
checked = importConfig.convertHeicToJpeg,
|
||||||
onCheckedChange = { state.updateImportConfig { copy(convertHeicToJpeg = it) } },
|
onCheckedChange = { state.updateImportConfig { copy(convertHeicToJpeg = it) } },
|
||||||
)
|
)
|
||||||
}
|
ToggleRow(
|
||||||
|
label = "Import Live Photo motion clips (.MOV)",
|
||||||
Row(
|
description = "Include the .MOV companion alongside each Live Photo still. Paired by filename match. Default off.",
|
||||||
modifier = Modifier.fillMaxWidth(),
|
checked = importConfig.importLivePhotoCompanions,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
onCheckedChange = { state.updateImportConfig { copy(importLivePhotoCompanions = it) } },
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text("Strip Live Photo motion clips")
|
|
||||||
Text(
|
|
||||||
"Excludes .MOV companions from Live Photos. You want JPEGs, not motion clips.",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Switch(
|
|
||||||
checked = importConfig.stripLivePhotoMotion,
|
|
||||||
onCheckedChange = { state.updateImportConfig { copy(stripLivePhotoMotion = it) } },
|
|
||||||
)
|
)
|
||||||
}
|
ToggleRow(
|
||||||
|
label = "Preserve folder structure",
|
||||||
HorizontalDivider()
|
description = "Files go into subfolders matching their source (Camera/, Screenshots/, etc.).",
|
||||||
|
checked = importConfig.preserveFolderStructure,
|
||||||
// Delete after verify
|
onCheckedChange = {
|
||||||
Text("After Import", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
|
state.updateImportConfig { copy(preserveFolderStructure = it) }
|
||||||
|
state.updateDefaultPreserveFolderStructure(it)
|
||||||
Row(
|
},
|
||||||
modifier = Modifier.fillMaxWidth(),
|
)
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
ToggleRow(
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
label = "Delete from phone after verification",
|
||||||
) {
|
description = "Only deletes files that passed SHA-256 check. You'll confirm on the next screen.",
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text("Delete from phone after verification")
|
|
||||||
Text(
|
|
||||||
"Only deletes files that passed SHA-256 verification. You'll confirm on the next screen.",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Switch(
|
|
||||||
checked = importConfig.deleteAfterVerify,
|
checked = importConfig.deleteAfterVerify,
|
||||||
onCheckedChange = { state.updateImportConfig { copy(deleteAfterVerify = it) } },
|
onCheckedChange = { state.updateImportConfig { copy(deleteAfterVerify = it) } },
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(Modifier.weight(1f))
|
// Extra bottom padding so last item isn't flush against the button
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
} // end scroll Column
|
||||||
|
} // end Box
|
||||||
|
|
||||||
// Start import button
|
// ── Pinned bottom button ─────────────────────────────────────────────
|
||||||
Button(
|
HorizontalDivider()
|
||||||
onClick = { state.startImport() },
|
Box(modifier = Modifier.padding(horizontal = 32.dp, vertical = 16.dp)) {
|
||||||
enabled = stagingError == null && importConfig.folderName.isNotBlank() && selected.isNotEmpty(),
|
Button(
|
||||||
modifier = Modifier.fillMaxWidth().height(52.dp),
|
onClick = { state.startImport() },
|
||||||
) {
|
enabled = canImport,
|
||||||
Text("Start Import (${selected.size} files)", style = MaterialTheme.typography.titleMedium)
|
modifier = Modifier.fillMaxWidth().height(52.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Start Import (${selected.size} files)",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ToggleRow(
|
||||||
|
label: String,
|
||||||
|
description: String,
|
||||||
|
checked: Boolean,
|
||||||
|
onCheckedChange: (Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f).padding(end = 8.dp)) {
|
||||||
|
Text(label)
|
||||||
|
Text(
|
||||||
|
description,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Switch(checked = checked, onCheckedChange = onCheckedChange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,12 +3,16 @@ package com.bolenpad.photophetch.ui
|
|||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.FolderOpen
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import java.awt.FileDialog
|
||||||
|
import java.awt.Frame
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsScreen(state: AppState) {
|
fun SettingsScreen(state: AppState) {
|
||||||
@@ -33,14 +37,30 @@ fun SettingsScreen(state: AppState) {
|
|||||||
|
|
||||||
// Staging directory
|
// Staging directory
|
||||||
Text("Staging Directory", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
|
Text("Staging Directory", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
|
||||||
OutlinedTextField(
|
Row(
|
||||||
value = stagingRoot,
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
onValueChange = { stagingRoot = it },
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
label = { Text("Path to _staging/ directory") },
|
) {
|
||||||
placeholder = { Text("/Volumes/delphi/_staging") },
|
OutlinedTextField(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
value = stagingRoot,
|
||||||
singleLine = true,
|
onValueChange = { stagingRoot = it },
|
||||||
)
|
label = { Text("Path to _staging/ directory") },
|
||||||
|
placeholder = { Text("/Volumes/delphi/_staging") },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
OutlinedIconButton(
|
||||||
|
onClick = {
|
||||||
|
val chosen = pickFolder(
|
||||||
|
title = "Select _staging directory",
|
||||||
|
initialDir = stagingRoot.ifBlank { "/Volumes" },
|
||||||
|
)
|
||||||
|
if (chosen != null) stagingRoot = chosen
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.FolderOpen, contentDescription = "Browse…")
|
||||||
|
}
|
||||||
|
}
|
||||||
Text(
|
Text(
|
||||||
"This should be the _staging directory on your NAS (must be mounted).",
|
"This should be the _staging directory on your NAS (must be mounted).",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
@@ -78,7 +98,6 @@ fun SettingsScreen(state: AppState) {
|
|||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
state.updateStagingRoot(stagingRoot)
|
state.updateStagingRoot(stagingRoot)
|
||||||
// Also save template update
|
|
||||||
state.saveConfig()
|
state.saveConfig()
|
||||||
},
|
},
|
||||||
modifier = Modifier.fillMaxWidth().height(52.dp),
|
modifier = Modifier.fillMaxWidth().height(52.dp),
|
||||||
@@ -87,3 +106,24 @@ fun SettingsScreen(state: AppState) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the native macOS folder picker (System.setProperty trick to make
|
||||||
|
* AWT FileDialog show directories instead of files).
|
||||||
|
* Returns the chosen absolute path, or null if cancelled.
|
||||||
|
*/
|
||||||
|
private fun pickFolder(title: String, initialDir: String): String? {
|
||||||
|
// On macOS, this system property switches FileDialog to directory-selection mode
|
||||||
|
System.setProperty("apple.awt.fileDialogForDirectories", "true")
|
||||||
|
return try {
|
||||||
|
val dialog = FileDialog(null as Frame?, title, FileDialog.LOAD).apply {
|
||||||
|
directory = initialDir
|
||||||
|
isVisible = true
|
||||||
|
}
|
||||||
|
val dir = dialog.directory
|
||||||
|
val file = dialog.file
|
||||||
|
if (file != null) File(dir, file).absolutePath else null
|
||||||
|
} finally {
|
||||||
|
System.setProperty("apple.awt.fileDialogForDirectories", "false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,51 +1,61 @@
|
|||||||
package com.bolenpad.photophetch.ui
|
package com.bolenpad.photophetch.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.CheckCircle
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.Error
|
import androidx.compose.material.icons.filled.Error
|
||||||
import androidx.compose.material.icons.filled.Home
|
import androidx.compose.material.icons.filled.Home
|
||||||
|
import androidx.compose.material.icons.filled.Warning
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.drawWithContent
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
|
import androidx.compose.ui.graphics.toComposeImageBitmap
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
import com.bolenpad.photophetch.model.CopyResult
|
import com.bolenpad.photophetch.model.CopyResult
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.jetbrains.skia.Image as SkiaImage
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.Paths
|
||||||
|
|
||||||
|
private enum class VerifyFilter { ALL, OK, UNVERIFIED, FAILED }
|
||||||
|
|
||||||
/**
|
|
||||||
* Shown during active import (IMPORTING screen) and after import completes (VERIFY screen).
|
|
||||||
*/
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ImportingScreen(state: AppState) {
|
fun ImportingScreen(state: AppState) {
|
||||||
val progress by state.importProgress.collectAsState()
|
val progress by state.importProgress.collectAsState()
|
||||||
|
Column(modifier = Modifier.fillMaxSize().padding(32.dp),
|
||||||
Column(
|
|
||||||
modifier = Modifier.fillMaxSize().padding(32.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center) {
|
||||||
) {
|
|
||||||
Text("Importing…", style = MaterialTheme.typography.headlineSmall)
|
Text("Importing…", style = MaterialTheme.typography.headlineSmall)
|
||||||
Spacer(Modifier.height(32.dp))
|
Spacer(Modifier.height(32.dp))
|
||||||
|
|
||||||
val p = progress
|
val p = progress
|
||||||
if (p != null && p.total > 0) {
|
if (p != null && p.total > 0) {
|
||||||
val fraction = p.completed.toFloat() / p.total
|
val fraction = p.completed.toFloat() / p.total
|
||||||
LinearProgressIndicator(progress = { fraction }, modifier = Modifier.fillMaxWidth(0.6f))
|
LinearProgressIndicator(progress = { fraction }, modifier = Modifier.fillMaxWidth(0.6f))
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
Text("${p.completed} / ${p.total} files")
|
Text("${p.completed} / ${p.total} files")
|
||||||
if (p.currentFile.isNotBlank()) {
|
if (p.currentFile.isNotBlank())
|
||||||
Text(
|
Text(p.currentFile, style = MaterialTheme.typography.bodySmall,
|
||||||
p.currentFile,
|
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
style = MaterialTheme.typography.bodySmall,
|
} else CircularProgressIndicator()
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
CircularProgressIndicator()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,166 +63,344 @@ fun ImportingScreen(state: AppState) {
|
|||||||
fun VerifyScreen(state: AppState) {
|
fun VerifyScreen(state: AppState) {
|
||||||
val result by state.importResult.collectAsState()
|
val result by state.importResult.collectAsState()
|
||||||
val importConfig by state.importConfig.collectAsState()
|
val importConfig by state.importConfig.collectAsState()
|
||||||
var deleteConfirmShown by remember { mutableStateOf(false) }
|
var previewResult by remember { mutableStateOf<CopyResult?>(null) }
|
||||||
var deleteDone by remember { mutableStateOf(false) }
|
var activeFilter by remember { mutableStateOf(VerifyFilter.ALL) }
|
||||||
|
|
||||||
val r = result ?: return
|
val r = result ?: return
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize().padding(24.dp)) {
|
// Build initial delete set: verified + EXIF-confirmed only
|
||||||
// Summary header
|
val deletableResults = r.results.filter { it.verified && it.error == null }
|
||||||
ResultSummaryHeader(
|
val initiallyChecked = deletableResults
|
||||||
success = r.successCount,
|
.filter { it.exifVerified != false }
|
||||||
failure = r.failureCount,
|
.map { it.photo.devicePath }.toSet()
|
||||||
destination = "${r.job.stagingRoot}/${r.job.folderName}",
|
var toDelete by remember(r) { mutableStateOf(initiallyChecked) }
|
||||||
)
|
var softRemoved by remember { mutableStateOf(setOf<String>()) }
|
||||||
|
|
||||||
Spacer(Modifier.height(16.dp))
|
previewResult?.let { pr ->
|
||||||
|
ResultPreviewDialog(result = pr, onDismiss = { previewResult = null })
|
||||||
|
}
|
||||||
|
|
||||||
// File-by-file results
|
Column(modifier = Modifier.fillMaxSize().padding(horizontal = 20.dp, vertical = 16.dp)) {
|
||||||
Text("Results", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
LazyColumn(
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
|
||||||
) {
|
|
||||||
items(r.results, key = { it.photo.devicePath }) { copyResult ->
|
|
||||||
CopyResultRow(copyResult)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(Modifier.height(16.dp))
|
// ── Summary — counts exclude soft-removed files ───────────────────
|
||||||
HorizontalDivider()
|
val activeResults = r.results.filter { it.photo.devicePath !in softRemoved }
|
||||||
Spacer(Modifier.height(12.dp))
|
val failCount = activeResults.count { it.error != null || !it.verified }
|
||||||
|
val unverifiedCount = activeResults.count { it.exifVerified == false }
|
||||||
|
val okCount = activeResults.count { it.verified && it.error == null && it.exifVerified != false }
|
||||||
|
val removedCount = softRemoved.size
|
||||||
|
|
||||||
// Actions
|
Card(colors = CardDefaults.cardColors(
|
||||||
if (importConfig.deleteAfterVerify && r.successCount > 0 && !deleteDone) {
|
containerColor = if (failCount == 0) MaterialTheme.colorScheme.primaryContainer
|
||||||
if (deleteConfirmShown) {
|
else MaterialTheme.colorScheme.errorContainer)) {
|
||||||
DeleteConfirmCard(
|
Row(modifier = Modifier.fillMaxWidth().padding(12.dp),
|
||||||
count = r.successCount,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
onConfirm = {
|
verticalAlignment = Alignment.CenterVertically) {
|
||||||
state.deleteVerifiedFromDevice()
|
Column {
|
||||||
deleteDone = true
|
Text(if (failCount == 0) "Import complete ✓" else "Import complete with errors",
|
||||||
deleteConfirmShown = false
|
fontWeight = FontWeight.SemiBold)
|
||||||
},
|
Text("→ ${r.job.stagingRoot}/${r.job.folderName}",
|
||||||
onCancel = { deleteConfirmShown = false },
|
style = MaterialTheme.typography.bodySmall,
|
||||||
)
|
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
} else {
|
}
|
||||||
OutlinedButton(
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
onClick = { deleteConfirmShown = true },
|
if (failCount > 0) StatusChip("$failCount failed", MaterialTheme.colorScheme.error)
|
||||||
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
|
if (unverifiedCount > 0) StatusChip("$unverifiedCount ⚠", MaterialTheme.colorScheme.tertiary)
|
||||||
modifier = Modifier.fillMaxWidth(),
|
StatusChip("$okCount ✓", MaterialTheme.colorScheme.primary)
|
||||||
) {
|
if (removedCount > 0) StatusChip("$removedCount removed", MaterialTheme.colorScheme.outline)
|
||||||
Text("Delete ${r.successCount} verified files from phone")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (deleteDone) {
|
|
||||||
Text(
|
|
||||||
"✓ ${r.successCount} files deleted from phone",
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// ── Filter bar ─────────────────────────────────────────────────────
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
VerifyFilter.entries.forEach { filter ->
|
||||||
|
val label = when (filter) {
|
||||||
|
VerifyFilter.ALL -> "All (${activeResults.size})"
|
||||||
|
VerifyFilter.OK -> "✓ OK ($okCount)"
|
||||||
|
VerifyFilter.UNVERIFIED -> "⚠ Unverified ($unverifiedCount)"
|
||||||
|
VerifyFilter.FAILED -> "✗ Failed ($failCount)"
|
||||||
|
}
|
||||||
|
FilterChip(selected = activeFilter == filter, onClick = { activeFilter = filter },
|
||||||
|
label = { Text(label, style = MaterialTheme.typography.labelSmall) })
|
||||||
|
}
|
||||||
|
if (removedCount > 0) {
|
||||||
|
FilterChip(selected = false, onClick = { /* show removed inline */ },
|
||||||
|
label = { Text("$removedCount removed — tap to undo all",
|
||||||
|
style = MaterialTheme.typography.labelSmall) },
|
||||||
|
trailingIcon = {
|
||||||
|
IconButton(onClick = { softRemoved = emptySet() },
|
||||||
|
modifier = Modifier.size(16.dp)) {
|
||||||
|
Icon(Icons.Default.Close, contentDescription = "Undo all removals",
|
||||||
|
modifier = Modifier.size(12.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
|
||||||
|
// ── Results list ───────────────────────────────────────────────────
|
||||||
|
val filteredResults = r.results.filter { cr ->
|
||||||
|
when (activeFilter) {
|
||||||
|
VerifyFilter.ALL -> true // show all including soft-removed (they'll be greyed)
|
||||||
|
VerifyFilter.OK -> cr.verified && cr.error == null && cr.exifVerified != false && cr.photo.devicePath !in softRemoved
|
||||||
|
VerifyFilter.UNVERIFIED -> cr.exifVerified == false && cr.photo.devicePath !in softRemoved
|
||||||
|
VerifyFilter.FAILED -> (cr.error != null || !cr.verified) && cr.photo.devicePath !in softRemoved
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
val topAlpha by remember { derivedStateOf {
|
||||||
|
((listState.firstVisibleItemScrollOffset + listState.firstVisibleItemIndex * 80) / 60f).coerceIn(0f, 1f)
|
||||||
|
}}
|
||||||
|
val bottomAlpha by remember { derivedStateOf {
|
||||||
|
val info = listState.layoutInfo
|
||||||
|
val last = info.visibleItemsInfo.lastOrNull()
|
||||||
|
if (last == null || last.index >= info.totalItemsCount - 1) 0f else 1f
|
||||||
|
}}
|
||||||
|
|
||||||
|
if (topAlpha > 0f) Box(Modifier.fillMaxWidth().height(16.dp).drawWithContent {
|
||||||
|
drawContent()
|
||||||
|
drawRect(brush = Brush.verticalGradient(listOf(Color.Black.copy(0.08f * topAlpha), Color.Transparent)))
|
||||||
|
})
|
||||||
|
|
||||||
|
LazyColumn(state = listState, modifier = Modifier.weight(1f),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
|
items(filteredResults, key = { it.photo.devicePath }) { cr ->
|
||||||
|
val isSoftRemoved = cr.photo.devicePath in softRemoved
|
||||||
|
VerifyResultRow(
|
||||||
|
result = cr,
|
||||||
|
showCheckbox = importConfig.deleteAfterVerify && !isSoftRemoved,
|
||||||
|
isChecked = cr.photo.devicePath in toDelete,
|
||||||
|
canCheck = cr.verified && cr.error == null,
|
||||||
|
isSoftRemoved = isSoftRemoved,
|
||||||
|
onToggleCheck = {
|
||||||
|
toDelete = if (cr.photo.devicePath in toDelete)
|
||||||
|
toDelete - cr.photo.devicePath
|
||||||
|
else toDelete + cr.photo.devicePath
|
||||||
|
},
|
||||||
|
onPreview = { previewResult = it },
|
||||||
|
onToggleRemove = {
|
||||||
|
if (isSoftRemoved) {
|
||||||
|
softRemoved = softRemoved - cr.photo.devicePath
|
||||||
|
} else {
|
||||||
|
softRemoved = softRemoved + cr.photo.devicePath
|
||||||
|
toDelete = toDelete - cr.photo.devicePath
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bottomAlpha > 0f) Box(Modifier.fillMaxWidth().height(16.dp).drawWithContent {
|
||||||
|
drawContent()
|
||||||
|
drawRect(brush = Brush.verticalGradient(listOf(Color.Transparent, Color.Black.copy(0.08f * bottomAlpha))))
|
||||||
|
})
|
||||||
|
|
||||||
|
HorizontalDivider()
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// ── Commit button ──────────────────────────────────────────────────
|
||||||
|
val hasPendingPhoneDeletes = importConfig.deleteAfterVerify && toDelete.isNotEmpty()
|
||||||
|
val hasPendingStagingCleanup = softRemoved.isNotEmpty()
|
||||||
|
|
||||||
|
val doneLabel = when {
|
||||||
|
hasPendingPhoneDeletes && hasPendingStagingCleanup ->
|
||||||
|
"Confirm: delete ${toDelete.size} from phone + remove ${softRemoved.size} from staging"
|
||||||
|
hasPendingPhoneDeletes ->
|
||||||
|
"Confirm: delete ${toDelete.size} files from phone"
|
||||||
|
hasPendingStagingCleanup ->
|
||||||
|
"Confirm: remove ${softRemoved.size} files from staging"
|
||||||
|
else -> "Done — Back to Start"
|
||||||
|
}
|
||||||
|
|
||||||
|
val doneColor = if (hasPendingPhoneDeletes || hasPendingStagingCleanup)
|
||||||
|
ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)
|
||||||
|
else ButtonDefaults.buttonColors()
|
||||||
|
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
|
// 1. Delete phone files (if delete-after-verify was on and user confirmed via checkbox)
|
||||||
|
if (hasPendingPhoneDeletes) {
|
||||||
|
state.deleteSpecificFromDevice(toDelete)
|
||||||
|
}
|
||||||
|
// 2. Delete soft-removed files from staging
|
||||||
|
if (hasPendingStagingCleanup) {
|
||||||
|
r.results.filter { it.photo.devicePath in softRemoved }.forEach { cr ->
|
||||||
|
try { cr.destinationPath.toFile().delete() } catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
state.navigateTo(Screen.CONNECT)
|
state.navigateTo(Screen.CONNECT)
|
||||||
state.selectNone()
|
state.selectNone()
|
||||||
},
|
},
|
||||||
modifier = Modifier.fillMaxWidth(),
|
colors = doneColor,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
|
||||||
) {
|
) {
|
||||||
Icon(Icons.Default.Home, contentDescription = null, modifier = Modifier.size(18.dp))
|
Icon(Icons.Default.Home, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
Text("Done — Back to Start")
|
Text(doneLabel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ResultSummaryHeader(success: Int, failure: Int, destination: String) {
|
private fun StatusChip(label: String, color: Color) {
|
||||||
Card(
|
Surface(shape = MaterialTheme.shapes.small,
|
||||||
colors = CardDefaults.cardColors(
|
color = color.copy(alpha = 0.15f),
|
||||||
containerColor = if (failure == 0)
|
contentColor = color) {
|
||||||
MaterialTheme.colorScheme.primaryContainer
|
Text(label, modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
else
|
style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.SemiBold)
|
||||||
MaterialTheme.colorScheme.errorContainer,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
|
||||||
Text(
|
|
||||||
if (failure == 0) "Import complete ✓" else "Import complete with errors",
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
)
|
|
||||||
Text("$success verified, $failure failed")
|
|
||||||
Text(
|
|
||||||
"→ $destination",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun CopyResultRow(result: CopyResult) {
|
private fun VerifyResultRow(
|
||||||
Row(
|
result: CopyResult,
|
||||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
showCheckbox: Boolean,
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
isChecked: Boolean,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
canCheck: Boolean,
|
||||||
) {
|
isSoftRemoved: Boolean,
|
||||||
Icon(
|
onToggleCheck: () -> Unit,
|
||||||
if (result.verified && result.error == null) Icons.Default.CheckCircle else Icons.Default.Error,
|
onPreview: (CopyResult) -> Unit,
|
||||||
contentDescription = null,
|
onToggleRemove: () -> Unit,
|
||||||
tint = if (result.verified && result.error == null)
|
) {
|
||||||
MaterialTheme.colorScheme.primary
|
val isFailed = result.error != null || !result.verified
|
||||||
else
|
val exifUnverified = result.exifVerified == false
|
||||||
MaterialTheme.colorScheme.error,
|
val exifUnavailable = result.exifVerified == null
|
||||||
modifier = Modifier.size(18.dp),
|
|
||||||
)
|
val rowBg = when {
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
isSoftRemoved -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
||||||
val displayName = if (result.convertedFromHeic) {
|
isFailed -> MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f)
|
||||||
"${result.photo.filename} → ${result.destinationPath.fileName}"
|
exifUnverified -> MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.3f)
|
||||||
} else {
|
else -> Color.Transparent
|
||||||
result.photo.filename
|
}
|
||||||
|
|
||||||
|
Box(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
.background(rowBg)
|
||||||
|
.padding(vertical = 4.dp, horizontal = 4.dp)
|
||||||
|
.then(if (isSoftRemoved) Modifier.graphicsLayer { alpha = 0.4f } else Modifier),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
ThumbnailChip(destinationPath = result.destinationPath.toString(),
|
||||||
|
onClick = { if (!isSoftRemoved) onPreview(result) })
|
||||||
|
|
||||||
|
if (showCheckbox && canCheck) {
|
||||||
|
Checkbox(checked = isChecked, onCheckedChange = { onToggleCheck() },
|
||||||
|
modifier = Modifier.size(20.dp))
|
||||||
|
} else if (showCheckbox) {
|
||||||
|
Spacer(Modifier.width(20.dp))
|
||||||
}
|
}
|
||||||
Text(displayName, style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Medium)
|
|
||||||
if (result.error != null) {
|
val icon = when { isFailed -> Icons.Default.Error; exifUnverified -> Icons.Default.Warning; else -> Icons.Default.CheckCircle }
|
||||||
Text(result.error, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.error)
|
val iconTint = when { isFailed -> MaterialTheme.colorScheme.error; exifUnverified -> MaterialTheme.colorScheme.tertiary; else -> MaterialTheme.colorScheme.primary }
|
||||||
} else {
|
Icon(icon, contentDescription = null, tint = iconTint, modifier = Modifier.size(16.dp))
|
||||||
|
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
val displayName = if (result.convertedFromHeic)
|
||||||
|
"${result.photo.filename} → ${result.destinationPath.fileName}"
|
||||||
|
else result.photo.filename
|
||||||
|
Text(displayName, style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Medium,
|
||||||
|
maxLines = 1, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis)
|
||||||
Text(
|
Text(
|
||||||
"${result.photo.sizeBytes / 1024} KB • SHA-256: ${result.destSha256.take(12)}…",
|
when {
|
||||||
|
isFailed -> result.error ?: "Failed"
|
||||||
|
exifUnverified -> "⚠ Origin unknown" +
|
||||||
|
if (showCheckbox) " — kept on phone" else ""
|
||||||
|
exifUnavailable -> "${result.photo.sizeBytes / 1024} KB"
|
||||||
|
else -> "✓ ${result.exifMake} · ${result.photo.sizeBytes / 1024} KB"
|
||||||
|
},
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = when { isFailed -> MaterialTheme.colorScheme.error; exifUnverified -> MaterialTheme.colorScheme.tertiary; else -> MaterialTheme.colorScheme.onSurfaceVariant },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
IconButton(onClick = onToggleRemove, modifier = Modifier.size(28.dp)) {
|
||||||
|
Icon(Icons.Default.Close, contentDescription = if (isSoftRemoved) "Undo remove" else "Remove from import",
|
||||||
|
tint = if (isSoftRemoved) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(16.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Removed" overlay label when soft-removed
|
||||||
|
if (isSoftRemoved) {
|
||||||
|
Box(modifier = Modifier.matchParentSize(), contentAlignment = Alignment.Center) {
|
||||||
|
Surface(shape = MaterialTheme.shapes.small,
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onSurfaceVariant) {
|
||||||
|
Text("Removed — tap ✕ to undo",
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
fontWeight = FontWeight.Medium)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DeleteConfirmCard(count: Int, onConfirm: () -> Unit, onCancel: () -> Unit) {
|
private fun ThumbnailChip(destinationPath: String, onClick: () -> Unit) {
|
||||||
Card(
|
var bitmap by remember(destinationPath) { mutableStateOf<ImageBitmap?>(null) }
|
||||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer),
|
LaunchedEffect(destinationPath) {
|
||||||
modifier = Modifier.fillMaxWidth(),
|
val bytes = withContext(Dispatchers.IO) {
|
||||||
|
try { Files.readAllBytes(Paths.get(destinationPath)) } catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
if (bytes != null) runCatching { bitmap = SkiaImage.makeFromEncoded(bytes).toComposeImageBitmap() }
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.size(44.dp).clip(MaterialTheme.shapes.small)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||||
|
.clickable(onClick = onClick),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
if (bitmap != null) {
|
||||||
Text(
|
Image(bitmap = bitmap!!, contentDescription = null,
|
||||||
"Delete $count files from phone?",
|
contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize())
|
||||||
fontWeight = FontWeight.SemiBold,
|
} else {
|
||||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
Text("👁", style = MaterialTheme.typography.labelSmall)
|
||||||
)
|
}
|
||||||
Text(
|
}
|
||||||
"This is permanent. Only files that passed SHA-256 verification will be deleted.",
|
}
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
@Composable
|
||||||
)
|
private fun ResultPreviewDialog(result: CopyResult, onDismiss: () -> Unit) {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
var bitmap by remember(result.destinationPath) { mutableStateOf<ImageBitmap?>(null) }
|
||||||
Button(
|
LaunchedEffect(result.destinationPath) {
|
||||||
onClick = onConfirm,
|
val bytes = withContext(Dispatchers.IO) {
|
||||||
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error),
|
try { Files.readAllBytes(result.destinationPath) } catch (_: Exception) { null }
|
||||||
) { Text("Delete from Phone") }
|
}
|
||||||
OutlinedButton(onClick = onCancel) { Text("Cancel") }
|
if (bytes != null) runCatching { bitmap = SkiaImage.makeFromEncoded(bytes).toComposeImageBitmap() }
|
||||||
|
}
|
||||||
|
Dialog(onDismissRequest = onDismiss) {
|
||||||
|
Card(modifier = Modifier.fillMaxWidth(0.9f)) {
|
||||||
|
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text(result.photo.filename, fontWeight = FontWeight.SemiBold)
|
||||||
|
Text(when (result.exifVerified) {
|
||||||
|
true -> "✓ Camera original — Make: ${result.exifMake}"
|
||||||
|
false -> "⚠ Origin unknown — may not be your photo${if (!result.exifMake.isNullOrBlank()) " (Make: ${result.exifMake})" else ""}"
|
||||||
|
null -> "EXIF not checked (install exiftool)"
|
||||||
|
}, style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = when (result.exifVerified) {
|
||||||
|
true -> MaterialTheme.colorScheme.primary
|
||||||
|
false -> MaterialTheme.colorScheme.tertiary
|
||||||
|
null -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
})
|
||||||
|
HorizontalDivider()
|
||||||
|
Box(modifier = Modifier.fillMaxWidth().aspectRatio(4f / 3f),
|
||||||
|
contentAlignment = Alignment.Center) {
|
||||||
|
if (bitmap != null) Image(bitmap = bitmap!!, contentDescription = null,
|
||||||
|
contentScale = ContentScale.Fit, modifier = Modifier.fillMaxSize())
|
||||||
|
else CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
Text("${result.photo.sizeBytes / 1024} KB · ${result.destinationPath}",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
Button(onClick = onDismiss, modifier = Modifier.fillMaxWidth()) { Text("Close") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,19 +25,34 @@ object ExifFilter {
|
|||||||
private val excludedPathFragments = listOf(
|
private val excludedPathFragments = listOf(
|
||||||
"screenshot",
|
"screenshot",
|
||||||
"screen_record",
|
"screen_record",
|
||||||
|
"screen recording",
|
||||||
|
"screenrecord",
|
||||||
|
"videocapture",
|
||||||
|
"video editor",
|
||||||
|
"video_editor",
|
||||||
"whatsapp",
|
"whatsapp",
|
||||||
"telegram",
|
"telegram",
|
||||||
"signal",
|
"signal",
|
||||||
"viber",
|
"viber",
|
||||||
"received",
|
"received",
|
||||||
"download",
|
|
||||||
"snapchat",
|
"snapchat",
|
||||||
"instagram",
|
"instagram",
|
||||||
"twitter",
|
"twitter",
|
||||||
"facebook",
|
"facebook",
|
||||||
"tiktok",
|
"tiktok",
|
||||||
|
"slack",
|
||||||
|
"teams",
|
||||||
|
"zoom",
|
||||||
|
"discord",
|
||||||
"share",
|
"share",
|
||||||
"temp",
|
"temp",
|
||||||
|
"resize",
|
||||||
|
"sticker",
|
||||||
|
"gif",
|
||||||
|
"avatar",
|
||||||
|
"profile",
|
||||||
|
"thumbnail",
|
||||||
|
"cache",
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -84,6 +99,29 @@ object ExifFilter {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if a folder name is likely a camera-roll source.
|
||||||
|
* Handles both Android path-based names and iOS classified group names.
|
||||||
|
*/
|
||||||
|
fun isCameraRollFolder(folderName: String): Boolean {
|
||||||
|
// iPhone classified groups (from iOS helper classify_iphone_file)
|
||||||
|
if (folderName == "Camera" || folderName == "Edited") return true
|
||||||
|
if (folderName in setOf("Screenshots", "Screen Recordings", "Videos", "Live Photo", "Other")) return false
|
||||||
|
|
||||||
|
// Android: path-based exclusion
|
||||||
|
val lower = folderName.lowercase()
|
||||||
|
return excludedPathFragments.none { lower.contains(it) } &&
|
||||||
|
screenshotFilePrefixes.none { lower.startsWith(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Folders that should be completely hidden from the UI (not even in Other).
|
||||||
|
* Live Photo companions are proven by the LIVE badge on the HEIC — showing
|
||||||
|
* the MOVs separately adds noise with unverifiable placeholder thumbnails.
|
||||||
|
*/
|
||||||
|
fun isHiddenFolder(folderName: String): Boolean =
|
||||||
|
folderName == "Live Photo"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns photos that are HEIC files (and not Live Photo motion clips).
|
* Returns photos that are HEIC files (and not Live Photo motion clips).
|
||||||
* Used to determine whether to show the HEIC conversion warning.
|
* Used to determine whether to show the HEIC conversion warning.
|
||||||
|
|||||||
@@ -49,6 +49,26 @@ object StagingPath {
|
|||||||
return stagingRoot.resolve(folderName)
|
return stagingRoot.resolve(folderName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a folder name that doesn't already exist under [stagingRoot].
|
||||||
|
* If the base name is free, returns it as-is.
|
||||||
|
* If it already exists, appends _2, _3, … until a free name is found.
|
||||||
|
*
|
||||||
|
* Examples:
|
||||||
|
* "2026-07-28_iPhone15Pro" → free → returns as-is
|
||||||
|
* "2026-07-28_iPhone15Pro" taken → returns "2026-07-28_iPhone15Pro_2"
|
||||||
|
* "2026-07-28_iPhone15Pro_2" taken → returns "2026-07-28_iPhone15Pro_3"
|
||||||
|
*/
|
||||||
|
fun uniqueFolderName(stagingRoot: Path, baseName: String): String {
|
||||||
|
if (!stagingRoot.resolve(baseName).exists()) return baseName
|
||||||
|
var counter = 2
|
||||||
|
while (true) {
|
||||||
|
val candidate = "${baseName}_$counter"
|
||||||
|
if (!stagingRoot.resolve(candidate).exists()) return candidate
|
||||||
|
counter++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create the staging directory if it doesn't already exist.
|
* Create the staging directory if it doesn't already exist.
|
||||||
* @throws IllegalStateException if the parent is not writable
|
* @throws IllegalStateException if the parent is not writable
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package com.bolenpad.photophetch.util
|
||||||
|
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.Path
|
||||||
|
import java.nio.file.Paths
|
||||||
|
import kotlin.io.path.createDirectories
|
||||||
|
import kotlin.io.path.exists
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pulls and caches JPEG thumbnails from a connected phone.
|
||||||
|
*
|
||||||
|
* Device-agnostic: caller provides a [pullFn] lambda that pulls a remote file
|
||||||
|
* to a local destination path. Works for both Android (adb pull) and
|
||||||
|
* iOS (pymobiledevice3 AFC pull).
|
||||||
|
*
|
||||||
|
* Cache: ~/.photophetch/thumbcache/<cacheKey>/<hash>.jpg
|
||||||
|
*/
|
||||||
|
object ThumbnailCache {
|
||||||
|
private val log = LoggerFactory.getLogger(ThumbnailCache::class.java)
|
||||||
|
private val cacheRoot: Path = Paths.get(System.getProperty("user.home"), ".photophetch", "thumbcache")
|
||||||
|
private val tmpDir: Path = Paths.get(System.getProperty("java.io.tmpdir"), "photophetch-thumbs")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns raw JPEG bytes for the thumbnail, pulling from device if not cached.
|
||||||
|
* Returns null for videos or on any error.
|
||||||
|
*
|
||||||
|
* @param cacheKey Unique key for the cache directory (e.g. adb serial or device UDID)
|
||||||
|
* @param devicePath Remote path on the device
|
||||||
|
* @param filename Local filename (used to determine extension and media type)
|
||||||
|
* @param pullFn Lambda that pulls [devicePath] to [localDestPath]. Returns true on success.
|
||||||
|
*/
|
||||||
|
suspend fun get(
|
||||||
|
cacheKey: String,
|
||||||
|
devicePath: String,
|
||||||
|
filename: String,
|
||||||
|
pullFn: suspend (devicePath: String, localDestPath: Path) -> Boolean,
|
||||||
|
): ByteArray? {
|
||||||
|
val ext = filename.substringAfterLast(".", "").lowercase()
|
||||||
|
if (ext in setOf("mp4", "mov", "3gp", "avi", "mkv", "m4v", "webm", "mpeg", "mpg")) return null
|
||||||
|
|
||||||
|
val cacheDir = cacheRoot.resolve(cacheKey.replace("/", "_").replace(":", "_"))
|
||||||
|
if (!cacheDir.exists()) cacheDir.createDirectories()
|
||||||
|
if (!tmpDir.exists()) tmpDir.createDirectories()
|
||||||
|
|
||||||
|
val hash = (cacheKey + devicePath).hashCode().toString(16)
|
||||||
|
val cachePath = cacheDir.resolve("$hash.jpg")
|
||||||
|
|
||||||
|
if (cachePath.exists() && cachePath.toFile().length() > 0) {
|
||||||
|
return try { Files.readAllBytes(cachePath) } catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
return try {
|
||||||
|
val tmpFile = tmpDir.resolve("$hash.$ext")
|
||||||
|
|
||||||
|
val success = pullFn(devicePath, tmpFile)
|
||||||
|
if (!success || !tmpFile.exists() || tmpFile.toFile().length() == 0L) return null
|
||||||
|
|
||||||
|
// Resize to thumbnail via sips — also converts HEIC→JPEG
|
||||||
|
val sips = ProcessBuilder(
|
||||||
|
"sips",
|
||||||
|
"--resampleWidth", "240",
|
||||||
|
"--setProperty", "format", "jpeg",
|
||||||
|
tmpFile.toString(),
|
||||||
|
"--out", cachePath.toString()
|
||||||
|
).redirectErrorStream(true).start()
|
||||||
|
sips.inputStream.readBytes()
|
||||||
|
sips.waitFor()
|
||||||
|
|
||||||
|
tmpFile.toFile().delete()
|
||||||
|
|
||||||
|
if (cachePath.exists() && cachePath.toFile().length() > 0) {
|
||||||
|
Files.readAllBytes(cachePath)
|
||||||
|
} else null
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log.debug("Thumbnail generation failed for $filename: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearForKey(cacheKey: String) {
|
||||||
|
val sanitized = cacheKey.replace("/", "_").replace(":", "_")
|
||||||
|
cacheRoot.resolve(sanitized).toFile().deleteRecursively()
|
||||||
|
}
|
||||||
|
}
|
||||||
34
src/main/resources/logback.xml
Normal file
34
src/main/resources/logback.xml
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<configuration>
|
||||||
|
|
||||||
|
<!-- Console output for development (./gradlew run) -->
|
||||||
|
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
|
||||||
|
<encoder>
|
||||||
|
<pattern>%d{HH:mm:ss} [%-5level] %logger{20} - %msg%n</pattern>
|
||||||
|
</encoder>
|
||||||
|
</appender>
|
||||||
|
|
||||||
|
<!-- Rolling file output for packaged app -->
|
||||||
|
<!-- Writes to ~/Library/Logs/PhotoPhetch/ on macOS (standard location) -->
|
||||||
|
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||||
|
<file>${user.home}/Library/Logs/PhotoPhetch/photophetch.log</file>
|
||||||
|
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
|
||||||
|
<!-- Keep 7 days of logs, max 10MB per file -->
|
||||||
|
<fileNamePattern>${user.home}/Library/Logs/PhotoPhetch/photophetch.%d{yyyy-MM-dd}.log</fileNamePattern>
|
||||||
|
<maxHistory>7</maxHistory>
|
||||||
|
<totalSizeCap>50MB</totalSizeCap>
|
||||||
|
</rollingPolicy>
|
||||||
|
<encoder>
|
||||||
|
<pattern>%d{yyyy-MM-dd HH:mm:ss} [%-5level] %logger{30} - %msg%n</pattern>
|
||||||
|
</encoder>
|
||||||
|
</appender>
|
||||||
|
|
||||||
|
<!-- photophetch package: DEBUG in dev, INFO when packaged -->
|
||||||
|
<logger name="com.bolenpad.photophetch" level="DEBUG" />
|
||||||
|
|
||||||
|
<!-- Always write to file; console only in dev -->
|
||||||
|
<root level="WARN">
|
||||||
|
<appender-ref ref="CONSOLE" />
|
||||||
|
<appender-ref ref="FILE" />
|
||||||
|
</root>
|
||||||
|
|
||||||
|
</configuration>
|
||||||
313
src/main/resources/scripts/photophetch-ios-helper.py
Normal file
313
src/main/resources/scripts/photophetch-ios-helper.py
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
photophetch-ios-helper.py
|
||||||
|
|
||||||
|
Async wrapper around pymobiledevice3 AFC for PhotoPhetch iOS connectivity.
|
||||||
|
Called by IosConnector as a subprocess.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 photophetch-ios-helper.py list-devices
|
||||||
|
python3 photophetch-ios-helper.py list-photos
|
||||||
|
python3 photophetch-ios-helper.py pull /DCIM/148APPLE/IMG_001.HEIC /tmp/dest.heic
|
||||||
|
python3 photophetch-ios-helper.py delete /DCIM/148APPLE/IMG_001.HEIC
|
||||||
|
|
||||||
|
Output is JSON on stdout. Errors go to stderr with exit code 1.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import asyncio
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
MEDIA_EXTENSIONS = {
|
||||||
|
'jpg', 'jpeg', 'heic', 'heif', 'avif', 'webp', 'png',
|
||||||
|
'dng', 'raw', 'arw', 'cr2', 'cr3', 'nef', 'orf', 'rw2',
|
||||||
|
'tiff', 'tif', 'bmp',
|
||||||
|
'mp4', 'm4v', 'mov', 'mpeg', 'mpg', '3gp', '3g2', 'avi', 'mkv', 'webm',
|
||||||
|
}
|
||||||
|
|
||||||
|
VIDEO_EXTENSIONS = {
|
||||||
|
'mp4', 'm4v', 'mov', 'mpeg', 'mpg', '3gp', '3g2', 'avi', 'mkv', 'webm',
|
||||||
|
}
|
||||||
|
|
||||||
|
# iPhone camera originals: IMG_XXXX.{HEIC,JPG,MOV} — exactly 4 digits
|
||||||
|
CAMERA_ORIGINAL_RE = re.compile(r'^IMG_\d{4}\.(HEIC|HEIF|JPG|JPEG|MOV)$', re.IGNORECASE)
|
||||||
|
# Edited versions: IMG_EXXXX — edited in Photos.app
|
||||||
|
CAMERA_EDITED_RE = re.compile(r'^IMG_E\d{4}\.(HEIC|HEIF|JPG|JPEG)$', re.IGNORECASE)
|
||||||
|
# Screenshots: PNG files (iPhone camera never produces PNG)
|
||||||
|
SCREENSHOT_RE = re.compile(r'.*\.PNG$', re.IGNORECASE)
|
||||||
|
# Screen recordings
|
||||||
|
SCREEN_RECORDING_RE = re.compile(r'^RPReplay_Final.*\.mp4$', re.IGNORECASE)
|
||||||
|
|
||||||
|
def classify_iphone_file(filename: str, all_filenames: set = None, size_bytes: int = 0) -> str:
|
||||||
|
"""
|
||||||
|
Returns a group name for display in the folder panel.
|
||||||
|
Groups: 'Camera', 'Edited', 'Live Photo', 'Screenshots', 'Videos', 'Other'
|
||||||
|
|
||||||
|
all_filenames: set of all filenames in the same DCIM folder (for pairing detection).
|
||||||
|
size_bytes: used to distinguish Live Photo clips (<5MB) from real videos.
|
||||||
|
"""
|
||||||
|
ext = filename.rsplit('.', 1)[-1].lower() if '.' in filename else ''
|
||||||
|
if SCREEN_RECORDING_RE.match(filename):
|
||||||
|
return 'Screen Recordings'
|
||||||
|
if SCREENSHOT_RE.match(filename):
|
||||||
|
return 'Screenshots'
|
||||||
|
if CAMERA_ORIGINAL_RE.match(filename):
|
||||||
|
if ext in ('mov',):
|
||||||
|
# Check if this is a Live Photo companion
|
||||||
|
base = filename.rsplit('.', 1)[0] # IMG_XXXX
|
||||||
|
is_live_companion = False
|
||||||
|
if all_filenames is not None:
|
||||||
|
# Paired HEIC/JPEG exists with same base name → Live Photo companion
|
||||||
|
for still_ext in ('HEIC', 'HEIF', 'JPG', 'JPEG'):
|
||||||
|
if f'{base}.{still_ext}' in all_filenames:
|
||||||
|
is_live_companion = True
|
||||||
|
break
|
||||||
|
elif size_bytes > 0 and size_bytes < 5 * 1024 * 1024:
|
||||||
|
# No pairing info but very small MOV → likely Live Photo
|
||||||
|
is_live_companion = True
|
||||||
|
return 'Live Photo' if is_live_companion else 'Videos'
|
||||||
|
return 'Camera'
|
||||||
|
if CAMERA_EDITED_RE.match(filename):
|
||||||
|
return 'Edited'
|
||||||
|
if ext in VIDEO_EXTENSIONS:
|
||||||
|
return 'Videos'
|
||||||
|
return 'Other'
|
||||||
|
|
||||||
|
|
||||||
|
async def get_lockdown():
|
||||||
|
from pymobiledevice3.lockdown import create_using_usbmux
|
||||||
|
return await create_using_usbmux()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_afc(lockdown):
|
||||||
|
from pymobiledevice3.services.afc import AfcService
|
||||||
|
return AfcService(lockdown)
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_list_devices():
|
||||||
|
try:
|
||||||
|
lockdown = await get_lockdown()
|
||||||
|
result = [{
|
||||||
|
'udid': lockdown.udid,
|
||||||
|
'name': lockdown.display_name or lockdown.udid,
|
||||||
|
}]
|
||||||
|
print(json.dumps(result))
|
||||||
|
except Exception as e:
|
||||||
|
print(f'Error: {e}', file=sys.stderr)
|
||||||
|
# Return empty list rather than failing — device not connected
|
||||||
|
print(json.dumps([]))
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_list_photos():
|
||||||
|
try:
|
||||||
|
lockdown = await get_lockdown()
|
||||||
|
afc = await get_afc(lockdown)
|
||||||
|
|
||||||
|
try:
|
||||||
|
dcim_entries = await afc.listdir('/DCIM')
|
||||||
|
except Exception as e:
|
||||||
|
print(f'Error listing DCIM: {e}', file=sys.stderr)
|
||||||
|
print(json.dumps([]))
|
||||||
|
return
|
||||||
|
|
||||||
|
photos = []
|
||||||
|
debug_logged = False # log raw stat for first file to help debug date issues
|
||||||
|
|
||||||
|
for folder in sorted(dcim_entries):
|
||||||
|
folder_path = f'/DCIM/{folder}'
|
||||||
|
try:
|
||||||
|
entries = await afc.listdir(folder_path)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Build a set of all filenames in this folder for Live Photo pairing
|
||||||
|
all_filenames_in_folder = set(entries)
|
||||||
|
|
||||||
|
# Pre-compute Live Photo pairs: IMG_XXXX.MOV + IMG_XXXX.HEIC/JPG
|
||||||
|
# Key: base name (IMG_XXXX) -> MOV device path
|
||||||
|
live_photo_mov_paths = {}
|
||||||
|
for fname in entries:
|
||||||
|
if re.match(r'^IMG_\d{4}\.MOV$', fname, re.IGNORECASE):
|
||||||
|
base = fname.rsplit('.', 1)[0]
|
||||||
|
for still_ext in ('HEIC', 'HEIF', 'JPG', 'JPEG'):
|
||||||
|
if f'{base}.{still_ext}' in all_filenames_in_folder:
|
||||||
|
# Confirm it's small enough to be a Live Photo clip
|
||||||
|
# (we'll verify size when we hit the MOV entry)
|
||||||
|
live_photo_mov_paths[base] = f'{folder_path}/{fname}'
|
||||||
|
break
|
||||||
|
|
||||||
|
for filename in entries:
|
||||||
|
ext = filename.rsplit('.', 1)[-1].lower() if '.' in filename else ''
|
||||||
|
if ext not in MEDIA_EXTENSIONS:
|
||||||
|
continue
|
||||||
|
|
||||||
|
device_path = f'{folder_path}/{filename}'
|
||||||
|
base = filename.rsplit('.', 1)[0]
|
||||||
|
|
||||||
|
# Determine Live Photo status
|
||||||
|
is_live_motion = False
|
||||||
|
companion_device_path = None
|
||||||
|
|
||||||
|
if re.match(r'^IMG_\d{4}\.MOV$', filename, re.IGNORECASE):
|
||||||
|
if base in live_photo_mov_paths:
|
||||||
|
# This MOV is a companion — mark it but still include it
|
||||||
|
# (AppState will filter it from display)
|
||||||
|
is_live_motion = True
|
||||||
|
|
||||||
|
elif re.match(r'^IMG_\d{4}\.(HEIC|HEIF|JPG|JPEG)$', filename, re.IGNORECASE):
|
||||||
|
if base in live_photo_mov_paths:
|
||||||
|
companion_device_path = live_photo_mov_paths[base]
|
||||||
|
|
||||||
|
# Get file metadata via stat
|
||||||
|
size_bytes = 0
|
||||||
|
date_iso = None
|
||||||
|
try:
|
||||||
|
info = await afc.stat(device_path)
|
||||||
|
if not debug_logged:
|
||||||
|
print(f'DEBUG stat: {dict(info)}', file=sys.stderr)
|
||||||
|
debug_logged = True
|
||||||
|
size_bytes = int(info.get('st_size', 0))
|
||||||
|
# st_mtime is a datetime.datetime object in this version
|
||||||
|
dt = info.get('st_mtime') or info.get('datetime') or info.get('st_birthtime')
|
||||||
|
if dt is not None:
|
||||||
|
if hasattr(dt, 'isoformat'):
|
||||||
|
date_iso = dt.isoformat()
|
||||||
|
elif isinstance(dt, (list, tuple)) and len(dt) >= 6:
|
||||||
|
date_iso = datetime(int(dt[0]), int(dt[1]), int(dt[2]),
|
||||||
|
int(dt[3]), int(dt[4]), int(dt[5])).isoformat()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
photos.append({
|
||||||
|
'devicePath': device_path,
|
||||||
|
'filename': filename,
|
||||||
|
'sizeBytes': size_bytes,
|
||||||
|
'dateIso': date_iso,
|
||||||
|
'sourceFolder': classify_iphone_file(filename, all_filenames_in_folder, size_bytes),
|
||||||
|
'mediaType': 'VIDEO' if ext in VIDEO_EXTENSIONS else 'PHOTO',
|
||||||
|
'isHeic': ext in ('heic', 'heif'),
|
||||||
|
'isLivePhotoMotion': is_live_motion,
|
||||||
|
'companionDevicePath': companion_device_path,
|
||||||
|
})
|
||||||
|
|
||||||
|
print(json.dumps(photos))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f'Error: {e}', file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_pull(device_path: str, local_path: str):
|
||||||
|
try:
|
||||||
|
lockdown = await get_lockdown()
|
||||||
|
afc = await get_afc(lockdown)
|
||||||
|
os.makedirs(os.path.dirname(os.path.abspath(local_path)), exist_ok=True)
|
||||||
|
# afc.pull(src, dst) — copies remote file to local path
|
||||||
|
await afc.pull(device_path, local_path)
|
||||||
|
print(json.dumps({'ok': True, 'path': local_path}))
|
||||||
|
except Exception as e:
|
||||||
|
print(f'Error: {e}', file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_pull_thumb(device_path: str, local_path: str):
|
||||||
|
"""Pull first 512KB — enough for EXIF thumbnail extraction via sips."""
|
||||||
|
try:
|
||||||
|
lockdown = await get_lockdown()
|
||||||
|
afc = await get_afc(lockdown)
|
||||||
|
os.makedirs(os.path.dirname(os.path.abspath(local_path)), exist_ok=True)
|
||||||
|
try:
|
||||||
|
# fread(path, size, offset) reads partial file — much faster than full pull
|
||||||
|
data = await afc.fread(device_path, 512 * 1024, 0)
|
||||||
|
except Exception:
|
||||||
|
# fread may not exist or may fail — fall back to full pull
|
||||||
|
await afc.pull(device_path, local_path)
|
||||||
|
print(json.dumps({'ok': True, 'path': local_path}))
|
||||||
|
return
|
||||||
|
with open(local_path, 'wb') as f:
|
||||||
|
f.write(data)
|
||||||
|
print(json.dumps({'ok': True, 'path': local_path}))
|
||||||
|
except Exception as e:
|
||||||
|
print(f'Error: {e}', file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_batch_thumbs(pairs_json: str):
|
||||||
|
"""
|
||||||
|
Pull thumbnails for multiple files in a single AFC session.
|
||||||
|
Input: JSON array of [device_path, local_path] pairs.
|
||||||
|
Output: JSON array of {devicePath, ok} results.
|
||||||
|
|
||||||
|
Strategy by format:
|
||||||
|
- HEIC/HEIF: read first 1MB (thumbnail track is near file start),
|
||||||
|
extract embedded thumbnail via sips. Falls back to full pull if needed.
|
||||||
|
- JPEG/PNG: read first 512KB (sufficient for sips resample).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
pairs = json.loads(pairs_json)
|
||||||
|
lockdown = await get_lockdown()
|
||||||
|
afc = await get_afc(lockdown)
|
||||||
|
results = []
|
||||||
|
for device_path, local_path in pairs:
|
||||||
|
ext = device_path.rsplit('.', 1)[-1].lower() if '.' in device_path else ''
|
||||||
|
is_heic = ext in ('heic', 'heif')
|
||||||
|
try:
|
||||||
|
os.makedirs(os.path.dirname(os.path.abspath(local_path)), exist_ok=True)
|
||||||
|
if is_heic:
|
||||||
|
# HEIC: pull full file — sips needs the full image to convert to JPEG
|
||||||
|
# Cached permanently so only transferred once
|
||||||
|
await afc.pull(device_path, local_path)
|
||||||
|
else:
|
||||||
|
# JPEG/PNG: 512KB partial read is sufficient for sips resample
|
||||||
|
try:
|
||||||
|
data = await afc.fread(device_path, 512 * 1024, 0)
|
||||||
|
with open(local_path, 'wb') as f:
|
||||||
|
f.write(data)
|
||||||
|
except Exception:
|
||||||
|
await afc.pull(device_path, local_path)
|
||||||
|
results.append({'devicePath': device_path, 'ok': True})
|
||||||
|
except Exception as e:
|
||||||
|
results.append({'devicePath': device_path, 'ok': False, 'error': str(e)})
|
||||||
|
print(json.dumps(results))
|
||||||
|
except Exception as e:
|
||||||
|
print(f'Error: {e}', file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_delete(device_path: str):
|
||||||
|
try:
|
||||||
|
lockdown = await get_lockdown()
|
||||||
|
afc = await get_afc(lockdown)
|
||||||
|
await afc.rm(device_path)
|
||||||
|
print(json.dumps({'ok': True}))
|
||||||
|
except Exception as e:
|
||||||
|
print(f'Error: {e}', file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print('Usage: photophetch-ios-helper.py <command> [args]', file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
command = sys.argv[1]
|
||||||
|
|
||||||
|
if command == 'list-devices':
|
||||||
|
asyncio.run(cmd_list_devices())
|
||||||
|
elif command == 'list-photos':
|
||||||
|
asyncio.run(cmd_list_photos())
|
||||||
|
elif command == 'pull' and len(sys.argv) == 4:
|
||||||
|
asyncio.run(cmd_pull(sys.argv[2], sys.argv[3]))
|
||||||
|
elif command == 'pull-thumb' and len(sys.argv) == 4:
|
||||||
|
asyncio.run(cmd_pull_thumb(sys.argv[2], sys.argv[3]))
|
||||||
|
elif command == 'batch-thumbs' and len(sys.argv) == 3:
|
||||||
|
asyncio.run(cmd_batch_thumbs(sys.argv[2]))
|
||||||
|
elif command == 'delete' and len(sys.argv) == 3:
|
||||||
|
asyncio.run(cmd_delete(sys.argv[2]))
|
||||||
|
else:
|
||||||
|
print(f'Unknown command: {command}', file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
Reference in New Issue
Block a user