Compare commits
39 Commits
fbd228b768
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11a1faa395 | ||
|
|
d041298c5c | ||
|
|
b416e528b0 | ||
|
|
207f55d263 | ||
|
|
4b6a5fccf6 | ||
|
|
29595d5a7f | ||
|
|
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 |
@@ -40,12 +40,46 @@ compose.desktop {
|
|||||||
nativeDistributions {
|
nativeDistributions {
|
||||||
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
|
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
|
||||||
packageName = "PhotoPhetch"
|
packageName = "PhotoPhetch"
|
||||||
packageVersion = "1.0.0"
|
packageVersion = "1.0.1"
|
||||||
description = "Import photos from phones into PhotoPhile"
|
description = "Import photos from phones into PhotoPhile"
|
||||||
vendor = "bolenpad"
|
vendor = "bolenpad"
|
||||||
|
copyright = "© 2026 Kyle Bolen"
|
||||||
|
|
||||||
|
modules(
|
||||||
|
// Networking and naming — needed by logback JNDI
|
||||||
|
"java.naming",
|
||||||
|
// JMX — needed by logback JMX appender
|
||||||
|
"java.management",
|
||||||
|
// Instrumentation — needed by logback/class loading
|
||||||
|
"java.instrument",
|
||||||
|
// Reflection support for Unsafe — needed by coroutines, serialization
|
||||||
|
"jdk.unsupported",
|
||||||
|
// XML processing — needed by logback XML config parser
|
||||||
|
"java.xml",
|
||||||
|
// Logging — java.util.logging bridge
|
||||||
|
"java.logging",
|
||||||
|
// Security
|
||||||
|
"java.security.jgss",
|
||||||
|
"java.security.sasl",
|
||||||
|
// Desktop/AWT (for Swing integration)
|
||||||
|
"java.desktop",
|
||||||
|
// Compiler API — sometimes needed by Kotlin reflection
|
||||||
|
"java.compiler",
|
||||||
|
// SQL — needed by some Kotlin stdlib internals
|
||||||
|
"java.sql",
|
||||||
|
// Network — needed by HTTP calls (Gitea API, pymobiledevice3)
|
||||||
|
"java.net.http",
|
||||||
|
// Preferences — used by some desktop apps
|
||||||
|
"java.prefs",
|
||||||
|
)
|
||||||
|
|
||||||
macOS {
|
macOS {
|
||||||
bundleID = "com.bolenpad.photophetch"
|
bundleID = "com.bolenpad.photophetch"
|
||||||
iconFile.set(project.file("src/main/resources/icon.icns"))
|
// Uncomment and add icon.icns to src/main/resources/ for a custom icon:
|
||||||
|
// iconFile.set(project.file("src/main/resources/icon.icns"))
|
||||||
|
signing {
|
||||||
|
sign.set(false) // set true + add identity for App Store / Gatekeeper
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
1
gradle.properties
Normal file
1
gradle.properties
Normal file
@@ -0,0 +1 @@
|
|||||||
|
compose.desktop.packaging.checkJdkVendor=false
|
||||||
68
prepare-release.sh
Executable file
68
prepare-release.sh
Executable file
@@ -0,0 +1,68 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# prepare-release.sh — prepare a release commit and tag (run on dev machine)
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./prepare-release.sh 1.0.0 "Release notes here..."
|
||||||
|
#
|
||||||
|
# This script:
|
||||||
|
# 1. Writes release notes to releases/v<version>.md
|
||||||
|
# 2. Updates packageVersion in build.gradle.kts
|
||||||
|
# 3. Commits both
|
||||||
|
# 4. Creates and pushes the annotated tag
|
||||||
|
#
|
||||||
|
# After this, the Mac operator runs ./release.sh to build and upload.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ $# -lt 2 ]; then
|
||||||
|
echo "Usage: $0 <version> <release-notes>"
|
||||||
|
echo " Example: $0 1.0.0 'First release with Android and iPhone support'"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
VERSION="$1"
|
||||||
|
TAG="v${VERSION}"
|
||||||
|
NOTES="$2"
|
||||||
|
|
||||||
|
# ── Pre-flight ────────────────────────────────────────────────────────────────
|
||||||
|
if ! git diff --quiet || ! git diff --cached --quiet; then
|
||||||
|
echo "ERROR: Uncommitted changes. Commit or stash first."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if git tag | grep -q "^${TAG}$"; then
|
||||||
|
echo "ERROR: Tag $TAG already exists."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Write release notes ───────────────────────────────────────────────────────
|
||||||
|
mkdir -p releases
|
||||||
|
NOTES_FILE="releases/${TAG}.md"
|
||||||
|
cat > "$NOTES_FILE" << EOF
|
||||||
|
$NOTES
|
||||||
|
EOF
|
||||||
|
echo "→ Wrote $NOTES_FILE"
|
||||||
|
|
||||||
|
# ── Update version ────────────────────────────────────────────────────────────
|
||||||
|
sed -i "s/packageVersion = \"[^\"]*\"/packageVersion = \"${VERSION}\"/" build.gradle.kts
|
||||||
|
echo "→ Updated packageVersion to $VERSION"
|
||||||
|
|
||||||
|
# ── Commit ────────────────────────────────────────────────────────────────────
|
||||||
|
git add "$NOTES_FILE" build.gradle.kts
|
||||||
|
git commit -m "Release $TAG"
|
||||||
|
echo "→ Committed release files"
|
||||||
|
|
||||||
|
# ── Tag ───────────────────────────────────────────────────────────────────────
|
||||||
|
git tag -a "$TAG" -m "$NOTES"
|
||||||
|
echo "→ Created tag $TAG"
|
||||||
|
|
||||||
|
# ── Push ──────────────────────────────────────────────────────────────────────
|
||||||
|
echo "→ Pushing to origin..."
|
||||||
|
GIT_ASKPASS='' GIT_TERMINAL_PROMPT=0 git push origin HEAD:refs/heads/main 2>&1 || \
|
||||||
|
echo " (push to main may require running from Mac — tag is local)"
|
||||||
|
GIT_ASKPASS='' GIT_TERMINAL_PROMPT=0 git push origin "$TAG" 2>&1 || \
|
||||||
|
echo " (tag push may require running from Mac)"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✓ Release $TAG prepared."
|
||||||
|
echo " On your Mac: git fetch --tags && git checkout $TAG && ./release.sh"
|
||||||
126
release.sh
Executable file
126
release.sh
Executable file
@@ -0,0 +1,126 @@
|
|||||||
|
#!/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 — from macOS Keychain, env var, or prompt ────────────────────
|
||||||
|
if [ -z "${GITEA_TOKEN:-}" ]; then
|
||||||
|
# Try macOS Keychain first
|
||||||
|
GITEA_TOKEN=$(security find-generic-password -a kbolen -s gitea-photophetch -w 2>/dev/null || echo "")
|
||||||
|
fi
|
||||||
|
if [ -z "${GITEA_TOKEN:-}" ]; then
|
||||||
|
echo "No token found in Keychain or environment."
|
||||||
|
echo "Store it once with:"
|
||||||
|
echo " security add-generic-password -a kbolen -s gitea-photophetch -w YOUR_TOKEN"
|
||||||
|
read -rsp "Or enter token now: " 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
46
releases/v1.0.0.md
Normal file
46
releases/v1.0.0.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
## PhotoPhetch v1.0.0 — First Release
|
||||||
|
|
||||||
|
PhotoPhetch is a macOS desktop app for importing photos from Android and iPhone phones into a PhotoPhile staging directory.
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
**Android (Samsung, Pixel, and others)**
|
||||||
|
- Connects via ADB — scans all DCIM and Pictures subfolders automatically
|
||||||
|
- Smart filtering: excludes Screenshots, Screen Recordings, WhatsApp, Telegram, and other app folders
|
||||||
|
- Folder structure preserved on import (Camera/, Screenshots/, etc.)
|
||||||
|
- Thumbnails loaded and cached locally
|
||||||
|
|
||||||
|
**iPhone**
|
||||||
|
- Connects via pymobiledevice3 (AFC protocol) — works on iOS 17+, no gphoto2 needed
|
||||||
|
- Photos classified by filename pattern: Camera, Edited, Videos, Screenshots
|
||||||
|
- Live Photo detection: companion MOV files hidden from grid, HEIC stills show LIVE badge
|
||||||
|
- Atomic import/delete: HEIC and its MOV companion treated as a unit
|
||||||
|
- Thumbnails batch pre-fetched newest-first and cached permanently
|
||||||
|
|
||||||
|
**Import flow**
|
||||||
|
- Browse photos in a two-panel layout: folder jump list on left, dense photo grid on right
|
||||||
|
- Date markers on first photo of each day; +/- buttons to adjust grid density
|
||||||
|
- Tap + on any photo to preview full-size (HEIC converted to JPEG via sips)
|
||||||
|
- Select individual photos, entire days, or entire folders
|
||||||
|
- SHA-256 copy verification; size-based verification when remote hash unavailable
|
||||||
|
- EXIF Make verification via exiftool: flags photos with absent Make as unverified
|
||||||
|
|
||||||
|
**Verify screen**
|
||||||
|
- Per-file status: green (verified), yellow (origin unknown), red (failed)
|
||||||
|
- Filter by: All / OK / Unverified / Failed
|
||||||
|
- Thumbnail chip on each row — click to preview
|
||||||
|
- Soft-remove any file with undo before committing
|
||||||
|
- Single Done button commits all actions: delete from phone + clean staging
|
||||||
|
|
||||||
|
**Settings**
|
||||||
|
- Staging path with native macOS folder picker
|
||||||
|
- Folder name template with {date} and {device} tokens
|
||||||
|
- Auto-increments folder name if it already exists (e.g. 2026-07-28_iPhone15Pro_2)
|
||||||
|
- Persists HEIC conversion, folder structure, and other preferences
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
- macOS (Intel or Apple Silicon)
|
||||||
|
- Java 17+
|
||||||
|
- ADB for Android: brew install android-platform-tools
|
||||||
|
- pymobiledevice3 for iPhone: brew install pipx && pipx install pymobiledevice3
|
||||||
|
- exiftool for EXIF verification (optional): brew install exiftool
|
||||||
9
releases/v1.0.1.md
Normal file
9
releases/v1.0.1.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
## PhotoPhetch v1.0.1 — Packaging Fixes
|
||||||
|
|
||||||
|
### Bug fixes
|
||||||
|
|
||||||
|
- **Fixed crash on launch** when installed as a .app bundle: bundled JVM was missing required modules (java.naming, java.xml, java.desktop, and others needed by logback and Kotlin coroutines)
|
||||||
|
- **Fixed 'exiftool not installed' false warning** when launched as .app: the bundled app does not inherit the shell PATH, so Homebrew tools in /usr/local/bin and /opt/homebrew/bin were not found. ExternalTools now searches common Homebrew paths directly
|
||||||
|
- **Fixed adb not found** when launched as .app: same PATH issue, now resolved lazily using Homebrew path search
|
||||||
|
- **Fixed EXIF verification false warnings** on Android: Samsung and other Android manufacturers were incorrectly flagged as 'Origin unknown'. Any non-blank EXIF Make is now considered a verified camera original; only absent Make triggers the warning
|
||||||
|
- **Preserve folder structure now sticky**: the toggle in the Import Configuration screen now saves your preference and remembers it between imports
|
||||||
@@ -24,6 +24,12 @@ class AndroidConnector(
|
|||||||
|
|
||||||
private val log = LoggerFactory.getLogger(AndroidConnector::class.java)
|
private val log = LoggerFactory.getLogger(AndroidConnector::class.java)
|
||||||
|
|
||||||
|
// Resolve adb path lazily — ExternalTools searches Homebrew paths
|
||||||
|
// so the packaged .app finds adb even without shell PATH
|
||||||
|
private val resolvedAdb: String by lazy {
|
||||||
|
com.bolenpad.photophetch.util.ExternalTools.resolve(adbBin)
|
||||||
|
}
|
||||||
|
|
||||||
// Directories on Android that contain camera-roll originals.
|
// Directories on Android that contain camera-roll originals.
|
||||||
// Used as fallback if dynamic DCIM discovery finds nothing.
|
// Used as fallback if dynamic DCIM discovery finds nothing.
|
||||||
private val fallbackCameraDirs = listOf(
|
private val fallbackCameraDirs = listOf(
|
||||||
@@ -213,7 +219,7 @@ class AndroidConnector(
|
|||||||
|
|
||||||
private fun runAdb(serial: String?, vararg args: String): String {
|
private fun runAdb(serial: String?, vararg args: String): String {
|
||||||
val cmd = buildList {
|
val cmd = buildList {
|
||||||
add(adbBin)
|
add(resolvedAdb)
|
||||||
if (serial != null) {
|
if (serial != null) {
|
||||||
add("-s")
|
add("-s")
|
||||||
add(serial)
|
add(serial)
|
||||||
|
|||||||
@@ -121,6 +121,9 @@ class IosConnector(
|
|||||||
exifModel = device.displayName,
|
exifModel = device.displayName,
|
||||||
mediaType = if (mediaTypeStr == "VIDEO") MediaType.VIDEO else MediaType.PHOTO,
|
mediaType = if (mediaTypeStr == "VIDEO") MediaType.VIDEO else MediaType.PHOTO,
|
||||||
sourceFolder = sourceFolder,
|
sourceFolder = sourceFolder,
|
||||||
|
isLivePhotoMotion = obj["isLivePhotoMotion"]?.jsonPrimitive?.contentOrNull == "true",
|
||||||
|
isLivePhotoStill = obj["companionDevicePath"]?.jsonPrimitive?.contentOrNull != null,
|
||||||
|
companionDevicePath = obj["companionDevicePath"]?.jsonPrimitive?.contentOrNull,
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
log.debug("Failed to parse photo entry: ${e.message}")
|
log.debug("Failed to parse photo entry: ${e.message}")
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ data class AppConfig(
|
|||||||
val defaultStripLivePhotoMotion: Boolean = true,
|
val defaultStripLivePhotoMotion: Boolean = true,
|
||||||
/** Default: do not delete from phone (safer default) */
|
/** Default: do not delete from phone (safer default) */
|
||||||
val defaultDeleteAfterVerify: Boolean = false,
|
val defaultDeleteAfterVerify: Boolean = false,
|
||||||
|
/** Default: preserve folder structure on import */
|
||||||
|
val defaultPreserveFolderStructure: Boolean = true,
|
||||||
/** Whether the user has been warned about HEIC phone settings */
|
/** Whether the user has been warned about HEIC phone settings */
|
||||||
val heicWarningDismissed: Boolean = false,
|
val heicWarningDismissed: Boolean = false,
|
||||||
/** Path to adb binary, or null to use PATH */
|
/** Path to adb binary, or null to use PATH */
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ data class ImportJob(
|
|||||||
val stripLivePhotoMotion: Boolean = true,
|
val stripLivePhotoMotion: Boolean = true,
|
||||||
/** Whether to delete photos from the phone after successful verification */
|
/** Whether to delete photos from the phone after successful verification */
|
||||||
val deleteAfterVerify: Boolean = false,
|
val deleteAfterVerify: Boolean = false,
|
||||||
|
/**
|
||||||
|
* Whether to import Live Photo .MOV companions alongside their HEIC stills.
|
||||||
|
* Default false — you said you don't want them.
|
||||||
|
*/
|
||||||
|
val importLivePhotoCompanions: Boolean = false,
|
||||||
/**
|
/**
|
||||||
* Whether to preserve the device's subfolder structure under the staging folder.
|
* Whether to preserve the device's subfolder structure under the staging folder.
|
||||||
* e.g. _staging/2026-07-28_Samsung/Camera/IMG_001.jpg
|
* e.g. _staging/2026-07-28_Samsung/Camera/IMG_001.jpg
|
||||||
@@ -38,6 +43,14 @@ data class CopyResult(
|
|||||||
val destSha256: String,
|
val destSha256: String,
|
||||||
val verified: Boolean,
|
val verified: Boolean,
|
||||||
val convertedFromHeic: Boolean = false,
|
val convertedFromHeic: Boolean = false,
|
||||||
|
/**
|
||||||
|
* True if exiftool confirmed Make=Apple (or expected camera make) on the pulled file.
|
||||||
|
* False means EXIF was missing or Make was unexpected — file may not be a camera original.
|
||||||
|
* Null means exiftool was not available or not run.
|
||||||
|
*/
|
||||||
|
val exifVerified: Boolean? = null,
|
||||||
|
/** The Make value exiftool found, e.g. "Apple", "samsung", or null if absent */
|
||||||
|
val exifMake: String? = null,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ data class PhonePhoto(
|
|||||||
val isLivePhotoStill: Boolean = false,
|
val isLivePhotoStill: Boolean = false,
|
||||||
/** True if this is the motion component of an Apple Live Photo (.MOV companion) */
|
/** True if this is the motion component of an Apple Live Photo (.MOV companion) */
|
||||||
val isLivePhotoMotion: Boolean = false,
|
val isLivePhotoMotion: Boolean = false,
|
||||||
|
/**
|
||||||
|
* Device path of the paired MOV companion (set on the HEIC when isLivePhotoStill=true).
|
||||||
|
* Used by the Copier to pull both files atomically and by deletePhoto to delete both.
|
||||||
|
*/
|
||||||
|
val companionDevicePath: String? = null,
|
||||||
/** True if the format is HEIC — user may want to convert to JPEG */
|
/** True if the format is HEIC — user may want to convert to JPEG */
|
||||||
val isHeic: Boolean = filename.endsWith(".HEIC", ignoreCase = true) ||
|
val isHeic: Boolean = filename.endsWith(".HEIC", ignoreCase = true) ||
|
||||||
filename.endsWith(".HEIF", ignoreCase = true),
|
filename.endsWith(".HEIF", ignoreCase = true),
|
||||||
|
|||||||
@@ -63,14 +63,10 @@ class Copier(
|
|||||||
|
|
||||||
photosToImport.forEachIndexed { idx, photo ->
|
photosToImport.forEachIndexed { idx, photo ->
|
||||||
onProgress(idx, photosToImport.size, photo.filename)
|
onProgress(idx, photosToImport.size, photo.filename)
|
||||||
// Determine destination directory
|
|
||||||
val destDir = if (job.preserveFolderStructure) {
|
val destDir = if (job.preserveFolderStructure) {
|
||||||
// Always use sourceFolder regardless of how many folders are in the job
|
|
||||||
val subFolder = photo.sourceFolder.ifBlank {
|
val subFolder = photo.sourceFolder.ifBlank {
|
||||||
photo.devicePath
|
photo.devicePath.substringAfter("/DCIM/", "")
|
||||||
.substringAfter("/DCIM/", "")
|
.substringBeforeLast("/", "").ifBlank { "Camera" }
|
||||||
.substringBeforeLast("/", "")
|
|
||||||
.ifBlank { "Camera" }
|
|
||||||
}
|
}
|
||||||
stagingDir.resolve(subFolder).also { it.toFile().mkdirs() }
|
stagingDir.resolve(subFolder).also { it.toFile().mkdirs() }
|
||||||
} else {
|
} else {
|
||||||
@@ -81,6 +77,23 @@ class Copier(
|
|||||||
if (result.error != null) {
|
if (result.error != null) {
|
||||||
log.error("Failed to copy ${photo.filename}: ${result.error}")
|
log.error("Failed to copy ${photo.filename}: ${result.error}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pull Live Photo companion MOV if requested
|
||||||
|
if (job.importLivePhotoCompanions && photo.isLivePhotoStill && photo.companionDevicePath != null) {
|
||||||
|
val companionFilename = photo.companionDevicePath.substringAfterLast("/")
|
||||||
|
val companionPhoto = photo.copy(
|
||||||
|
devicePath = photo.companionDevicePath,
|
||||||
|
filename = companionFilename,
|
||||||
|
isLivePhotoStill = false,
|
||||||
|
isLivePhotoMotion = true,
|
||||||
|
companionDevicePath = null,
|
||||||
|
)
|
||||||
|
val companionResult = copyOne(companionPhoto, destDir, convertHeic = false)
|
||||||
|
results.add(companionResult)
|
||||||
|
if (companionResult.error != null) {
|
||||||
|
log.error("Failed to copy companion ${companionFilename}: ${companionResult.error}")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
onProgress(photosToImport.size, photosToImport.size, "")
|
onProgress(photosToImport.size, photosToImport.size, "")
|
||||||
|
|
||||||
@@ -122,18 +135,15 @@ class Copier(
|
|||||||
val remoteHash = connector.remoteChecksum(device, photo)
|
val remoteHash = connector.remoteChecksum(device, photo)
|
||||||
|
|
||||||
val verified: Boolean
|
val verified: Boolean
|
||||||
val verificationNote: String?
|
|
||||||
|
|
||||||
when {
|
when {
|
||||||
wasConverted -> {
|
wasConverted -> {
|
||||||
// HEIC converted — can't compare hashes, check file is non-empty
|
// HEIC converted — can't compare hashes, check file is non-empty
|
||||||
verified = destSize > 0
|
verified = destSize > 0
|
||||||
verificationNote = if (verified) "Converted HEIC→JPEG" else null
|
|
||||||
}
|
}
|
||||||
remoteHash != null -> {
|
remoteHash != null -> {
|
||||||
// Full hash comparison
|
// Full hash comparison
|
||||||
verified = remoteHash.equals(destHash, ignoreCase = true)
|
verified = remoteHash.equals(destHash, ignoreCase = true)
|
||||||
verificationNote = null
|
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
// No remote hash — verify size matches what ADB reported
|
// No remote hash — verify size matches what ADB reported
|
||||||
@@ -141,10 +151,11 @@ class Copier(
|
|||||||
val sizeMatch = photo.sizeBytes == 0L || // unknown size is OK
|
val sizeMatch = photo.sizeBytes == 0L || // unknown size is OK
|
||||||
destSize == photo.sizeBytes
|
destSize == photo.sizeBytes
|
||||||
verified = destSize > 0 && sizeMatch
|
verified = destSize > 0 && sizeMatch
|
||||||
verificationNote = if (verified) "Size verified (${destSize / 1024} KB)" else null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val exifMake = readExifMake(finalFile)
|
||||||
|
|
||||||
CopyResult(
|
CopyResult(
|
||||||
photo = photo,
|
photo = photo,
|
||||||
destinationPath = finalFile,
|
destinationPath = finalFile,
|
||||||
@@ -152,6 +163,8 @@ class Copier(
|
|||||||
destSha256 = destHash,
|
destSha256 = destHash,
|
||||||
verified = verified,
|
verified = verified,
|
||||||
convertedFromHeic = wasConverted,
|
convertedFromHeic = wasConverted,
|
||||||
|
exifVerified = exifMake?.let { it.isNotBlank() },
|
||||||
|
exifMake = exifMake,
|
||||||
error = when {
|
error = when {
|
||||||
!verified && remoteHash != null -> "Hash mismatch — file may be corrupt"
|
!verified && remoteHash != null -> "Hash mismatch — file may be corrupt"
|
||||||
!verified -> "Copy failed — file missing or size mismatch"
|
!verified -> "Copy failed — file missing or size mismatch"
|
||||||
@@ -226,4 +239,32 @@ class Copier(
|
|||||||
}
|
}
|
||||||
return digest.digest().joinToString("") { "%02x".format(it) }
|
return digest.digest().joinToString("") { "%02x".format(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the EXIF Make tag from a local file using exiftool.
|
||||||
|
* Returns the Make value (e.g. "Apple"), empty string if Make tag absent,
|
||||||
|
* or null if exiftool is not installed or fails.
|
||||||
|
*/
|
||||||
|
private fun readExifMake(file: Path): String? {
|
||||||
|
return try {
|
||||||
|
val process = ProcessBuilder(com.bolenpad.photophetch.util.ExternalTools.resolve("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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -234,13 +234,30 @@ class AppState(private val configStore: ConfigStore) {
|
|||||||
val tmpFile = tmpDir.resolve("$hash.$ext")
|
val tmpFile = tmpDir.resolve("$hash.$ext")
|
||||||
val cachePath = cacheDir.resolve("$hash.jpg")
|
val cachePath = cacheDir.resolve("$hash.jpg")
|
||||||
if (tmpFile.toFile().exists() && tmpFile.toFile().length() > 0 && !cachePath.toFile().exists()) {
|
if (tmpFile.toFile().exists() && tmpFile.toFile().length() > 0 && !cachePath.toFile().exists()) {
|
||||||
val sips = ProcessBuilder("sips", "--resampleWidth", "240",
|
val success = if (ext in setOf("heic", "heif")) {
|
||||||
tmpFile.toString(), "--out", cachePath.toString())
|
// Convert partial HEIC to JPEG via sips
|
||||||
.redirectErrorStream(true).start()
|
// 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.inputStream.readBytes()
|
||||||
sips.waitFor()
|
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()
|
tmpFile.toFile().delete()
|
||||||
if (cachePath.toFile().exists()) batchLoaded++
|
if (success) batchLoaded++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
loaded += batchLoaded
|
loaded += batchLoaded
|
||||||
@@ -279,6 +296,7 @@ class AppState(private val configStore: ConfigStore) {
|
|||||||
val zoneId = java.time.ZoneId.systemDefault()
|
val zoneId = java.time.ZoneId.systemDefault()
|
||||||
return photos
|
return photos
|
||||||
.groupBy { it.sourceFolder }
|
.groupBy { it.sourceFolder }
|
||||||
|
.filterKeys { !ExifFilter.isHiddenFolder(it) } // hide Live Photo MOV folder
|
||||||
.map { (name, folderPhotos) ->
|
.map { (name, folderPhotos) ->
|
||||||
val dates = folderPhotos.map {
|
val dates = folderPhotos.map {
|
||||||
it.dateTaken.atZone(zoneId).toLocalDate()
|
it.dateTaken.atZone(zoneId).toLocalDate()
|
||||||
@@ -333,6 +351,7 @@ class AppState(private val configStore: ConfigStore) {
|
|||||||
val stripLivePhotoMotion: Boolean,
|
val stripLivePhotoMotion: Boolean,
|
||||||
val deleteAfterVerify: Boolean,
|
val deleteAfterVerify: Boolean,
|
||||||
val preserveFolderStructure: Boolean,
|
val preserveFolderStructure: Boolean,
|
||||||
|
val importLivePhotoCompanions: Boolean = false,
|
||||||
val folderName: String,
|
val folderName: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -352,11 +371,11 @@ class AppState(private val configStore: ConfigStore) {
|
|||||||
StagingPath.uniqueFolderName(Paths.get(cfg.stagingRoot), baseName)
|
StagingPath.uniqueFolderName(Paths.get(cfg.stagingRoot), baseName)
|
||||||
} else baseName
|
} else baseName
|
||||||
|
|
||||||
// Multi-folder selection → preserve structure by default
|
// Multi-folder selection → preserve structure; otherwise use saved config default
|
||||||
val multipleSourceFolders = _selectedPhotos.value.map { it.sourceFolder }.distinct().size > 1
|
val multipleSourceFolders = _selectedPhotos.value.map { it.sourceFolder }.distinct().size > 1
|
||||||
_importConfig.update { it.copy(
|
_importConfig.update { it.copy(
|
||||||
folderName = folderName,
|
folderName = folderName,
|
||||||
preserveFolderStructure = multipleSourceFolders,
|
preserveFolderStructure = if (multipleSourceFolders) true else cfg.defaultPreserveFolderStructure,
|
||||||
) }
|
) }
|
||||||
navigateTo(Screen.IMPORT_CONFIG)
|
navigateTo(Screen.IMPORT_CONFIG)
|
||||||
}
|
}
|
||||||
@@ -385,6 +404,7 @@ class AppState(private val configStore: ConfigStore) {
|
|||||||
stripLivePhotoMotion = importCfg.stripLivePhotoMotion,
|
stripLivePhotoMotion = importCfg.stripLivePhotoMotion,
|
||||||
deleteAfterVerify = importCfg.deleteAfterVerify,
|
deleteAfterVerify = importCfg.deleteAfterVerify,
|
||||||
preserveFolderStructure = importCfg.preserveFolderStructure,
|
preserveFolderStructure = importCfg.preserveFolderStructure,
|
||||||
|
importLivePhotoCompanions = importCfg.importLivePhotoCompanions,
|
||||||
)
|
)
|
||||||
|
|
||||||
navigateTo(Screen.IMPORTING)
|
navigateTo(Screen.IMPORTING)
|
||||||
@@ -406,13 +426,37 @@ class AppState(private val configStore: ConfigStore) {
|
|||||||
// Post-import deletion
|
// Post-import deletion
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
fun deleteVerifiedFromDevice() {
|
fun deleteVerifiedFromDevice() {
|
||||||
|
deleteSpecificFromDevice(
|
||||||
|
_importResult.value?.results
|
||||||
|
?.filter { it.verified && it.exifVerified != false }
|
||||||
|
?.map { it.photo.devicePath }
|
||||||
|
?.toSet() ?: emptySet()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteSpecificFromDevice(devicePaths: Set<String>) {
|
||||||
val device = _selectedDevice.value ?: return
|
val device = _selectedDevice.value ?: return
|
||||||
val result = _importResult.value ?: return
|
val result = _importResult.value ?: return
|
||||||
scope.launch(Dispatchers.IO) {
|
scope.launch(Dispatchers.IO) {
|
||||||
val connector = connectorFor(device)
|
val connector = connectorFor(device)
|
||||||
result.results.filter { it.verified }.forEach { r ->
|
result.results
|
||||||
try { connector.deletePhoto(device, r.photo) }
|
.filter { it.photo.devicePath in devicePaths }
|
||||||
catch (e: DeviceException) { log.error("Delete failed for ${r.photo.filename}: ${e.message}") }
|
.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}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -425,6 +469,11 @@ class AppState(private val configStore: ConfigStore) {
|
|||||||
saveConfig()
|
saveConfig()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun updateDefaultPreserveFolderStructure(value: Boolean) {
|
||||||
|
_config.update { it.copy(defaultPreserveFolderStructure = value) }
|
||||||
|
saveConfig()
|
||||||
|
}
|
||||||
|
|
||||||
fun dismissHeicWarning() {
|
fun dismissHeicWarning() {
|
||||||
_config.update { it.copy(heicWarningDismissed = true) }
|
_config.update { it.copy(heicWarningDismissed = true) }
|
||||||
saveConfig()
|
saveConfig()
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import androidx.compose.foundation.background
|
|||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
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.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.Warning
|
import androidx.compose.material.icons.filled.Warning
|
||||||
@@ -11,11 +13,20 @@ import androidx.compose.material3.*
|
|||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
|
import androidx.compose.ui.graphics.toComposeImageBitmap
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
import com.bolenpad.photophetch.model.PhonePhoto
|
import com.bolenpad.photophetch.model.PhonePhoto
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.jetbrains.skia.Image as SkiaImage
|
||||||
|
import java.nio.file.Path
|
||||||
|
import java.nio.file.Paths
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -25,6 +36,20 @@ fun BrowseScreen(state: AppState) {
|
|||||||
val device by state.selectedDevice.collectAsState()
|
val device by state.selectedDevice.collectAsState()
|
||||||
val config by state.config.collectAsState()
|
val config by state.config.collectAsState()
|
||||||
|
|
||||||
|
var previewPhoto by remember { mutableStateOf<PhonePhoto?>(null) }
|
||||||
|
|
||||||
|
// Preview dialog
|
||||||
|
previewPhoto?.let { photo ->
|
||||||
|
PhotoPreviewDialog(
|
||||||
|
photo = photo,
|
||||||
|
cacheKey = device?.let { state.connectorFor(it).cacheKey(it) } ?: "unknown",
|
||||||
|
pullFn = { devicePath, localPath ->
|
||||||
|
device?.let { d -> state.connectorFor(d).pullToFile(d, devicePath, localPath) } ?: false
|
||||||
|
},
|
||||||
|
onDismiss = { previewPhoto = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
var columnCount by remember { mutableStateOf(4) }
|
var columnCount by remember { mutableStateOf(4) }
|
||||||
|
|
||||||
@@ -112,6 +137,7 @@ fun BrowseScreen(state: AppState) {
|
|||||||
onDeselectAllFolder = { folder ->
|
onDeselectAllFolder = { folder ->
|
||||||
folder.forEach { p -> if (p in selectedPhotos) state.togglePhotoSelection(p) }
|
folder.forEach { p -> if (p in selectedPhotos) state.togglePhotoSelection(p) }
|
||||||
},
|
},
|
||||||
|
onPreviewPhoto = { previewPhoto = it },
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -134,7 +160,8 @@ fun BrowseScreen(state: AppState) {
|
|||||||
|
|
||||||
private fun buildFolderSections(pl: PhotoListState.Loaded): List<FolderSection> {
|
private fun buildFolderSections(pl: PhotoListState.Loaded): List<FolderSection> {
|
||||||
val zoneId = ZoneId.systemDefault()
|
val zoneId = ZoneId.systemDefault()
|
||||||
return pl.folders.map { folder ->
|
return pl.folders // already filtered by buildFolderList (no hidden folders)
|
||||||
|
.map { folder ->
|
||||||
val photos = pl.all
|
val photos = pl.all
|
||||||
.filter { it.sourceFolder == folder.name }
|
.filter { it.sourceFolder == folder.name }
|
||||||
.sortedByDescending { it.dateTaken }
|
.sortedByDescending { it.dateTaken }
|
||||||
@@ -322,3 +349,91 @@ private fun HeicWarningBanner(onDismiss: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── 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") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.bolenpad.photophetch.model.DeviceInfo
|
import com.bolenpad.photophetch.model.DeviceInfo
|
||||||
|
import com.bolenpad.photophetch.util.ExternalTools
|
||||||
import com.bolenpad.photophetch.model.DeviceType
|
import com.bolenpad.photophetch.model.DeviceType
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -46,6 +47,19 @@ fun ConnectScreen(state: AppState) {
|
|||||||
StagingNotConfiguredBanner(onConfigureClick = { state.navigateTo(Screen.SETTINGS) })
|
StagingNotConfiguredBanner(onConfigureClick = { state.navigateTo(Screen.SETTINGS) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// exiftool warning — checked once per session
|
||||||
|
val exiftoolMissing = remember {
|
||||||
|
try {
|
||||||
|
val p = ProcessBuilder(ExternalTools.resolve("exiftool"), "-ver")
|
||||||
|
.redirectErrorStream(true).start()
|
||||||
|
p.inputStream.readBytes()
|
||||||
|
p.waitFor() != 0
|
||||||
|
} catch (_: Exception) { true }
|
||||||
|
}
|
||||||
|
if (exiftoolMissing) {
|
||||||
|
ExiftoolMissingBanner()
|
||||||
|
}
|
||||||
|
|
||||||
// Device scan area
|
// Device scan area
|
||||||
when (val scan = scanState) {
|
when (val scan = scanState) {
|
||||||
is DeviceScanState.Idle -> {
|
is DeviceScanState.Idle -> {
|
||||||
@@ -200,3 +214,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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,10 +10,12 @@ import androidx.compose.foundation.lazy.LazyListState
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.CheckCircle
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
import androidx.compose.material.icons.filled.Image
|
import androidx.compose.material.icons.filled.Image
|
||||||
|
import androidx.compose.material.icons.filled.RadioButtonUnchecked
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.ImageBitmap
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
import androidx.compose.ui.graphics.toComposeImageBitmap
|
import androidx.compose.ui.graphics.toComposeImageBitmap
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
@@ -51,8 +53,14 @@ fun DensePhotoGrid(
|
|||||||
onTogglePhoto: (PhonePhoto) -> Unit,
|
onTogglePhoto: (PhonePhoto) -> Unit,
|
||||||
onSelectAllFolder: (List<PhonePhoto>) -> Unit,
|
onSelectAllFolder: (List<PhonePhoto>) -> Unit,
|
||||||
onDeselectAllFolder: (List<PhonePhoto>) -> Unit,
|
onDeselectAllFolder: (List<PhonePhoto>) -> Unit,
|
||||||
|
onPreviewPhoto: ((PhonePhoto) -> Unit)? = null,
|
||||||
modifier: Modifier = Modifier,
|
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() }) {
|
if (folderSections.isEmpty() || folderSections.all { it.allPhotos.isEmpty() }) {
|
||||||
Box(modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
Column(
|
Column(
|
||||||
@@ -144,7 +152,9 @@ fun DensePhotoGrid(
|
|||||||
cacheKey = cacheKey,
|
cacheKey = cacheKey,
|
||||||
pullFn = pullFn,
|
pullFn = pullFn,
|
||||||
thumbnailVersion = thumbnailVersion,
|
thumbnailVersion = thumbnailVersion,
|
||||||
|
bitmapCache = bitmapCache,
|
||||||
onToggle = { onTogglePhoto(labeled.photo) },
|
onToggle = { onTogglePhoto(labeled.photo) },
|
||||||
|
onPreview = onPreviewPhoto,
|
||||||
dateLabel = labeled.dateLabel,
|
dateLabel = labeled.dateLabel,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
@@ -167,13 +177,17 @@ private fun DenseThumb(
|
|||||||
cacheKey: String,
|
cacheKey: String,
|
||||||
pullFn: suspend (devicePath: String, localDestPath: Path) -> Boolean,
|
pullFn: suspend (devicePath: String, localDestPath: Path) -> Boolean,
|
||||||
thumbnailVersion: Int,
|
thumbnailVersion: Int,
|
||||||
|
bitmapCache: MutableMap<String, ImageBitmap>,
|
||||||
onToggle: () -> Unit,
|
onToggle: () -> Unit,
|
||||||
|
onPreview: ((PhonePhoto) -> Unit)?,
|
||||||
dateLabel: String?,
|
dateLabel: String?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
var thumbnail by remember(photo.devicePath) { mutableStateOf<ImageBitmap?>(null) }
|
// Check in-memory cache first (survives Compose cell recycling during scroll)
|
||||||
|
var thumbnail by remember(photo.devicePath) {
|
||||||
|
mutableStateOf(bitmapCache[photo.devicePath])
|
||||||
|
}
|
||||||
|
|
||||||
// Re-run when thumbnailVersion increments (new batch cached) or on first composition
|
|
||||||
LaunchedEffect(photo.devicePath, thumbnailVersion) {
|
LaunchedEffect(photo.devicePath, thumbnailVersion) {
|
||||||
if (thumbnail == null) {
|
if (thumbnail == null) {
|
||||||
val bytes = withContext(Dispatchers.IO) {
|
val bytes = withContext(Dispatchers.IO) {
|
||||||
@@ -186,7 +200,9 @@ private fun DenseThumb(
|
|||||||
}
|
}
|
||||||
if (bytes != null && bytes.isNotEmpty()) {
|
if (bytes != null && bytes.isNotEmpty()) {
|
||||||
runCatching {
|
runCatching {
|
||||||
thumbnail = SkiaImage.makeFromEncoded(bytes).toComposeImageBitmap()
|
val bitmap = SkiaImage.makeFromEncoded(bytes).toComposeImageBitmap()
|
||||||
|
bitmapCache[photo.devicePath] = bitmap // store in memory cache
|
||||||
|
thumbnail = bitmap
|
||||||
}.onFailure { e ->
|
}.onFailure { e ->
|
||||||
thumbLog.warn("Decode failed for ${photo.filename} (${bytes.size} bytes): ${e.message}")
|
thumbLog.warn("Decode failed for ${photo.filename} (${bytes.size} bytes): ${e.message}")
|
||||||
}
|
}
|
||||||
@@ -235,16 +251,74 @@ private fun DenseThumb(
|
|||||||
tint = MaterialTheme.colorScheme.outline, modifier = Modifier.size(20.dp))
|
tint = MaterialTheme.colorScheme.outline, modifier = Modifier.size(20.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (photo.mediaType == MediaType.VIDEO) {
|
|
||||||
Badge(containerColor = MaterialTheme.colorScheme.tertiary,
|
// Top-right: high-contrast selection indicator
|
||||||
modifier = Modifier.align(Alignment.BottomStart).padding(2.dp)) {
|
// White circle with drop shadow so it's visible on both light and dark thumbnails
|
||||||
Text("▶", style = MaterialTheme.typography.labelSmall)
|
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) {
|
if (isSelected) {
|
||||||
Icon(Icons.Default.CheckCircle, contentDescription = null,
|
Icon(Icons.Default.CheckCircle, contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
tint = Color.White, modifier = Modifier.size(18.dp))
|
||||||
modifier = Modifier.align(Alignment.TopEnd).padding(2.dp).size(16.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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -145,16 +145,19 @@ fun ImportScreen(state: AppState) {
|
|||||||
onCheckedChange = { state.updateImportConfig { copy(convertHeicToJpeg = it) } },
|
onCheckedChange = { state.updateImportConfig { copy(convertHeicToJpeg = it) } },
|
||||||
)
|
)
|
||||||
ToggleRow(
|
ToggleRow(
|
||||||
label = "Strip Live Photo motion clips",
|
label = "Import Live Photo motion clips (.MOV)",
|
||||||
description = "Excludes .MOV companions from Live Photos.",
|
description = "Include the .MOV companion alongside each Live Photo still. Paired by filename match. Default off.",
|
||||||
checked = importConfig.stripLivePhotoMotion,
|
checked = importConfig.importLivePhotoCompanions,
|
||||||
onCheckedChange = { state.updateImportConfig { copy(stripLivePhotoMotion = it) } },
|
onCheckedChange = { state.updateImportConfig { copy(importLivePhotoCompanions = it) } },
|
||||||
)
|
)
|
||||||
ToggleRow(
|
ToggleRow(
|
||||||
label = "Preserve folder structure",
|
label = "Preserve folder structure",
|
||||||
description = "Files go into subfolders matching their source (Camera/, Screenshots/, etc.).",
|
description = "Files go into subfolders matching their source (Camera/, Screenshots/, etc.).",
|
||||||
checked = importConfig.preserveFolderStructure,
|
checked = importConfig.preserveFolderStructure,
|
||||||
onCheckedChange = { state.updateImportConfig { copy(preserveFolderStructure = it) } },
|
onCheckedChange = {
|
||||||
|
state.updateImportConfig { copy(preserveFolderStructure = it) }
|
||||||
|
state.updateDefaultPreserveFolderStructure(it)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
ToggleRow(
|
ToggleRow(
|
||||||
label = "Delete from phone after verification",
|
label = "Delete from phone after verification",
|
||||||
|
|||||||
@@ -1,296 +1,406 @@
|
|||||||
package com.bolenpad.photophetch.ui
|
package com.bolenpad.photophetch.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.CheckCircle
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.Error
|
import androidx.compose.material.icons.filled.Error
|
||||||
import androidx.compose.material.icons.filled.Home
|
import androidx.compose.material.icons.filled.Home
|
||||||
|
import androidx.compose.material.icons.filled.Warning
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.draw.drawWithContent
|
import androidx.compose.ui.draw.drawWithContent
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
|
import androidx.compose.ui.graphics.toComposeImageBitmap
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
import com.bolenpad.photophetch.model.CopyResult
|
import com.bolenpad.photophetch.model.CopyResult
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.jetbrains.skia.Image as SkiaImage
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.Paths
|
||||||
|
|
||||||
|
private enum class VerifyFilter { ALL, OK, UNVERIFIED, FAILED }
|
||||||
|
|
||||||
/**
|
|
||||||
* Shown during active import (IMPORTING screen).
|
|
||||||
*/
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ImportingScreen(state: AppState) {
|
fun ImportingScreen(state: AppState) {
|
||||||
val progress by state.importProgress.collectAsState()
|
val progress by state.importProgress.collectAsState()
|
||||||
|
Column(modifier = Modifier.fillMaxSize().padding(32.dp),
|
||||||
Column(
|
|
||||||
modifier = Modifier.fillMaxSize().padding(32.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center) {
|
||||||
) {
|
|
||||||
Text("Importing…", style = MaterialTheme.typography.headlineSmall)
|
Text("Importing…", style = MaterialTheme.typography.headlineSmall)
|
||||||
Spacer(Modifier.height(32.dp))
|
Spacer(Modifier.height(32.dp))
|
||||||
|
|
||||||
val p = progress
|
val p = progress
|
||||||
if (p != null && p.total > 0) {
|
if (p != null && p.total > 0) {
|
||||||
val fraction = p.completed.toFloat() / p.total
|
val fraction = p.completed.toFloat() / p.total
|
||||||
LinearProgressIndicator(progress = { fraction }, modifier = Modifier.fillMaxWidth(0.6f))
|
LinearProgressIndicator(progress = { fraction }, modifier = Modifier.fillMaxWidth(0.6f))
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
Text("${p.completed} / ${p.total} files")
|
Text("${p.completed} / ${p.total} files")
|
||||||
if (p.currentFile.isNotBlank()) {
|
if (p.currentFile.isNotBlank())
|
||||||
Text(
|
Text(p.currentFile, style = MaterialTheme.typography.bodySmall,
|
||||||
p.currentFile,
|
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
style = MaterialTheme.typography.bodySmall,
|
} else CircularProgressIndicator()
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
CircularProgressIndicator()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Shown after import completes (VERIFY screen).
|
|
||||||
*/
|
|
||||||
@Composable
|
@Composable
|
||||||
fun VerifyScreen(state: AppState) {
|
fun VerifyScreen(state: AppState) {
|
||||||
val result by state.importResult.collectAsState()
|
val result by state.importResult.collectAsState()
|
||||||
val importConfig by state.importConfig.collectAsState()
|
val importConfig by state.importConfig.collectAsState()
|
||||||
var deleteConfirmShown by remember { mutableStateOf(false) }
|
var previewResult by remember { mutableStateOf<CopyResult?>(null) }
|
||||||
var deleteDone by remember { mutableStateOf(false) }
|
var activeFilter by remember { mutableStateOf(VerifyFilter.ALL) }
|
||||||
|
|
||||||
val r = result ?: return
|
val r = result ?: return
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize().padding(24.dp)) {
|
// Build initial delete set: verified + EXIF-confirmed only
|
||||||
|
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>()) }
|
||||||
|
|
||||||
// Summary header
|
previewResult?.let { pr ->
|
||||||
ResultSummaryHeader(
|
ResultPreviewDialog(result = pr, onDismiss = { previewResult = null })
|
||||||
success = r.successCount,
|
|
||||||
failure = r.failureCount,
|
|
||||||
destination = "${r.job.stagingRoot}/${r.job.folderName}",
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
Text("Results", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
|
||||||
Spacer(Modifier.height(4.dp))
|
|
||||||
HorizontalDivider()
|
|
||||||
|
|
||||||
// Scrollable results list — shadows are pinned to the divider edges
|
|
||||||
val listState = rememberLazyListState()
|
|
||||||
|
|
||||||
val topShadowAlpha by remember {
|
|
||||||
derivedStateOf {
|
|
||||||
val scrolled = listState.firstVisibleItemScrollOffset +
|
|
||||||
listState.firstVisibleItemIndex * 80
|
|
||||||
(scrolled / 60f).coerceIn(0f, 1f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val bottomShadowAlpha by remember {
|
|
||||||
derivedStateOf {
|
|
||||||
val info = listState.layoutInfo
|
|
||||||
val lastVisible = info.visibleItemsInfo.lastOrNull()
|
|
||||||
val total = info.totalItemsCount
|
|
||||||
if (lastVisible == null || lastVisible.index >= total - 1) 0f else 1f
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Top edge: shadow drawn below the divider line above the list
|
Column(modifier = Modifier.fillMaxSize().padding(horizontal = 20.dp, vertical = 16.dp)) {
|
||||||
if (topShadowAlpha > 0f) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(16.dp)
|
|
||||||
.drawWithContent {
|
|
||||||
drawContent()
|
|
||||||
drawRect(
|
|
||||||
brush = Brush.verticalGradient(
|
|
||||||
colors = listOf(
|
|
||||||
Color.Black.copy(alpha = 0.10f * topShadowAlpha),
|
|
||||||
Color.Transparent,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
LazyColumn(
|
// ── Summary — counts exclude soft-removed files ───────────────────
|
||||||
state = listState,
|
val activeResults = r.results.filter { it.photo.devicePath !in softRemoved }
|
||||||
modifier = Modifier.weight(1f),
|
val failCount = activeResults.count { it.error != null || !it.verified }
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
val unverifiedCount = activeResults.count { it.exifVerified == false }
|
||||||
contentPadding = PaddingValues(vertical = 4.dp),
|
val okCount = activeResults.count { it.verified && it.error == null && it.exifVerified != false }
|
||||||
) {
|
val removedCount = softRemoved.size
|
||||||
items(r.results, key = { it.photo.devicePath }) { copyResult ->
|
|
||||||
CopyResultRow(copyResult)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bottom edge: shadow drawn above the divider line below the list
|
Card(colors = CardDefaults.cardColors(
|
||||||
if (bottomShadowAlpha > 0f) {
|
containerColor = if (failCount == 0) MaterialTheme.colorScheme.primaryContainer
|
||||||
Box(
|
else MaterialTheme.colorScheme.errorContainer)) {
|
||||||
modifier = Modifier
|
Row(modifier = Modifier.fillMaxWidth().padding(12.dp),
|
||||||
.fillMaxWidth()
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
.height(16.dp)
|
verticalAlignment = Alignment.CenterVertically) {
|
||||||
.drawWithContent {
|
Column {
|
||||||
drawContent()
|
Text(if (failCount == 0) "Import complete ✓" else "Import complete with errors",
|
||||||
drawRect(
|
fontWeight = FontWeight.SemiBold)
|
||||||
brush = Brush.verticalGradient(
|
Text("→ ${r.job.stagingRoot}/${r.job.folderName}",
|
||||||
colors = listOf(
|
|
||||||
Color.Transparent,
|
|
||||||
Color.Black.copy(alpha = 0.10f * bottomShadowAlpha),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
HorizontalDivider()
|
|
||||||
Spacer(Modifier.height(12.dp))
|
|
||||||
|
|
||||||
// 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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (deleteDone) {
|
|
||||||
Text(
|
|
||||||
"✓ ${r.successCount} files deleted from phone",
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// ── Filter bar ─────────────────────────────────────────────────────
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
VerifyFilter.entries.forEach { filter ->
|
||||||
|
val label = when (filter) {
|
||||||
|
VerifyFilter.ALL -> "All (${activeResults.size})"
|
||||||
|
VerifyFilter.OK -> "✓ OK ($okCount)"
|
||||||
|
VerifyFilter.UNVERIFIED -> "⚠ Unverified ($unverifiedCount)"
|
||||||
|
VerifyFilter.FAILED -> "✗ Failed ($failCount)"
|
||||||
|
}
|
||||||
|
FilterChip(selected = activeFilter == filter, onClick = { activeFilter = filter },
|
||||||
|
label = { Text(label, style = MaterialTheme.typography.labelSmall) })
|
||||||
|
}
|
||||||
|
if (removedCount > 0) {
|
||||||
|
FilterChip(selected = false, onClick = { /* show removed inline */ },
|
||||||
|
label = { Text("$removedCount removed — tap to undo all",
|
||||||
|
style = MaterialTheme.typography.labelSmall) },
|
||||||
|
trailingIcon = {
|
||||||
|
IconButton(onClick = { softRemoved = emptySet() },
|
||||||
|
modifier = Modifier.size(16.dp)) {
|
||||||
|
Icon(Icons.Default.Close, contentDescription = "Undo all removals",
|
||||||
|
modifier = Modifier.size(12.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
|
||||||
|
// ── Results list ───────────────────────────────────────────────────
|
||||||
|
val filteredResults = r.results.filter { cr ->
|
||||||
|
when (activeFilter) {
|
||||||
|
VerifyFilter.ALL -> true // show all including soft-removed (they'll be greyed)
|
||||||
|
VerifyFilter.OK -> cr.verified && cr.error == null && cr.exifVerified != false && cr.photo.devicePath !in softRemoved
|
||||||
|
VerifyFilter.UNVERIFIED -> cr.exifVerified == false && cr.photo.devicePath !in softRemoved
|
||||||
|
VerifyFilter.FAILED -> (cr.error != null || !cr.verified) && cr.photo.devicePath !in softRemoved
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
val topAlpha by remember { derivedStateOf {
|
||||||
|
((listState.firstVisibleItemScrollOffset + listState.firstVisibleItemIndex * 80) / 60f).coerceIn(0f, 1f)
|
||||||
|
}}
|
||||||
|
val bottomAlpha by remember { derivedStateOf {
|
||||||
|
val info = listState.layoutInfo
|
||||||
|
val last = info.visibleItemsInfo.lastOrNull()
|
||||||
|
if (last == null || last.index >= info.totalItemsCount - 1) 0f else 1f
|
||||||
|
}}
|
||||||
|
|
||||||
|
if (topAlpha > 0f) Box(Modifier.fillMaxWidth().height(16.dp).drawWithContent {
|
||||||
|
drawContent()
|
||||||
|
drawRect(brush = Brush.verticalGradient(listOf(Color.Black.copy(0.08f * topAlpha), Color.Transparent)))
|
||||||
|
})
|
||||||
|
|
||||||
|
LazyColumn(state = listState, modifier = Modifier.weight(1f),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
|
items(filteredResults, key = { it.photo.devicePath }) { cr ->
|
||||||
|
val isSoftRemoved = cr.photo.devicePath in softRemoved
|
||||||
|
VerifyResultRow(
|
||||||
|
result = cr,
|
||||||
|
showCheckbox = importConfig.deleteAfterVerify && !isSoftRemoved,
|
||||||
|
isChecked = cr.photo.devicePath in toDelete,
|
||||||
|
canCheck = cr.verified && cr.error == null,
|
||||||
|
isSoftRemoved = isSoftRemoved,
|
||||||
|
onToggleCheck = {
|
||||||
|
toDelete = if (cr.photo.devicePath in toDelete)
|
||||||
|
toDelete - cr.photo.devicePath
|
||||||
|
else toDelete + cr.photo.devicePath
|
||||||
|
},
|
||||||
|
onPreview = { previewResult = it },
|
||||||
|
onToggleRemove = {
|
||||||
|
if (isSoftRemoved) {
|
||||||
|
softRemoved = softRemoved - cr.photo.devicePath
|
||||||
|
} else {
|
||||||
|
softRemoved = softRemoved + cr.photo.devicePath
|
||||||
|
toDelete = toDelete - cr.photo.devicePath
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bottomAlpha > 0f) Box(Modifier.fillMaxWidth().height(16.dp).drawWithContent {
|
||||||
|
drawContent()
|
||||||
|
drawRect(brush = Brush.verticalGradient(listOf(Color.Transparent, Color.Black.copy(0.08f * bottomAlpha))))
|
||||||
|
})
|
||||||
|
|
||||||
|
HorizontalDivider()
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// ── Commit button ──────────────────────────────────────────────────
|
||||||
|
val hasPendingPhoneDeletes = importConfig.deleteAfterVerify && toDelete.isNotEmpty()
|
||||||
|
val hasPendingStagingCleanup = softRemoved.isNotEmpty()
|
||||||
|
|
||||||
|
val doneLabel = when {
|
||||||
|
hasPendingPhoneDeletes && hasPendingStagingCleanup ->
|
||||||
|
"Confirm: delete ${toDelete.size} from phone + remove ${softRemoved.size} from staging"
|
||||||
|
hasPendingPhoneDeletes ->
|
||||||
|
"Confirm: delete ${toDelete.size} files from phone"
|
||||||
|
hasPendingStagingCleanup ->
|
||||||
|
"Confirm: remove ${softRemoved.size} files from staging"
|
||||||
|
else -> "Done — Back to Start"
|
||||||
|
}
|
||||||
|
|
||||||
|
val doneColor = if (hasPendingPhoneDeletes || hasPendingStagingCleanup)
|
||||||
|
ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)
|
||||||
|
else ButtonDefaults.buttonColors()
|
||||||
|
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
|
// 1. Delete phone files (if delete-after-verify was on and user confirmed via checkbox)
|
||||||
|
if (hasPendingPhoneDeletes) {
|
||||||
|
state.deleteSpecificFromDevice(toDelete)
|
||||||
|
}
|
||||||
|
// 2. Delete soft-removed files from staging
|
||||||
|
if (hasPendingStagingCleanup) {
|
||||||
|
r.results.filter { it.photo.devicePath in softRemoved }.forEach { cr ->
|
||||||
|
try { cr.destinationPath.toFile().delete() } catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
state.navigateTo(Screen.CONNECT)
|
state.navigateTo(Screen.CONNECT)
|
||||||
state.selectNone()
|
state.selectNone()
|
||||||
},
|
},
|
||||||
modifier = Modifier.fillMaxWidth(),
|
colors = doneColor,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
|
||||||
) {
|
) {
|
||||||
Icon(Icons.Default.Home, contentDescription = null, modifier = Modifier.size(18.dp))
|
Icon(Icons.Default.Home, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
Text("Done — Back to Start")
|
Text(doneLabel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ResultSummaryHeader(success: Int, failure: Int, destination: String) {
|
private fun StatusChip(label: String, color: Color) {
|
||||||
Card(
|
Surface(shape = MaterialTheme.shapes.small,
|
||||||
colors = CardDefaults.cardColors(
|
color = color.copy(alpha = 0.15f),
|
||||||
containerColor = if (failure == 0)
|
contentColor = color) {
|
||||||
MaterialTheme.colorScheme.primaryContainer
|
Text(label, modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
else
|
style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.SemiBold)
|
||||||
MaterialTheme.colorScheme.errorContainer,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
|
||||||
Text(
|
|
||||||
if (failure == 0) "Import complete ✓" else "Import complete with errors",
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
)
|
|
||||||
Text("$success verified, $failure failed")
|
|
||||||
Text(
|
|
||||||
"→ $destination",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun CopyResultRow(result: CopyResult) {
|
private fun VerifyResultRow(
|
||||||
val hasRemoteHash = result.sourceSha256 != "(not available)"
|
result: CopyResult,
|
||||||
val isVerifiedWithHash = result.verified && result.error == null && hasRemoteHash
|
showCheckbox: Boolean,
|
||||||
val isVerifiedNoHash = result.verified && result.error == null && !hasRemoteHash
|
isChecked: Boolean,
|
||||||
|
canCheck: Boolean,
|
||||||
|
isSoftRemoved: Boolean,
|
||||||
|
onToggleCheck: () -> Unit,
|
||||||
|
onPreview: (CopyResult) -> Unit,
|
||||||
|
onToggleRemove: () -> Unit,
|
||||||
|
) {
|
||||||
val isFailed = result.error != null || !result.verified
|
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(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
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),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Icon(
|
ThumbnailChip(destinationPath = result.destinationPath.toString(),
|
||||||
when {
|
onClick = { if (!isSoftRemoved) onPreview(result) })
|
||||||
isFailed -> Icons.Default.Error
|
|
||||||
else -> Icons.Default.CheckCircle
|
if (showCheckbox && canCheck) {
|
||||||
},
|
Checkbox(checked = isChecked, onCheckedChange = { onToggleCheck() },
|
||||||
contentDescription = null,
|
modifier = Modifier.size(20.dp))
|
||||||
tint = when {
|
} else if (showCheckbox) {
|
||||||
isFailed -> MaterialTheme.colorScheme.error
|
Spacer(Modifier.width(20.dp))
|
||||||
isVerifiedWithHash -> MaterialTheme.colorScheme.primary
|
}
|
||||||
else -> MaterialTheme.colorScheme.secondary // verified, no hash
|
|
||||||
},
|
val icon = when { isFailed -> Icons.Default.Error; exifUnverified -> Icons.Default.Warning; else -> Icons.Default.CheckCircle }
|
||||||
modifier = Modifier.size(18.dp),
|
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)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
val displayName = if (result.convertedFromHeic)
|
val displayName = if (result.convertedFromHeic)
|
||||||
"${result.photo.filename} → ${result.destinationPath.fileName}"
|
"${result.photo.filename} → ${result.destinationPath.fileName}"
|
||||||
else result.photo.filename
|
else result.photo.filename
|
||||||
Text(displayName, style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Medium)
|
Text(displayName, style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Medium,
|
||||||
|
maxLines = 1, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis)
|
||||||
Text(
|
Text(
|
||||||
when {
|
when {
|
||||||
isFailed -> result.error ?: "Failed"
|
isFailed -> result.error ?: "Failed"
|
||||||
isVerifiedWithHash -> "SHA-256 verified · ${result.photo.sizeBytes / 1024} KB"
|
exifUnverified -> "⚠ Origin unknown" +
|
||||||
else -> "Copied · ${result.photo.sizeBytes / 1024} KB · local SHA-256: ${result.destSha256.take(12)}…"
|
if (showCheckbox) " — kept on phone" else ""
|
||||||
|
exifUnavailable -> "${result.photo.sizeBytes / 1024} KB"
|
||||||
|
else -> "✓ ${result.exifMake} · ${result.photo.sizeBytes / 1024} KB"
|
||||||
},
|
},
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = when {
|
color = when { isFailed -> MaterialTheme.colorScheme.error; exifUnverified -> MaterialTheme.colorScheme.tertiary; else -> MaterialTheme.colorScheme.onSurfaceVariant },
|
||||||
isFailed -> MaterialTheme.colorScheme.error
|
|
||||||
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
IconButton(onClick = onToggleRemove, modifier = Modifier.size(28.dp)) {
|
||||||
|
Icon(Icons.Default.Close, contentDescription = if (isSoftRemoved) "Undo remove" else "Remove from import",
|
||||||
|
tint = if (isSoftRemoved) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(16.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Removed" overlay label when soft-removed
|
||||||
|
if (isSoftRemoved) {
|
||||||
|
Box(modifier = Modifier.matchParentSize(), contentAlignment = Alignment.Center) {
|
||||||
|
Surface(shape = MaterialTheme.shapes.small,
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onSurfaceVariant) {
|
||||||
|
Text("Removed — tap ✕ to undo",
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
fontWeight = FontWeight.Medium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DeleteConfirmCard(count: Int, onConfirm: () -> Unit, onCancel: () -> Unit) {
|
private fun ThumbnailChip(destinationPath: String, onClick: () -> Unit) {
|
||||||
Card(
|
var bitmap by remember(destinationPath) { mutableStateOf<ImageBitmap?>(null) }
|
||||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer),
|
LaunchedEffect(destinationPath) {
|
||||||
modifier = Modifier.fillMaxWidth(),
|
val bytes = withContext(Dispatchers.IO) {
|
||||||
|
try { Files.readAllBytes(Paths.get(destinationPath)) } catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
if (bytes != null) runCatching { bitmap = SkiaImage.makeFromEncoded(bytes).toComposeImageBitmap() }
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.size(44.dp).clip(MaterialTheme.shapes.small)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||||
|
.clickable(onClick = onClick),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
|
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)) {
|
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Text(
|
Text(result.photo.filename, fontWeight = FontWeight.SemiBold)
|
||||||
"Delete $count files from phone?",
|
Text(when (result.exifVerified) {
|
||||||
fontWeight = FontWeight.SemiBold,
|
true -> "✓ Camera original — Make: ${result.exifMake}"
|
||||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
false -> "⚠ Origin unknown — may not be your photo${if (!result.exifMake.isNullOrBlank()) " (Make: ${result.exifMake})" else ""}"
|
||||||
)
|
null -> "EXIF not checked (install exiftool)"
|
||||||
Text(
|
}, style = MaterialTheme.typography.bodySmall,
|
||||||
"This is permanent. Only files that passed SHA-256 verification will be deleted.",
|
color = when (result.exifVerified) {
|
||||||
style = MaterialTheme.typography.bodySmall,
|
true -> MaterialTheme.colorScheme.primary
|
||||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
false -> MaterialTheme.colorScheme.tertiary
|
||||||
)
|
null -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
})
|
||||||
Button(
|
HorizontalDivider()
|
||||||
onClick = onConfirm,
|
Box(modifier = Modifier.fillMaxWidth().aspectRatio(4f / 3f),
|
||||||
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error),
|
contentAlignment = Alignment.Center) {
|
||||||
) { Text("Delete from Phone") }
|
if (bitmap != null) Image(bitmap = bitmap!!, contentDescription = null,
|
||||||
OutlinedButton(onClick = onCancel) { Text("Cancel") }
|
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") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,15 +100,28 @@ object ExifFilter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns true if a folder name is likely a camera-roll source
|
* Returns true if a folder name is likely a camera-roll source.
|
||||||
* (i.e. not screenshots, received images, etc.)
|
* Handles both Android path-based names and iOS classified group names.
|
||||||
*/
|
*/
|
||||||
fun isCameraRollFolder(folderName: String): Boolean {
|
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()
|
val lower = folderName.lowercase()
|
||||||
return excludedPathFragments.none { lower.contains(it) } &&
|
return excludedPathFragments.none { lower.contains(it) } &&
|
||||||
screenshotFilePrefixes.none { lower.startsWith(it) }
|
screenshotFilePrefixes.none { lower.startsWith(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Folders that should be completely hidden from the UI (not even in Other).
|
||||||
|
* Live Photo companions are proven by the LIVE badge on the HEIC — showing
|
||||||
|
* the MOVs separately adds noise with unverifiable placeholder thumbnails.
|
||||||
|
*/
|
||||||
|
fun isHiddenFolder(folderName: String): Boolean =
|
||||||
|
folderName == "Live Photo"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns photos that are HEIC files (and not Live Photo motion clips).
|
* Returns photos that are HEIC files (and not Live Photo motion clips).
|
||||||
* Used to determine whether to show the HEIC conversion warning.
|
* Used to determine whether to show the HEIC conversion warning.
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package com.bolenpad.photophetch.util
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves paths for external command-line tools (exiftool, adb, python3, etc.)
|
||||||
|
*
|
||||||
|
* macOS .app bundles launch with a minimal PATH (/usr/bin:/bin:/usr/sbin:/sbin)
|
||||||
|
* that doesn't include Homebrew. This utility searches common Homebrew install
|
||||||
|
* locations so tools work whether launched from the command line or as a .app.
|
||||||
|
*/
|
||||||
|
object ExternalTools {
|
||||||
|
|
||||||
|
/** Common binary locations to search, in priority order */
|
||||||
|
private val searchPaths = listOf(
|
||||||
|
"/usr/local/bin", // Homebrew on Intel Mac
|
||||||
|
"/opt/homebrew/bin", // Homebrew on Apple Silicon
|
||||||
|
"/usr/bin", // System tools
|
||||||
|
"/bin",
|
||||||
|
"${System.getProperty("user.home")}/.local/bin", // pipx and user installs
|
||||||
|
"/usr/local/opt/exiftool/bin", // Homebrew formula-specific
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the full path to a tool binary.
|
||||||
|
* Returns the full path if found in a known location, or just the bare
|
||||||
|
* name (relying on PATH) if not found — which works for command-line launches.
|
||||||
|
*/
|
||||||
|
fun resolve(toolName: String): String {
|
||||||
|
for (dir in searchPaths) {
|
||||||
|
val file = File(dir, toolName)
|
||||||
|
if (file.exists() && file.canExecute()) {
|
||||||
|
return file.absolutePath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return toolName // fall back to PATH lookup
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a tool is available (either in known paths or on PATH).
|
||||||
|
*/
|
||||||
|
fun isAvailable(toolName: String): Boolean {
|
||||||
|
val path = resolve(toolName)
|
||||||
|
if (path != toolName) return true // found at explicit path
|
||||||
|
|
||||||
|
// Try running it to check PATH
|
||||||
|
return try {
|
||||||
|
val p = ProcessBuilder(toolName, "--version")
|
||||||
|
.redirectErrorStream(true).start()
|
||||||
|
p.inputStream.readBytes()
|
||||||
|
p.waitFor() == 0 || true // any exit = it exists
|
||||||
|
} catch (_: Exception) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,10 +56,13 @@ object ThumbnailCache {
|
|||||||
val success = pullFn(devicePath, tmpFile)
|
val success = pullFn(devicePath, tmpFile)
|
||||||
if (!success || !tmpFile.exists() || tmpFile.toFile().length() == 0L) return null
|
if (!success || !tmpFile.exists() || tmpFile.toFile().length() == 0L) return null
|
||||||
|
|
||||||
// Resize to thumbnail via sips (macOS built-in)
|
// Resize to thumbnail via sips — also converts HEIC→JPEG
|
||||||
val sips = ProcessBuilder(
|
val sips = ProcessBuilder(
|
||||||
"sips", "--resampleWidth", "240",
|
"sips",
|
||||||
tmpFile.toString(), "--out", cachePath.toString()
|
"--resampleWidth", "240",
|
||||||
|
"--setProperty", "format", "jpeg",
|
||||||
|
tmpFile.toString(),
|
||||||
|
"--out", cachePath.toString()
|
||||||
).redirectErrorStream(true).start()
|
).redirectErrorStream(true).start()
|
||||||
sips.inputStream.readBytes()
|
sips.inputStream.readBytes()
|
||||||
sips.waitFor()
|
sips.waitFor()
|
||||||
|
|||||||
@@ -1,13 +1,34 @@
|
|||||||
<configuration>
|
<configuration>
|
||||||
|
|
||||||
|
<!-- Console output for development (./gradlew run) -->
|
||||||
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
|
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
|
||||||
<encoder>
|
<encoder>
|
||||||
<pattern>%d{HH:mm:ss} [%-5level] %logger{20} - %msg%n</pattern>
|
<pattern>%d{HH:mm:ss} [%-5level] %logger{20} - %msg%n</pattern>
|
||||||
</encoder>
|
</encoder>
|
||||||
</appender>
|
</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">
|
<root level="WARN">
|
||||||
<appender-ref ref="CONSOLE" />
|
<appender-ref ref="CONSOLE" />
|
||||||
|
<appender-ref ref="FILE" />
|
||||||
</root>
|
</root>
|
||||||
|
|
||||||
<logger name="com.bolenpad.photophetch" level="DEBUG" />
|
|
||||||
</configuration>
|
</configuration>
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import os
|
|||||||
import asyncio
|
import asyncio
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
MEDIA_EXTENSIONS = {
|
MEDIA_EXTENSIONS = {
|
||||||
'jpg', 'jpeg', 'heic', 'heif', 'avif', 'webp', 'png',
|
'jpg', 'jpeg', 'heic', 'heif', 'avif', 'webp', 'png',
|
||||||
'dng', 'raw', 'arw', 'cr2', 'cr3', 'nef', 'orf', 'rw2',
|
'dng', 'raw', 'arw', 'cr2', 'cr3', 'nef', 'orf', 'rw2',
|
||||||
@@ -31,6 +33,50 @@ VIDEO_EXTENSIONS = {
|
|||||||
'mp4', 'm4v', 'mov', 'mpeg', 'mpg', '3gp', '3g2', 'avi', 'mkv', 'webm',
|
'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():
|
async def get_lockdown():
|
||||||
from pymobiledevice3.lockdown import create_using_usbmux
|
from pymobiledevice3.lockdown import create_using_usbmux
|
||||||
@@ -78,12 +124,43 @@ async def cmd_list_photos():
|
|||||||
except Exception:
|
except Exception:
|
||||||
continue
|
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:
|
for filename in entries:
|
||||||
ext = filename.rsplit('.', 1)[-1].lower() if '.' in filename else ''
|
ext = filename.rsplit('.', 1)[-1].lower() if '.' in filename else ''
|
||||||
if ext not in MEDIA_EXTENSIONS:
|
if ext not in MEDIA_EXTENSIONS:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
device_path = f'{folder_path}/{filename}'
|
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
|
# Get file metadata via stat
|
||||||
size_bytes = 0
|
size_bytes = 0
|
||||||
@@ -110,9 +187,11 @@ async def cmd_list_photos():
|
|||||||
'filename': filename,
|
'filename': filename,
|
||||||
'sizeBytes': size_bytes,
|
'sizeBytes': size_bytes,
|
||||||
'dateIso': date_iso,
|
'dateIso': date_iso,
|
||||||
'sourceFolder': folder,
|
'sourceFolder': classify_iphone_file(filename, all_filenames_in_folder, size_bytes),
|
||||||
'mediaType': 'VIDEO' if ext in VIDEO_EXTENSIONS else 'PHOTO',
|
'mediaType': 'VIDEO' if ext in VIDEO_EXTENSIONS else 'PHOTO',
|
||||||
'isHeic': ext in ('heic', 'heif'),
|
'isHeic': ext in ('heic', 'heif'),
|
||||||
|
'isLivePhotoMotion': is_live_motion,
|
||||||
|
'companionDevicePath': companion_device_path,
|
||||||
})
|
})
|
||||||
|
|
||||||
print(json.dumps(photos))
|
print(json.dumps(photos))
|
||||||
@@ -162,6 +241,11 @@ async def cmd_batch_thumbs(pairs_json: str):
|
|||||||
Pull thumbnails for multiple files in a single AFC session.
|
Pull thumbnails for multiple files in a single AFC session.
|
||||||
Input: JSON array of [device_path, local_path] pairs.
|
Input: JSON array of [device_path, local_path] pairs.
|
||||||
Output: JSON array of {devicePath, ok} results.
|
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:
|
try:
|
||||||
pairs = json.loads(pairs_json)
|
pairs = json.loads(pairs_json)
|
||||||
@@ -169,16 +253,22 @@ async def cmd_batch_thumbs(pairs_json: str):
|
|||||||
afc = await get_afc(lockdown)
|
afc = await get_afc(lockdown)
|
||||||
results = []
|
results = []
|
||||||
for device_path, local_path in pairs:
|
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:
|
try:
|
||||||
os.makedirs(os.path.dirname(os.path.abspath(local_path)), exist_ok=True)
|
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:
|
try:
|
||||||
data = await afc.fread(device_path, 512 * 1024, 0)
|
data = await afc.fread(device_path, 512 * 1024, 0)
|
||||||
except Exception:
|
|
||||||
await afc.pull(device_path, local_path)
|
|
||||||
results.append({'devicePath': device_path, 'ok': True})
|
|
||||||
continue
|
|
||||||
with open(local_path, 'wb') as f:
|
with open(local_path, 'wb') as f:
|
||||||
f.write(data)
|
f.write(data)
|
||||||
|
except Exception:
|
||||||
|
await afc.pull(device_path, local_path)
|
||||||
results.append({'devicePath': device_path, 'ok': True})
|
results.append({'devicePath': device_path, 'ok': True})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
results.append({'devicePath': device_path, 'ok': False, 'error': str(e)})
|
results.append({'devicePath': device_path, 'ok': False, 'error': str(e)})
|
||||||
|
|||||||
Reference in New Issue
Block a user