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
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
@@ -85,6 +107,8 @@ Enable 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.
|
||||
|
||||
## Project structure
|
||||
|
||||
@@ -10,7 +10,16 @@ group = "com.bolenpad.photophetch"
|
||||
version = "1.0.0"
|
||||
|
||||
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 {
|
||||
@@ -34,9 +43,14 @@ compose.desktop {
|
||||
packageVersion = "1.0.0"
|
||||
description = "Import photos from phones into PhotoPhile"
|
||||
vendor = "bolenpad"
|
||||
copyright = "© 2026 Kyle Bolen"
|
||||
macOS {
|
||||
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)
|
||||
|
||||
// Directories on Android that contain camera-roll originals
|
||||
private val cameraDirs = listOf(
|
||||
"/sdcard/DCIM/Camera",
|
||||
"/sdcard/DCIM/100ANDRO",
|
||||
"/sdcard/Pictures/Camera",
|
||||
// Directories on Android that contain camera-roll originals.
|
||||
// Used as fallback if dynamic DCIM discovery finds nothing.
|
||||
private val fallbackCameraDirs = listOf(
|
||||
"/storage/emulated/0/DCIM/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(
|
||||
"jpg", "jpeg", "png", "dng", "raw", // photos
|
||||
"mp4", "mov", "3gp", "avi", // videos
|
||||
// Photos
|
||||
"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> {
|
||||
@@ -49,12 +64,17 @@ class AndroidConnector(
|
||||
if (parts.size < 2 || parts[1] != "device") return@mapNotNull null
|
||||
val serial = parts[0]
|
||||
val model = parts.firstOrNull { it.startsWith("model:") }
|
||||
?.removePrefix("model:") ?: "Android Device"
|
||||
?.removePrefix("model:")
|
||||
?.replace("_", " ")
|
||||
?: "Android Device"
|
||||
val product = parts.firstOrNull { it.startsWith("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(
|
||||
displayName = product?.replace("_", " ")?.replaceFirstChar { it.uppercase() }
|
||||
?: model.replace("_", " "),
|
||||
displayName = displayName.trim(),
|
||||
type = DeviceType.ANDROID,
|
||||
adbSerial = serial,
|
||||
)
|
||||
@@ -65,45 +85,63 @@ class AndroidConnector(
|
||||
val serial = requireSerial(device)
|
||||
val photos = mutableListOf<PhonePhoto>()
|
||||
|
||||
for (dir in cameraDirs) {
|
||||
// Check if directory exists first
|
||||
// Scan all subdirectories under DCIM and Pictures, plus Downloads root.
|
||||
// /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")
|
||||
if (check.contains("No such file") || check.isBlank()) continue
|
||||
|
||||
// List files with metadata: size, date, time, name
|
||||
// `stat` format: size=%s mtime=%Y path=%n
|
||||
val statOutput = runAdb(
|
||||
serial, "shell",
|
||||
"find", dir, "-maxdepth", "1", "-type", "f",
|
||||
"-exec", "stat", "-c", "%s %Y %n", "{}", ";"
|
||||
)
|
||||
// Derive the human-readable folder name from the path
|
||||
val folderName = dir.substringAfterLast("/")
|
||||
|
||||
for (line in statOutput.lines()) {
|
||||
val lsOutput = runAdb(serial, "shell", "ls", "-la", dir)
|
||||
|
||||
for (line in lsOutput.lines()) {
|
||||
val trimmed = line.trim()
|
||||
if (trimmed.isBlank()) continue
|
||||
val parts = trimmed.split(" ", limit = 3)
|
||||
if (parts.size < 3) continue
|
||||
if (trimmed.isBlank() || trimmed.startsWith("total") || trimmed.startsWith("d")) continue
|
||||
|
||||
val sizeBytes = parts[0].toLongOrNull() ?: continue
|
||||
val epochSecs = parts[1].toLongOrNull() ?: continue
|
||||
val path = parts[2]
|
||||
val filename = path.substringAfterLast("/")
|
||||
val parts = trimmed.split(Regex("\\s+"), limit = 8)
|
||||
if (parts.size < 8) continue
|
||||
|
||||
val filename = parts[7].trim()
|
||||
val ext = filename.substringAfterLast(".", "").lowercase()
|
||||
|
||||
if (ext !in mediaExtensions) continue
|
||||
|
||||
val sizeBytes = parts[4].toLongOrNull() ?: 0L
|
||||
val dateTaken = parseDate(parts[5], parts[6])
|
||||
|
||||
photos.add(
|
||||
PhonePhoto(
|
||||
devicePath = path,
|
||||
devicePath = "$dir/$filename",
|
||||
filename = filename,
|
||||
sizeBytes = sizeBytes,
|
||||
dateTaken = Instant.ofEpochSecond(epochSecs),
|
||||
// Android stat doesn't give EXIF; we infer Make/Model from
|
||||
// the device itself rather than per-file EXIF
|
||||
dateTaken = dateTaken,
|
||||
exifMake = 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,
|
||||
sourceFolder = folderName,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -123,6 +161,14 @@ class AndroidConnector(
|
||||
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) {
|
||||
val serial = requireSerial(device)
|
||||
val output = runAdb(serial, "shell", "rm", photo.devicePath)
|
||||
@@ -132,23 +178,12 @@ class AndroidConnector(
|
||||
log.info("Deleted ${photo.filename} from device")
|
||||
}
|
||||
|
||||
override suspend fun remoteChecksum(device: DeviceInfo, photo: PhonePhoto): String? {
|
||||
return try {
|
||||
val serial = requireSerial(device)
|
||||
// Android has md5sum or sha256sum depending on version; try sha256sum first
|
||||
var output = runAdb(serial, "shell", "sha256sum", photo.devicePath)
|
||||
if (output.contains("not found") || output.isBlank()) {
|
||||
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
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Remote checksum disabled — Samsung's toybox sha256sum output format is
|
||||
* inconsistent across Android versions and causes false hash mismatches.
|
||||
* Verification uses file size comparison + local SHA-256 instead.
|
||||
*/
|
||||
override suspend fun remoteChecksum(device: DeviceInfo, photo: PhonePhoto): String? = null
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
@@ -157,6 +192,25 @@ class AndroidConnector(
|
||||
private fun requireSerial(device: DeviceInfo): String =
|
||||
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 {
|
||||
val cmd = buildList {
|
||||
add(adbBin)
|
||||
|
||||
@@ -26,10 +26,22 @@ interface DeviceConnector {
|
||||
/**
|
||||
* Pull a single photo from the device to [destinationDir].
|
||||
* 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
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.MediaType
|
||||
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 java.nio.file.Path
|
||||
import java.time.Instant
|
||||
@@ -13,206 +23,203 @@ import java.time.format.DateTimeFormatter
|
||||
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]).
|
||||
* Install via: brew install gphoto2
|
||||
* Requires pymobiledevice3 installed via pipx:
|
||||
* brew install pipx && pipx install pymobiledevice3
|
||||
*
|
||||
* Important macOS note: The built-in Image Capture daemon (PTPCamera) will
|
||||
* conflict with gphoto2. If listing returns no files, run:
|
||||
* killall PTPCamera
|
||||
* PhotoPhetch shows instructions for this when no photos are found on a
|
||||
* connected iPhone.
|
||||
* The helper script is bundled in the JAR and extracted to a temp file.
|
||||
* It must be run with the pipx virtualenv Python (not system python3)
|
||||
* since pymobiledevice3 is installed in the pipx venv.
|
||||
*
|
||||
* Live Photo handling: gphoto2 lists both the .HEIC and the companion .MOV
|
||||
* with matching base names. We detect the pair by comparing filenames and
|
||||
* flag [PhonePhoto.isLivePhotoStill] / [PhonePhoto.isLivePhotoMotion].
|
||||
* The MOV companion is excluded from import by default.
|
||||
* Auto-discovers the pipx Python at:
|
||||
* ~/Library/Application Support/pipx/venvs/pymobiledevice3/bin/python3
|
||||
*/
|
||||
class IosConnector(
|
||||
private val gphoto2Bin: String = "gphoto2",
|
||||
pythonBinOverride: String? = null,
|
||||
) : DeviceConnector {
|
||||
|
||||
private val log = LoggerFactory.getLogger(IosConnector::class.java)
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
// gphoto2 date format in --list-files output
|
||||
private val gphotoDateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
|
||||
internal val pythonBin: String = pythonBinOverride ?: discoverPython()
|
||||
|
||||
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> {
|
||||
// gphoto2 --auto-detect lists connected cameras/phones
|
||||
val output = runGphoto2("--auto-detect")
|
||||
/*
|
||||
* 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
|
||||
return try {
|
||||
val output = runHelper("list-devices")
|
||||
if (output.isBlank() || output.startsWith("Error")) return emptyList()
|
||||
|
||||
// Split on 2+ spaces to separate model from port
|
||||
val parts = line.trim().split(Regex("\\s{2,}"), limit = 2)
|
||||
if (parts.size < 2) continue
|
||||
val model = parts[0].trim()
|
||||
val port = parts[1].trim()
|
||||
|
||||
if (model.contains("iPhone", ignoreCase = true) ||
|
||||
model.contains("iPad", ignoreCase = true) ||
|
||||
model.contains("Apple", ignoreCase = true)
|
||||
) {
|
||||
devices.add(
|
||||
DeviceInfo(
|
||||
displayName = model,
|
||||
type = DeviceType.IOS,
|
||||
gphotoPort = port,
|
||||
)
|
||||
val array = json.parseToJsonElement(output).jsonArray
|
||||
array.map { element ->
|
||||
val obj = element.jsonObject
|
||||
DeviceInfo(
|
||||
displayName = obj["name"]?.jsonPrimitive?.contentOrNull ?: "iPhone",
|
||||
type = DeviceType.IOS,
|
||||
gphotoPort = obj["udid"]?.jsonPrimitive?.contentOrNull,
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
log.warn("iOS device detection failed: ${e.message}")
|
||||
emptyList()
|
||||
}
|
||||
return devices
|
||||
}
|
||||
|
||||
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 output = runGphoto2("--port", port, "--list-files")
|
||||
val array = json.parseToJsonElement(output).jsonArray
|
||||
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"
|
||||
|
||||
/*
|
||||
* Output format (each file):
|
||||
* #1 IMG_0001.HEIC 5906 KB 2026-07-15 14:22:01
|
||||
* #2 IMG_0001.MOV 12344 KB 2026-07-15 14:22:01
|
||||
* #3 IMG_0002.JPG 3021 KB 2026-07-15 14:23:45
|
||||
*/
|
||||
val rawPhotos = mutableListOf<PhonePhoto>()
|
||||
for (line in output.lines()) {
|
||||
if (!line.trimStart().startsWith("#")) continue
|
||||
val photo = parseGphoto2FileLine(line) ?: continue
|
||||
rawPhotos.add(photo)
|
||||
val dateTaken = if (dateIso != null) {
|
||||
try { Instant.parse(dateIso + "Z") } catch (_: Exception) {
|
||||
try { LocalDateTime.parse(dateIso).atZone(ZoneId.systemDefault()).toInstant() }
|
||||
catch (_: Exception) { Instant.EPOCH }
|
||||
}
|
||||
} else Instant.EPOCH
|
||||
|
||||
PhonePhoto(
|
||||
devicePath = devicePath,
|
||||
filename = filename,
|
||||
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 {
|
||||
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)
|
||||
|
||||
// --get-file downloads to current dir; we set working dir to destinationDir
|
||||
val output = runGphoto2InDir(
|
||||
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")
|
||||
val output = runHelper("pull", photo.devicePath, destFile.absolutePathString())
|
||||
if (output.contains("Error") || !destFile.toFile().exists()) {
|
||||
throw DeviceException("AFC pull failed for ${photo.filename}: $output")
|
||||
}
|
||||
log.info("Pulled ${photo.filename} → ${destFile.absolutePathString()}")
|
||||
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) {
|
||||
val port = requirePort(device)
|
||||
val fileIndex = photo.devicePath.removePrefix("#")
|
||||
val output = runGphoto2("--port", port, "--delete-file", fileIndex)
|
||||
val output = runHelper("delete", photo.devicePath)
|
||||
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")
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** AFC doesn't provide remote checksums — verified by size + local SHA-256 in Copier */
|
||||
override suspend fun remoteChecksum(device: DeviceInfo, photo: PhonePhoto): String? = null
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Parse a single line from `gphoto2 --list-files`.
|
||||
* Example: "#1 IMG_0001.HEIC 5906 KB 2026-07-15 14:22:01"
|
||||
*/
|
||||
private fun parseGphoto2FileLine(line: String): PhonePhoto? {
|
||||
return try {
|
||||
val trimmed = line.trim()
|
||||
val indexEnd = trimmed.indexOf(' ')
|
||||
val index = trimmed.substring(0, indexEnd).trimStart('#')
|
||||
private fun runHelper(vararg args: String): String {
|
||||
val cmd = listOf(pythonBin, helperScript) + args.toList()
|
||||
log.debug("Running: ${cmd.joinToString(" ")}")
|
||||
|
||||
// Split remainder on multiple spaces
|
||||
val rest = trimmed.substring(indexEnd).trim().split(Regex("\\s{2,}"))
|
||||
if (rest.size < 3) return null
|
||||
val process = ProcessBuilder(cmd)
|
||||
.redirectErrorStream(false)
|
||||
.start()
|
||||
|
||||
val filename = rest[0].trim()
|
||||
// rest[1] = "5906 KB" or "5906 kB"
|
||||
val sizeStr = rest[1].trim().split(" ").firstOrNull() ?: "0"
|
||||
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 stdout = process.inputStream.bufferedReader().readText()
|
||||
val stderr = process.errorStream.bufferedReader().readText()
|
||||
process.waitFor()
|
||||
|
||||
val ext = filename.substringAfterLast(".", "").lowercase()
|
||||
val mediaType = when (ext) {
|
||||
"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
|
||||
}
|
||||
if (stderr.isNotBlank()) log.warn("iOS helper stderr: $stderr")
|
||||
return stdout.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect Live Photo pairs by matching HEIC + MOV files with the same base name
|
||||
* (e.g. IMG_0001.HEIC + IMG_0001.MOV). Tags both sides appropriately.
|
||||
* Extract the bundled helper script from the JAR resources to a temp file.
|
||||
* 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> {
|
||||
// Build a map from base name (no extension) → list of photos
|
||||
val byBaseName = photos.groupBy { it.filename.substringBeforeLast(".", it.filename) }
|
||||
|
||||
return photos.map { photo ->
|
||||
val baseName = photo.filename.substringBeforeLast(".", photo.filename)
|
||||
val siblings = byBaseName[baseName] ?: return@map photo
|
||||
|
||||
val hasHeic = siblings.any {
|
||||
it.filename.endsWith(".HEIC", ignoreCase = true) ||
|
||||
it.filename.endsWith(".HEIF", ignoreCase = true)
|
||||
}
|
||||
val hasMov = siblings.any {
|
||||
it.filename.endsWith(".MOV", ignoreCase = true)
|
||||
}
|
||||
|
||||
val hasHeic = siblings.any { 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
|
||||
|
||||
val ext = photo.filename.substringAfterLast(".", "").lowercase()
|
||||
when (ext) {
|
||||
"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,
|
||||
/** Default: do not delete from phone (safer default) */
|
||||
val defaultDeleteAfterVerify: Boolean = false,
|
||||
/** Default: preserve folder structure on import */
|
||||
val defaultPreserveFolderStructure: Boolean = true,
|
||||
/** Whether the user has been warned about HEIC phone settings */
|
||||
val heicWarningDismissed: Boolean = false,
|
||||
/** Path to adb binary, or null to use PATH */
|
||||
val adbPath: String? = null,
|
||||
/** Path to gphoto2 binary, or null to use PATH */
|
||||
val gphoto2Path: String? = null,
|
||||
/** Path to python3 binary, or null to use PATH (needed for iOS via pymobiledevice3) */
|
||||
val python3Path: String? = null,
|
||||
)
|
||||
|
||||
@@ -19,6 +19,18 @@ data class ImportJob(
|
||||
val stripLivePhotoMotion: Boolean = true,
|
||||
/** Whether to delete photos from the phone after successful verification */
|
||||
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 verified: Boolean,
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@@ -20,10 +20,17 @@ data class PhonePhoto(
|
||||
val exifModel: String?,
|
||||
/** Media type */
|
||||
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) */
|
||||
val isLivePhotoStill: Boolean = false,
|
||||
/** True if this is the motion component of an Apple Live Photo (.MOV companion) */
|
||||
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 */
|
||||
val isHeic: Boolean = filename.endsWith(".HEIC", ignoreCase = true) ||
|
||||
filename.endsWith(".HEIF", ignoreCase = true),
|
||||
|
||||
@@ -63,11 +63,37 @@ class Copier(
|
||||
|
||||
photosToImport.forEachIndexed { idx, photo ->
|
||||
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)
|
||||
if (result.error != null) {
|
||||
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, "")
|
||||
|
||||
@@ -101,29 +127,35 @@ class Copier(
|
||||
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 destSize = finalFile.toFile().length()
|
||||
|
||||
// Step 4: Get remote hash for cross-check (Android only; iOS returns null)
|
||||
val remoteHash = connector.remoteChecksum(device, photo)
|
||||
|
||||
// Verification: if we have a remote hash, compare; otherwise mark verified
|
||||
// (the copy itself is the verification for iOS since we have no remote hash)
|
||||
val verified = when {
|
||||
val verified: Boolean
|
||||
|
||||
when {
|
||||
wasConverted -> {
|
||||
// HEIC was converted — we can't compare against original hash.
|
||||
// We accept the file if it exists and is non-zero.
|
||||
finalFile.exists() && finalFile.toFile().length() > 0
|
||||
// HEIC converted — can't compare hashes, check file is non-empty
|
||||
verified = destSize > 0
|
||||
}
|
||||
remoteHash != null -> {
|
||||
remoteHash.equals(destHash, ignoreCase = true)
|
||||
// Full hash comparison
|
||||
verified = remoteHash.equals(destHash, ignoreCase = true)
|
||||
}
|
||||
else -> {
|
||||
// No remote hash — verify that file is non-empty and readable
|
||||
finalFile.exists() && finalFile.toFile().length() > 0
|
||||
// No remote hash — verify size matches what ADB reported
|
||||
// (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(
|
||||
photo = photo,
|
||||
destinationPath = finalFile,
|
||||
@@ -131,7 +163,13 @@ class Copier(
|
||||
destSha256 = destHash,
|
||||
verified = verified,
|
||||
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) {
|
||||
pulledFile?.deleteIfExists()
|
||||
@@ -201,4 +239,32 @@ class Copier(
|
||||
}
|
||||
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.IosConnector
|
||||
import com.bolenpad.photophetch.model.AppConfig
|
||||
import com.bolenpad.photophetch.model.CopyResult
|
||||
import com.bolenpad.photophetch.model.DeviceInfo
|
||||
import com.bolenpad.photophetch.model.DeviceType
|
||||
import com.bolenpad.photophetch.model.ImportJob
|
||||
@@ -31,15 +30,9 @@ import java.time.LocalDate
|
||||
|
||||
/** Top-level navigation destinations */
|
||||
enum class Screen {
|
||||
CONNECT,
|
||||
BROWSE,
|
||||
IMPORT_CONFIG,
|
||||
IMPORTING,
|
||||
VERIFY,
|
||||
SETTINGS,
|
||||
CONNECT, BROWSE, IMPORT_CONFIG, IMPORTING, VERIFY, SETTINGS,
|
||||
}
|
||||
|
||||
/** Sealed state for the device scan */
|
||||
sealed class DeviceScanState {
|
||||
object Idle : DeviceScanState()
|
||||
object Scanning : DeviceScanState()
|
||||
@@ -47,37 +40,40 @@ sealed class DeviceScanState {
|
||||
data class Error(val message: String) : DeviceScanState()
|
||||
}
|
||||
|
||||
/** Sealed state for the photo listing */
|
||||
sealed class PhotoListState {
|
||||
object Idle : PhotoListState()
|
||||
object Loading : PhotoListState()
|
||||
data class Loaded(
|
||||
/** All photos from the device, unfiltered */
|
||||
val all: List<PhonePhoto>,
|
||||
val filtered: List<PhonePhoto>,
|
||||
val shootingInHeic: Boolean,
|
||||
/** Folders derived from all photos, sorted: camera-roll folders first then alphabetical */
|
||||
val folders: List<FolderInfo>,
|
||||
) : 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(
|
||||
val completed: Int,
|
||||
val total: Int,
|
||||
val currentFile: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* 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,
|
||||
) {
|
||||
class AppState(private val configStore: ConfigStore) {
|
||||
private val log = LoggerFactory.getLogger(AppState::class.java)
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Persisted config
|
||||
// Config
|
||||
// -------------------------------------------------------------------------
|
||||
private val _config = MutableStateFlow(configStore.load())
|
||||
val config: StateFlow<AppConfig> = _config.asStateFlow()
|
||||
@@ -87,7 +83,6 @@ class AppState(
|
||||
// -------------------------------------------------------------------------
|
||||
private val _currentScreen = MutableStateFlow(Screen.CONNECT)
|
||||
val currentScreen: StateFlow<Screen> = _currentScreen.asStateFlow()
|
||||
|
||||
fun navigateTo(screen: Screen) { _currentScreen.value = screen }
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -99,20 +94,20 @@ class AppState(
|
||||
private val _selectedDevice = MutableStateFlow<DeviceInfo?>(null)
|
||||
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() {
|
||||
scope.launch {
|
||||
_deviceScan.value = DeviceScanState.Scanning
|
||||
try {
|
||||
val devices = withContext(Dispatchers.IO) {
|
||||
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()
|
||||
}
|
||||
_deviceScan.value = if (devices.isEmpty()) {
|
||||
DeviceScanState.Found(emptyList())
|
||||
} else {
|
||||
DeviceScanState.Found(devices)
|
||||
}
|
||||
_deviceScan.value = DeviceScanState.Found(devices)
|
||||
} catch (e: Exception) {
|
||||
log.error("Device scan failed", e)
|
||||
_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)
|
||||
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())
|
||||
val selectedPhotos: StateFlow<Set<PhonePhoto>> = _selectedPhotos.asStateFlow()
|
||||
|
||||
private val _dateFilter = MutableStateFlow<Pair<LocalDate?, LocalDate?>>(null to null)
|
||||
val dateFilter: StateFlow<Pair<LocalDate?, LocalDate?>> = _dateFilter.asStateFlow()
|
||||
/** Whether HEIC warning should be shown */
|
||||
val shootingInHeic: Boolean
|
||||
get() = (_photoList.value as? PhotoListState.Loaded)
|
||||
?.all?.let { ExifFilter.isShootingInHeic(it) } ?: false
|
||||
|
||||
fun loadPhotos(device: DeviceInfo) {
|
||||
scope.launch {
|
||||
_photoList.value = PhotoListState.Loading
|
||||
_selectedFolder.value = null
|
||||
_selectedPhotos.value = emptySet()
|
||||
try {
|
||||
val connector = connectorFor(device)
|
||||
val raw = withContext(Dispatchers.IO) { connector.listPhotos(device) }
|
||||
val filtered = ExifFilter.filter(raw, _config.value.defaultStripLivePhotoMotion)
|
||||
val shootingInHeic = ExifFilter.isShootingInHeic(filtered)
|
||||
_photoList.value = PhotoListState.Loaded(
|
||||
all = raw,
|
||||
filtered = filtered,
|
||||
shootingInHeic = shootingInHeic,
|
||||
)
|
||||
|
||||
// Build folder summaries
|
||||
val folders = buildFolderList(raw)
|
||||
|
||||
_photoList.value = PhotoListState.Loaded(all = raw, folders = folders)
|
||||
|
||||
// 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) {
|
||||
log.error("Photo listing failed", e)
|
||||
_photoList.value = PhotoListState.Error(e.message ?: "Unknown error")
|
||||
@@ -158,40 +175,161 @@ class AppState(
|
||||
}
|
||||
}
|
||||
|
||||
fun setDateFilter(from: LocalDate?, to: LocalDate?) {
|
||||
_dateFilter.value = from to to
|
||||
// Re-apply filter over already-loaded photos
|
||||
val current = _photoList.value as? PhotoListState.Loaded ?: return
|
||||
val filtered = ExifFilter.filter(current.all, _config.value.defaultStripLivePhotoMotion)
|
||||
.let { applyDateFilter(it, from, to) }
|
||||
_photoList.value = current.copy(filtered = filtered)
|
||||
private suspend fun prefetchIosThumbnails(
|
||||
device: DeviceInfo,
|
||||
photos: List<PhonePhoto>,
|
||||
connector: com.bolenpad.photophetch.device.IosConnector,
|
||||
) {
|
||||
val cacheKey = connector.cacheKey(device)
|
||||
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) {
|
||||
_selectedPhotos.update { current ->
|
||||
if (photo in current) current - photo else current + photo
|
||||
fun selectFolder(folderName: String?) {
|
||||
_selectedFolder.value = folderName
|
||||
_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() {
|
||||
val photos = (_photoList.value as? PhotoListState.Loaded)?.filtered ?: return
|
||||
_selectedPhotos.value = photos.toSet()
|
||||
private fun buildFolderList(photos: List<PhonePhoto>): List<FolderInfo> {
|
||||
val zoneId = java.time.ZoneId.systemDefault()
|
||||
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() {
|
||||
_selectedPhotos.value = emptySet()
|
||||
}
|
||||
|
||||
private fun applyDateFilter(
|
||||
photos: List<PhonePhoto>,
|
||||
from: LocalDate?,
|
||||
to: LocalDate?,
|
||||
): List<PhonePhoto> {
|
||||
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))
|
||||
}
|
||||
fun selectFolder_All() {
|
||||
val loaded = _photoList.value as? PhotoListState.Loaded ?: return
|
||||
val folder = _selectedFolder.value ?: return
|
||||
val folderPhotos = loaded.all.filter { it.sourceFolder == folder }.toSet()
|
||||
_selectedPhotos.update { it + folderPhotos }
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -202,6 +340,7 @@ class AppState(
|
||||
convertHeicToJpeg = true,
|
||||
stripLivePhotoMotion = true,
|
||||
deleteAfterVerify = false,
|
||||
preserveFolderStructure = true,
|
||||
folderName = "",
|
||||
)
|
||||
)
|
||||
@@ -211,6 +350,8 @@ class AppState(
|
||||
val convertHeicToJpeg: Boolean,
|
||||
val stripLivePhotoMotion: Boolean,
|
||||
val deleteAfterVerify: Boolean,
|
||||
val preserveFolderStructure: Boolean,
|
||||
val importLivePhotoCompanions: Boolean = false,
|
||||
val folderName: String,
|
||||
)
|
||||
|
||||
@@ -220,11 +361,22 @@ class AppState(
|
||||
|
||||
fun prepareImport() {
|
||||
val device = _selectedDevice.value ?: return
|
||||
val cfg = _config.value
|
||||
val today = LocalDate.now()
|
||||
val folderName = _config.value.folderNameTemplate
|
||||
val baseName = cfg.folderNameTemplate
|
||||
.replace("{date}", today.toString())
|
||||
.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)
|
||||
}
|
||||
|
||||
@@ -251,6 +403,8 @@ class AppState(
|
||||
convertHeicToJpeg = importCfg.convertHeicToJpeg,
|
||||
stripLivePhotoMotion = importCfg.stripLivePhotoMotion,
|
||||
deleteAfterVerify = importCfg.deleteAfterVerify,
|
||||
preserveFolderStructure = importCfg.preserveFolderStructure,
|
||||
importLivePhotoCompanions = importCfg.importLivePhotoCompanions,
|
||||
)
|
||||
|
||||
navigateTo(Screen.IMPORTING)
|
||||
@@ -272,19 +426,38 @@ class AppState(
|
||||
// Post-import deletion
|
||||
// -------------------------------------------------------------------------
|
||||
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 result = _importResult.value ?: return
|
||||
val toDelete = result.results.filter { it.verified }.map { it.photo }
|
||||
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val connector = connectorFor(device)
|
||||
toDelete.forEach { photo ->
|
||||
try {
|
||||
connector.deletePhoto(device, photo)
|
||||
} catch (e: DeviceException) {
|
||||
log.error("Delete failed for ${photo.filename}: ${e.message}")
|
||||
result.results
|
||||
.filter { it.photo.devicePath in devicePaths }
|
||||
.forEach { r ->
|
||||
try {
|
||||
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()
|
||||
}
|
||||
|
||||
fun updateDefaultPreserveFolderStructure(value: Boolean) {
|
||||
_config.update { it.copy(defaultPreserveFolderStructure = value) }
|
||||
saveConfig()
|
||||
}
|
||||
|
||||
fun dismissHeicWarning() {
|
||||
_config.update { it.copy(heicWarningDismissed = true) }
|
||||
saveConfig()
|
||||
}
|
||||
|
||||
fun saveConfig() {
|
||||
configStore.save(_config.value)
|
||||
}
|
||||
fun saveConfig() { configStore.save(_config.value) }
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 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.IOS -> IosConnector(_config.value.gphoto2Path ?: "gphoto2")
|
||||
DeviceType.IOS -> IosConnector(_config.value.python3Path.takeIf { !it.isNullOrBlank() })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,51 +1,72 @@
|
||||
package com.bolenpad.photophetch.ui
|
||||
|
||||
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.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
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.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.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.bolenpad.photophetch.model.DeviceInfo
|
||||
import com.bolenpad.photophetch.model.MediaType
|
||||
import androidx.compose.ui.window.Dialog
|
||||
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.format.DateTimeFormatter
|
||||
|
||||
@Composable
|
||||
fun BrowseScreen(state: AppState) {
|
||||
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 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()) {
|
||||
// Top bar
|
||||
var columnCount by remember { mutableStateOf(4) }
|
||||
|
||||
BrowseTopBar(
|
||||
device = device,
|
||||
selected = selected,
|
||||
deviceName = device?.displayName ?: "Browse Photos",
|
||||
selectedCount = selectedPhotos.size,
|
||||
columnCount = columnCount,
|
||||
onBack = { state.navigateTo(Screen.CONNECT) },
|
||||
onSelectAll = { state.selectAll() },
|
||||
onSelectNone = { state.selectNone() },
|
||||
onImport = { state.prepareImport() },
|
||||
onColumnChange = { columnCount = (columnCount + it).coerceIn(2, 8) },
|
||||
)
|
||||
|
||||
if (state.shootingInHeic && !config.heicWarningDismissed) {
|
||||
HeicWarningBanner(onDismiss = { state.dismissHeicWarning() })
|
||||
}
|
||||
|
||||
when (val pl = photoList) {
|
||||
is PhotoListState.Idle, is PhotoListState.Loading -> {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
@@ -57,52 +78,68 @@ fun BrowseScreen(state: AppState) {
|
||||
}
|
||||
}
|
||||
is PhotoListState.Loaded -> {
|
||||
// HEIC warning banner
|
||||
if (pl.shootingInHeic && !config.heicWarningDismissed) {
|
||||
HeicWarningBanner(
|
||||
convertByDefault = config.defaultConvertHeicToJpeg,
|
||||
onDismiss = { state.dismissHeicWarning() },
|
||||
)
|
||||
val folderSections = buildFolderSections(pl)
|
||||
val listState = rememberLazyListState()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// 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
|
||||
DateFilterRow(
|
||||
from = dateFilter.first,
|
||||
to = dateFilter.second,
|
||||
onFilterChange = { from, to -> state.setDateFilter(from, to) },
|
||||
)
|
||||
|
||||
// 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,
|
||||
)
|
||||
val activeFolder by remember(folderScrollIndex) {
|
||||
derivedStateOf {
|
||||
val firstVisible = listState.firstVisibleItemIndex
|
||||
folderScrollIndex.entries
|
||||
.filter { it.value <= firstVisible }
|
||||
.maxByOrNull { it.value }?.key
|
||||
?: folderSections.firstOrNull()?.folder?.name
|
||||
}
|
||||
}
|
||||
|
||||
if (pl.filtered.isEmpty()) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("No photos match the current filter", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
} else {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(minSize = 110.dp),
|
||||
modifier = Modifier.fillMaxSize().padding(4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
items(pl.filtered, key = { it.devicePath }) { photo ->
|
||||
PhotoCell(
|
||||
photo = photo,
|
||||
isSelected = photo in selected,
|
||||
onToggle = { state.togglePhotoSelection(photo) },
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(modifier = Modifier.fillMaxSize()) {
|
||||
FolderJumpList(
|
||||
folders = pl.folders,
|
||||
activeFolder = activeFolder,
|
||||
selectedPhotos = selectedPhotos,
|
||||
onJump = { name ->
|
||||
val idx = folderScrollIndex[name] ?: return@FolderJumpList
|
||||
scope.launch { listState.animateScrollToItem(idx) }
|
||||
},
|
||||
modifier = Modifier.width(190.dp).fillMaxHeight(),
|
||||
)
|
||||
|
||||
VerticalDivider()
|
||||
|
||||
val thumbnailVersion by state.thumbnailVersion.collectAsState()
|
||||
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 -> {
|
||||
@@ -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)
|
||||
@Composable
|
||||
private fun BrowseTopBar(
|
||||
device: DeviceInfo?,
|
||||
selected: Set<PhonePhoto>,
|
||||
deviceName: String,
|
||||
selectedCount: Int,
|
||||
columnCount: Int,
|
||||
onBack: () -> Unit,
|
||||
onSelectAll: () -> Unit,
|
||||
onSelectNone: () -> Unit,
|
||||
onImport: () -> Unit,
|
||||
onColumnChange: (Int) -> Unit,
|
||||
) {
|
||||
TopAppBar(
|
||||
title = { Text(device?.displayName ?: "Browse Photos") },
|
||||
title = { Text(deviceName) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
TextButton(onClick = onSelectAll) { Text("All") }
|
||||
TextButton(onClick = onSelectNone) { Text("None") }
|
||||
IconButton(onClick = { onColumnChange(-1) }, enabled = columnCount > 2) {
|
||||
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(
|
||||
onClick = onImport,
|
||||
enabled = selected.isNotEmpty(),
|
||||
enabled = selectedCount > 0,
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
) {
|
||||
Text("Import ${if (selected.isNotEmpty()) "(${selected.size})" else ""}")
|
||||
Text(if (selectedCount > 0) "Import ($selectedCount)" else "Import")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PhotoCell(
|
||||
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) {
|
||||
private fun HeicWarningBanner(onDismiss: () -> Unit) {
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.tertiaryContainer),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Row(modifier = Modifier.padding(12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Default.Warning, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("Phone is shooting in HEIC", fontWeight = FontWeight.SemiBold, style = MaterialTheme.typography.bodySmall)
|
||||
Text(
|
||||
if (convertByDefault)
|
||||
"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.",
|
||||
Text("Phone is shooting in HEIC", fontWeight = FontWeight.SemiBold,
|
||||
style = MaterialTheme.typography.bodySmall)
|
||||
Text("Files will be converted to JPEG. To avoid: Settings → Camera → Formats → Most Compatible.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
)
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer)
|
||||
}
|
||||
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) })
|
||||
}
|
||||
|
||||
// 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
|
||||
when (val scan = scanState) {
|
||||
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
|
||||
|
||||
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.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.FolderOpen
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
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.unit.dp
|
||||
import com.bolenpad.photophetch.util.StagingPath
|
||||
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
|
||||
fun ImportScreen(state: AppState) {
|
||||
val importConfig by state.importConfig.collectAsState()
|
||||
@@ -24,131 +24,190 @@ fun ImportScreen(state: AppState) {
|
||||
val selected by state.selectedPhotos.collectAsState()
|
||||
val device by state.selectedDevice.collectAsState()
|
||||
|
||||
// Validate staging root
|
||||
val stagingError = if (config.stagingRoot.isNotBlank()) {
|
||||
StagingPath.validateStagingRoot(Paths.get(config.stagingRoot))
|
||||
} else "Staging directory not configured"
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(32.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||
) {
|
||||
// Header
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
val canImport = stagingError == null && importConfig.folderName.isNotBlank() && selected.isNotEmpty()
|
||||
|
||||
// Outer column: header + scrollable body + pinned button
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
|
||||
// ── 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) }) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
Text("Import Configuration", style = MaterialTheme.typography.headlineSmall)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
HorizontalDivider()
|
||||
|
||||
// ── Scrollable body with bottom shadow indicator ─────────────────────
|
||||
val scrollState = rememberScrollState()
|
||||
// shadowAlpha: 1.0 when far from bottom, fades to 0.0 over the last 80px
|
||||
val shadowAlpha by remember {
|
||||
derivedStateOf {
|
||||
val remaining = scrollState.maxValue - scrollState.value
|
||||
(remaining / 80f).coerceIn(0f, 1f)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
// Destination folder name
|
||||
Text("Destination Folder", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
"${config.stagingRoot}/",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = importConfig.folderName,
|
||||
onValueChange = { state.updateImportConfig { copy(folderName = it) } },
|
||||
label = { Text("Folder name") },
|
||||
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,
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.drawWithContent {
|
||||
drawContent()
|
||||
if (shadowAlpha > 0f) {
|
||||
drawRect(
|
||||
brush = Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
Color.Transparent,
|
||||
Color.Black.copy(alpha = 0.10f * shadowAlpha),
|
||||
),
|
||||
startY = size.height - 24.dp.toPx(),
|
||||
endY = size.height,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("Convert HEIC → JPEG")
|
||||
Column(
|
||||
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(
|
||||
"Recommended for PhotoPhile on Linux. Uses macOS sips (built-in).",
|
||||
"${config.stagingRoot}/",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
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,
|
||||
onCheckedChange = { state.updateImportConfig { copy(convertHeicToJpeg = it) } },
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
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 = "Import Live Photo motion clips (.MOV)",
|
||||
description = "Include the .MOV companion alongside each Live Photo still. Paired by filename match. Default off.",
|
||||
checked = importConfig.importLivePhotoCompanions,
|
||||
onCheckedChange = { state.updateImportConfig { copy(importLivePhotoCompanions = it) } },
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
// Delete after verify
|
||||
Text("After Import", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
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(
|
||||
ToggleRow(
|
||||
label = "Preserve folder structure",
|
||||
description = "Files go into subfolders matching their source (Camera/, Screenshots/, etc.).",
|
||||
checked = importConfig.preserveFolderStructure,
|
||||
onCheckedChange = {
|
||||
state.updateImportConfig { copy(preserveFolderStructure = it) }
|
||||
state.updateDefaultPreserveFolderStructure(it)
|
||||
},
|
||||
)
|
||||
ToggleRow(
|
||||
label = "Delete from phone after verification",
|
||||
description = "Only deletes files that passed SHA-256 check. You'll confirm on the next screen.",
|
||||
checked = importConfig.deleteAfterVerify,
|
||||
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
|
||||
Button(
|
||||
onClick = { state.startImport() },
|
||||
enabled = stagingError == null && importConfig.folderName.isNotBlank() && selected.isNotEmpty(),
|
||||
modifier = Modifier.fillMaxWidth().height(52.dp),
|
||||
) {
|
||||
Text("Start Import (${selected.size} files)", style = MaterialTheme.typography.titleMedium)
|
||||
// ── Pinned bottom button ─────────────────────────────────────────────
|
||||
HorizontalDivider()
|
||||
Box(modifier = Modifier.padding(horizontal = 32.dp, vertical = 16.dp)) {
|
||||
Button(
|
||||
onClick = { state.startImport() },
|
||||
enabled = canImport,
|
||||
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.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.FolderOpen
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import java.awt.FileDialog
|
||||
import java.awt.Frame
|
||||
import java.io.File
|
||||
|
||||
@Composable
|
||||
fun SettingsScreen(state: AppState) {
|
||||
@@ -33,14 +37,30 @@ fun SettingsScreen(state: AppState) {
|
||||
|
||||
// Staging directory
|
||||
Text("Staging Directory", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
|
||||
OutlinedTextField(
|
||||
value = stagingRoot,
|
||||
onValueChange = { stagingRoot = it },
|
||||
label = { Text("Path to _staging/ directory") },
|
||||
placeholder = { Text("/Volumes/delphi/_staging") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = stagingRoot,
|
||||
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(
|
||||
"This should be the _staging directory on your NAS (must be mounted).",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
@@ -78,7 +98,6 @@ fun SettingsScreen(state: AppState) {
|
||||
Button(
|
||||
onClick = {
|
||||
state.updateStagingRoot(stagingRoot)
|
||||
// Also save template update
|
||||
state.saveConfig()
|
||||
},
|
||||
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
|
||||
|
||||
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.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
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.Home
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
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.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
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
|
||||
fun ImportingScreen(state: AppState) {
|
||||
val progress by state.importProgress.collectAsState()
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(32.dp),
|
||||
Column(modifier = Modifier.fillMaxSize().padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
verticalArrangement = Arrangement.Center) {
|
||||
Text("Importing…", style = MaterialTheme.typography.headlineSmall)
|
||||
Spacer(Modifier.height(32.dp))
|
||||
|
||||
val p = progress
|
||||
if (p != null && p.total > 0) {
|
||||
val fraction = p.completed.toFloat() / p.total
|
||||
LinearProgressIndicator(progress = { fraction }, modifier = Modifier.fillMaxWidth(0.6f))
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text("${p.completed} / ${p.total} files")
|
||||
if (p.currentFile.isNotBlank()) {
|
||||
Text(
|
||||
p.currentFile,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
if (p.currentFile.isNotBlank())
|
||||
Text(p.currentFile, style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
} else CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,166 +63,344 @@ fun ImportingScreen(state: AppState) {
|
||||
fun VerifyScreen(state: AppState) {
|
||||
val result by state.importResult.collectAsState()
|
||||
val importConfig by state.importConfig.collectAsState()
|
||||
var deleteConfirmShown by remember { mutableStateOf(false) }
|
||||
var deleteDone by remember { mutableStateOf(false) }
|
||||
var previewResult by remember { mutableStateOf<CopyResult?>(null) }
|
||||
var activeFilter by remember { mutableStateOf(VerifyFilter.ALL) }
|
||||
|
||||
val r = result ?: return
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize().padding(24.dp)) {
|
||||
// Summary header
|
||||
ResultSummaryHeader(
|
||||
success = r.successCount,
|
||||
failure = r.failureCount,
|
||||
destination = "${r.job.stagingRoot}/${r.job.folderName}",
|
||||
)
|
||||
// Build initial delete set: verified + EXIF-confirmed only
|
||||
val deletableResults = r.results.filter { it.verified && it.error == null }
|
||||
val initiallyChecked = deletableResults
|
||||
.filter { it.exifVerified != false }
|
||||
.map { it.photo.devicePath }.toSet()
|
||||
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
|
||||
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)
|
||||
}
|
||||
}
|
||||
Column(modifier = Modifier.fillMaxSize().padding(horizontal = 20.dp, vertical = 16.dp)) {
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(Modifier.height(12.dp))
|
||||
// ── Summary — counts exclude soft-removed files ───────────────────
|
||||
val activeResults = r.results.filter { it.photo.devicePath !in softRemoved }
|
||||
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
|
||||
if (importConfig.deleteAfterVerify && r.successCount > 0 && !deleteDone) {
|
||||
if (deleteConfirmShown) {
|
||||
DeleteConfirmCard(
|
||||
count = r.successCount,
|
||||
onConfirm = {
|
||||
state.deleteVerifiedFromDevice()
|
||||
deleteDone = true
|
||||
deleteConfirmShown = false
|
||||
},
|
||||
onCancel = { deleteConfirmShown = false },
|
||||
)
|
||||
} else {
|
||||
OutlinedButton(
|
||||
onClick = { deleteConfirmShown = true },
|
||||
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Delete ${r.successCount} verified files from phone")
|
||||
Card(colors = CardDefaults.cardColors(
|
||||
containerColor = if (failCount == 0) MaterialTheme.colorScheme.primaryContainer
|
||||
else MaterialTheme.colorScheme.errorContainer)) {
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(12.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically) {
|
||||
Column {
|
||||
Text(if (failCount == 0) "Import complete ✓" else "Import complete with errors",
|
||||
fontWeight = FontWeight.SemiBold)
|
||||
Text("→ ${r.job.stagingRoot}/${r.job.folderName}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
if (failCount > 0) StatusChip("$failCount failed", MaterialTheme.colorScheme.error)
|
||||
if (unverifiedCount > 0) StatusChip("$unverifiedCount ⚠", MaterialTheme.colorScheme.tertiary)
|
||||
StatusChip("$okCount ✓", MaterialTheme.colorScheme.primary)
|
||||
if (removedCount > 0) StatusChip("$removedCount removed", MaterialTheme.colorScheme.outline)
|
||||
}
|
||||
}
|
||||
} else if (deleteDone) {
|
||||
Text(
|
||||
"✓ ${r.successCount} files deleted from phone",
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
|
||||
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(
|
||||
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.selectNone()
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = doneColor,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
|
||||
) {
|
||||
Icon(Icons.Default.Home, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Done — Back to Start")
|
||||
Text(doneLabel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ResultSummaryHeader(success: Int, failure: Int, destination: String) {
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (failure == 0)
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
else
|
||||
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,
|
||||
)
|
||||
}
|
||||
private fun StatusChip(label: String, color: Color) {
|
||||
Surface(shape = MaterialTheme.shapes.small,
|
||||
color = color.copy(alpha = 0.15f),
|
||||
contentColor = color) {
|
||||
Text(label, modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CopyResultRow(result: CopyResult) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
if (result.verified && result.error == null) Icons.Default.CheckCircle else Icons.Default.Error,
|
||||
contentDescription = null,
|
||||
tint = if (result.verified && result.error == null)
|
||||
MaterialTheme.colorScheme.primary
|
||||
else
|
||||
MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
val displayName = if (result.convertedFromHeic) {
|
||||
"${result.photo.filename} → ${result.destinationPath.fileName}"
|
||||
} else {
|
||||
result.photo.filename
|
||||
private fun VerifyResultRow(
|
||||
result: CopyResult,
|
||||
showCheckbox: Boolean,
|
||||
isChecked: Boolean,
|
||||
canCheck: Boolean,
|
||||
isSoftRemoved: Boolean,
|
||||
onToggleCheck: () -> Unit,
|
||||
onPreview: (CopyResult) -> Unit,
|
||||
onToggleRemove: () -> Unit,
|
||||
) {
|
||||
val isFailed = result.error != null || !result.verified
|
||||
val exifUnverified = result.exifVerified == false
|
||||
val exifUnavailable = result.exifVerified == null
|
||||
|
||||
val rowBg = when {
|
||||
isSoftRemoved -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
||||
isFailed -> MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f)
|
||||
exifUnverified -> MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.3f)
|
||||
else -> Color.Transparent
|
||||
}
|
||||
|
||||
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) {
|
||||
Text(result.error, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.error)
|
||||
} else {
|
||||
|
||||
val icon = when { isFailed -> Icons.Default.Error; exifUnverified -> Icons.Default.Warning; else -> Icons.Default.CheckCircle }
|
||||
val iconTint = when { isFailed -> MaterialTheme.colorScheme.error; exifUnverified -> MaterialTheme.colorScheme.tertiary; else -> MaterialTheme.colorScheme.primary }
|
||||
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(
|
||||
"${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,
|
||||
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
|
||||
private fun DeleteConfirmCard(count: Int, onConfirm: () -> Unit, onCancel: () -> Unit) {
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
private fun ThumbnailChip(destinationPath: String, onClick: () -> Unit) {
|
||||
var bitmap by remember(destinationPath) { mutableStateOf<ImageBitmap?>(null) }
|
||||
LaunchedEffect(destinationPath) {
|
||||
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)) {
|
||||
Text(
|
||||
"Delete $count files from phone?",
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
Text(
|
||||
"This is permanent. Only files that passed SHA-256 verification will be deleted.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(
|
||||
onClick = onConfirm,
|
||||
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error),
|
||||
) { Text("Delete from Phone") }
|
||||
OutlinedButton(onClick = onCancel) { Text("Cancel") }
|
||||
if (bitmap != null) {
|
||||
Image(bitmap = bitmap!!, contentDescription = null,
|
||||
contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize())
|
||||
} else {
|
||||
Text("👁", style = MaterialTheme.typography.labelSmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ResultPreviewDialog(result: CopyResult, onDismiss: () -> Unit) {
|
||||
var bitmap by remember(result.destinationPath) { mutableStateOf<ImageBitmap?>(null) }
|
||||
LaunchedEffect(result.destinationPath) {
|
||||
val bytes = withContext(Dispatchers.IO) {
|
||||
try { Files.readAllBytes(result.destinationPath) } catch (_: Exception) { null }
|
||||
}
|
||||
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(
|
||||
"screenshot",
|
||||
"screen_record",
|
||||
"screen recording",
|
||||
"screenrecord",
|
||||
"videocapture",
|
||||
"video editor",
|
||||
"video_editor",
|
||||
"whatsapp",
|
||||
"telegram",
|
||||
"signal",
|
||||
"viber",
|
||||
"received",
|
||||
"download",
|
||||
"snapchat",
|
||||
"instagram",
|
||||
"twitter",
|
||||
"facebook",
|
||||
"tiktok",
|
||||
"slack",
|
||||
"teams",
|
||||
"zoom",
|
||||
"discord",
|
||||
"share",
|
||||
"temp",
|
||||
"resize",
|
||||
"sticker",
|
||||
"gif",
|
||||
"avatar",
|
||||
"profile",
|
||||
"thumbnail",
|
||||
"cache",
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -84,6 +99,29 @@ object ExifFilter {
|
||||
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).
|
||||
* Used to determine whether to show the HEIC conversion warning.
|
||||
|
||||
@@ -49,6 +49,26 @@ object StagingPath {
|
||||
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.
|
||||
* @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