Compare commits

34 Commits

Author SHA1 Message Date
Kyle Bolen
29595d5a7f Release v1.0.0 2026-08-02 03:53:04 +00:00
Kyle Bolen
ec1d2f1660 Restructure release workflow: dev prepares, Mac builds+uploads
Two scripts:
- prepare-release.sh (run by me/Kiro on dev machine):
  Writes releases/vX.Y.Z.md, bumps packageVersion, commits, tags, pushes

- release.sh (run by you on Mac):
  Reads version from current git tag, reads notes from releases/<tag>.md,
  builds DMG, creates Gitea release, uploads DMG.
  No user input needed — just run ./release.sh

Workflow:
  Dev:  ./prepare-release.sh 1.0.0 'Release notes'
  Mac:  git fetch --tags && git checkout v1.0.0 && ./release.sh
2026-08-02 03:51:42 +00:00
Kyle Bolen
b437cbf6e1 release.sh: auto-generate notes from git log, prompt to accept/edit/quit 2026-08-02 03:49:14 +00:00
Kyle Bolen
0042f3b6ba Add release.sh: one-command build + tag + Gitea release upload
Usage: ./release.sh 1.0.0 'Release notes here'
Or:    ./release.sh 1.0.0   (prompts for notes)

Requires GITEA_TOKEN env var (or prompts).
Does: version bump → commit → build DMG → tag → push → create
Gitea release → upload DMG → print download URL.
2026-08-02 03:46:26 +00:00
Kyle Bolen
694dca8817 gradle.properties: skip JDK vendor check for packaging
Compose Desktop warns about Homebrew JDK but it works fine for
macOS packaging. compose.desktop.packaging.checkJdkVendor=false
suppresses the error without switching JDKs.
2026-08-02 03:19:56 +00:00
Kyle Bolen
c34e1201ab Packaging setup: DMG distribution + proper log location
logback.xml: write to ~/Library/Logs/PhotoPhetch/ (standard macOS
app log location), rolling 7-day retention, 50MB cap. Console output
retained for development runs.

build.gradle.kts: fix packaging config — remove missing icon.icns
reference, add copyright, add signing block (disabled by default).

To build:
  ./gradlew packageDmg
  # Output: build/compose/binaries/main/dmg/PhotoPhetch-1.0.0.dmg

Logs viewable in Console.app or:
  tail -f ~/Library/Logs/PhotoPhetch/photophetch.log
2026-08-02 03:12:44 +00:00
Kyle Bolen
dc32a3a3d7 Fix EXIF verification for non-Apple devices + sticky preserve folder
EXIF verification: any non-blank Make is now considered verified.
Only absent/empty Make triggers 'Origin unknown' warning. Samsung,
Google, etc. are all legitimate camera makes.
Also cache exifMake to avoid calling exiftool twice per file.

Preserve folder structure: add defaultPreserveFolderStructure to
AppConfig. Toggling in ImportScreen saves it as the new default.
prepareImport() reads from config instead of always resetting to true.
2026-07-30 12:48:15 +00:00
Kyle Bolen
a8906fa265 Fix Color import in preview dialog LIVE badge 2026-07-30 06:36:04 +00:00
Kyle Bolen
6912deac14 Remove video preview - JavaFX MOV playback not viable in Compose Desktop
Removed LivePhotoPlayer.kt, JavaFX plugin/deps from build.gradle.kts
and libs.versions.toml. PhotoPreviewDialog shows still HEIC only.
LIVE badge in dialog uses the same green as in the grid.
2026-07-30 06:35:34 +00:00
Kyle Bolen
bbb3bb1aae Use JavaFX Gradle plugin for proper module path setup
Replace manual classifier-based jar deps with the openjfx javafx-plugin
which correctly sets up the JavaFX module path, native lib paths, and
JVM args needed for media codecs (AVFoundation on macOS).

This fixes 'media type not supported' which was caused by JavaFX being
on classpath instead of module path — native .dylib codecs only load
when JavaFX runs as a named module.
2026-07-30 06:25:38 +00:00
Kyle Bolen
3c2ec07bdb Fix JavaFX media: extract native libs from jar to temp dir
The 'media type not supported' error occurs because the JavaFX native
codec .dylib files aren't extracted when loading from classpath.
extractJavafxNativeLibs() finds the javafx-media jar, extracts the
.dylib files to a temp dir, and adds it to java.library.path.

Also remove --add-modules JVM args (caused boot layer init failure).
2026-07-30 06:20:03 +00:00
Kyle Bolen
d128835c0d Add JavaFX module args and auto-detect mac/mac-aarch64 platform
--add-modules javafx.media,javafx.swing,javafx.graphics needed for
native codec loading on macOS (AVFoundation for MOV playback).
Platform auto-detects mac-aarch64 vs mac based on os.arch.
2026-07-30 06:18:10 +00:00
Kyle Bolen
f21e8dbd3a Fix LivePhotoPlayer: proper init order, error logging, setOnReady
Use SwingUtilities.invokeLater + Platform.runLater for correct init
order. Player only starts in setOnReady callback. Error logging added
so we can see what's failing in the terminal.
2026-07-30 06:10:35 +00:00
Kyle Bolen
7b28c87f2c Fix preview dialog: scrollable + cap video height at 200dp
Dialog content is now scrollable so buttons are always reachable.
Video player capped at 200dp height — no longer pushes buttons
off screen when the JavaFX panel expands.
2026-07-30 06:09:15 +00:00
Kyle Bolen
42039bade1 Live Photo inline video player using JavaFX MediaPlayer
Add OpenJFX dependencies (javafx-media, javafx-swing, javafx-graphics,
javafx-base 21.0.3 mac platform) to build.gradle.kts.

LivePhotoPlayer.kt: embeds JavaFX MediaPlayer in a SwingPanel via
JFXPanel Swing interop. Auto-plays, loops infinitely, muted.

PhotoPreviewDialog: pulls MOV companion to temp file, shows still HEIC
above and looping video below in a 16:9 panel. Falls back gracefully
if JavaFX is not available (isJavaFxMediaAvailable() check).
2026-07-30 06:03:43 +00:00
Kyle Bolen
4171ff04d2 Fix HEIC preview + clean up EXIF unverified messages
PhotoPreviewDialog: always use sips-converted JPEG from cache for
display. On cache miss: pull file, run sips --setProperty format jpeg
to convert, cache the JPEG. HEIC now displays inline without QuickTime.

Verify/VerifyRow: replace 'Make: absent — not a confirmed camera original'
with 'Origin unknown' (row) and 'Origin unknown — may not be your photo'
(preview dialog). Less developer-jargon, more understandable.
2026-07-30 05:58:51 +00:00
Kyle Bolen
bc3a89e858 Fix selection indicator: high-contrast white circle on any background
Replace low-contrast RadioButtonUnchecked (50% white) and primary-tint
CheckCircle with a custom indicator:
- Unselected: white circle (90% opacity) with dark border — visible on
  both light and dark thumbnails
- Selected: filled primary-color circle with white checkmark inside

The circle is also directly clickable (same as the thumbnail tap).
2026-07-30 05:51:12 +00:00
Kyle Bolen
de240be014 Fix mangled showCheckbox expression from sed substitution 2026-07-30 05:40:01 +00:00
Kyle Bolen
8681bddb60 Verify screen: Done button is the single commit action
Done button now handles everything in one tap:
- 'Confirm: delete N from phone + remove N from staging' — red button
- 'Confirm: delete N files from phone' — red button
- 'Confirm: remove N from staging' — red button
- 'Done — Back to Start' — normal blue (nothing to commit)

Deletes phone files (if delete-after-verify + checkboxes), removes
soft-removed files from staging, then navigates home.

Removed separate 'Delete N files' button — one action is clearer.
2026-07-30 05:39:38 +00:00
Kyle Bolen
8cf8a7a197 Fix: rename removedFromImport to softRemoved (missed in previous commit) 2026-07-30 05:38:06 +00:00
Kyle Bolen
80eee705a7 Verify screen: soft-remove with undo, reactive summary counts
Soft-remove (X button):
- Row dims to 40% opacity with 'Removed — tap ✕ to undo' overlay
- X button turns blue to indicate it's now an undo action
- File stays on screen, counts update immediately
- 'N removed' chip appears in summary header
- 'N removed — tap to undo all' chip in filter bar with X to undo all

On Done: soft-removed files are deleted from staging at that point.
Until Done is clicked, all removals are fully reversible.
2026-07-30 05:37:29 +00:00
Kyle Bolen
0139d47d5f Fix LIVE badge visibility + checkbox contrast
LIVE badge: explicit Color(0xFF00C853) green with white bold text,
no longer uses secondary color scheme which was dark-on-dark.

Checkbox: show faint white RadioButtonUnchecked when not selected so
user can see the selection zone on dark/thumbnail backgrounds.
2026-07-30 05:33:55 +00:00
Kyle Bolen
6863bad7a0 Browse + Verify screen improvements
DenseThumb:
- LIVE badge moved to bottom-left (no overlap with checkbox)
- Video size badge at bottom-left
- Checkbox stays top-right only
- + button at bottom-right opens PhotoPreviewDialog

PhotoPreviewDialog (BrowseScreen):
- Shows cached thumbnail in a dialog
- For Live Photos: also pulls and opens MOV via macOS 'open'

VerifyScreen redesign:
- Filter bar: All / OK / Unverified / Failed
- Thumbnail chip (44dp) on left of each row — click to preview
- Errors/warnings get tinted row background (red/yellow)
- X button on each row to remove from import (deletes from staging)
- Status chips in summary header
- Checkbox-based delete still works when deleteAfterVerify=on
- Delete confirmation as AlertDialog (cleaner than inline card)
2026-07-30 05:30:43 +00:00
Kyle Bolen
f90baa92d4 Fix exiftool warning pollution + verify screen messaging
Copier: exiftool output parsed with stderr separate; Warning:/Error:
lines stripped from stdout so IPTC/XMP warnings don't appear as
the Make value.

VerifyScreen:
- 'kept on phone' suffix on unverified only shown when deleteAfterVerify=on
- Summary header adapts: no delete messaging when delete is off,
  '(pre-unchecked)' label only when checkboxes are visible
2026-07-30 05:23:48 +00:00
Kyle Bolen
35984b0b85 Verify screen: per-file checkboxes for delete + preview dialog
VerifyScreen redesigned as review-before-delete:
- All imported files shown with status icons (green/yellow/red)
- Checkboxes appear when deleteAfterVerify is on
- Pre-checked: EXIF-verified files (safe to delete)
- Pre-unchecked: EXIF-unverified files (kept on phone by default)
- Failed copies: no checkbox (excluded from deletion entirely)
- Uncheck any file to protect it from deletion
- 'Delete N files' button → confirmation card
- Confirmation shows count of excluded unverified files
- Click any row → PreviewDialog with full image + EXIF status

AppState: add deleteSpecificFromDevice(devicePaths) to delete exactly
the set of paths the user confirmed on the verify screen.
2026-07-30 05:16:09 +00:00
Kyle Bolen
1d239fb60a EXIF verification at import time + click-to-preview on verify screen
Copier: run exiftool -Make -s3 on each pulled file after copy.
- exifVerified=true: Make contains expected camera make
- exifVerified=false: Make absent or unexpected (not deleted from phone)
- exifVerified=null: exiftool not installed

CopyResult: add exifVerified + exifMake fields.

deleteVerifiedFromDevice: skip files where exifVerified=false.

VerifyScreen:
- Green row: EXIF verified camera original
- Yellow row: EXIF unverified — shows Make value, warns not deleted
- Red row: copy failed
- Click any row: PreviewDialog shows full image + EXIF status + path

ConnectScreen: show warning banner if exiftool not installed, with
install command (brew install exiftool).
2026-07-30 05:14:08 +00:00
Kyle Bolen
347f182c30 Hide Live Photo folder entirely from UI
ExifFilter.isHiddenFolder(): returns true for 'Live Photo' folder.
buildFolderList: filters hidden folders before building FolderInfo list.
Live Photo MOVs are now completely invisible — the LIVE badge on the
HEIC is the only signal needed. No placeholder thumbnails in the panel.
2026-07-30 04:55:18 +00:00
Kyle Bolen
595d583f6e Live Photo handling: hide MOV companions, LIVE badge, atomic import/delete
PhonePhoto: add companionDevicePath (MOV path stored on the HEIC).

iOS helper: detect Live Photo pairs by IMG_XXXX filename matching.
MOVs with a paired HEIC get isLivePhotoMotion=true, sourceFolder='Live Photo'.
HEICs with a paired MOV get isLivePhotoStill=true, companionDevicePath set.

Grid: Live Photo MOVs appear only in 'Live Photo' folder (Other section).
HEIC stills show a LIVE badge. Camera folder stays clean.

Import: 'Import Live Photo motion clips' toggle (default off).
When on, Copier pulls the companion MOV alongside the HEIC atomically.

Delete: deleteVerifiedFromDevice also deletes the companion MOV when
deleting a Live Photo still.
2026-07-30 04:43:41 +00:00
Kyle Bolen
6876709051 Detect Live Photo companions and show video file size
iOS helper: classify_iphone_file now detects Live Photo MOV companions
by checking if IMG_XXXX.MOV has a matching IMG_XXXX.HEIC/JPG in the
same DCIM folder. Falls back to size heuristic (<5MB = Live Photo).

Live Photo companions appear in 'Live Photo' group (Other section) —
excluded from camera-roll default selection.

DenseThumb: show file size on video badge (e.g. '▶ 2MB' or '▶ 42MB')
so Live Photo clips (small) are visually distinct from real videos (large).
2026-07-30 04:24:22 +00:00
Kyle Bolen
e6d27d3ab3 iPhone file classification by filename pattern
iOS helper: classify_iphone_file() groups photos by name pattern:
- Camera: IMG_XXXX.{HEIC,JPG} — camera originals
- Edited: IMG_EXXXX.{HEIC,JPG} — Photos.app edits
- Videos: IMG_XXXX.MOV and other video files
- Screenshots: any .PNG (iPhone camera never produces PNG)
- Screen Recordings: RPReplay_Final*.mp4
- Other: everything else (web saves, app exports, etc.)

ExifFilter.isCameraRollFolder: Camera and Edited are camera-roll,
Screenshots/Screen Recordings/Other are not.

sourceFolder now reflects these groups instead of raw DCIM folder names
(145APPLE etc.) so the left panel shows meaningful categories.
2026-07-30 04:19:28 +00:00
Kyle Bolen
716124bff5 Fix scroll-back placeholders: in-memory bitmap cache in DensePhotoGrid
bitmapCache (MutableMap keyed on devicePath) lives at DensePhotoGrid
level and survives Compose cell recycling. DenseThumb checks memory
cache first — scroll-back is now instant for already-loaded cells.
Disk cache (ThumbnailCache) is only hit when bitmap not in memory.
2026-07-30 04:02:54 +00:00
Kyle Bolen
558a7add89 HEIC thumbnails: pull full file (partial reads unreliable for sips conversion)
1MB partial HEIC reads are not sufficient for sips to convert to JPEG.
Revert to full file pull for HEIC thumbnails. Files are cached permanently
so each HEIC is only transferred once — subsequent launches skip already-cached files.
2026-07-30 03:57:44 +00:00
Kyle Bolen
30b600e2a4 HEIC thumbnails: extract embedded thumbnail instead of full file pull
For HEIC photos: read first 1MB (thumbnail track is near file start),
then use sips -getProperty thumbnail to extract the embedded JPEG
thumbnail without decoding the full image. Falls back to full resample
if thumbnail extraction fails.

For JPEG/PNG: unchanged, 512KB partial read + resample.

This avoids transferring 3-5MB per HEIC photo just for a thumbnail.
2026-07-30 03:54:40 +00:00
Kyle Bolen
6ed4362856 Fix HEIC thumbnails: pull full file + convert to JPEG via sips
HEIC is not front-loaded — partial 512KB reads produce broken output.
batch-thumbs now pulls full file for HEIC/HEIF, partial for JPEG/PNG.

sips: add --setProperty format jpeg to all thumbnail conversions so
HEIC files are always converted to JPEG. SkiaImage on JVM desktop
cannot decode HEIC natively — JPEG is required.
2026-07-30 03:53:24 +00:00
21 changed files with 1089 additions and 274 deletions

View File

@@ -43,9 +43,14 @@ compose.desktop {
packageVersion = "1.0.0" packageVersion = "1.0.0"
description = "Import photos from phones into PhotoPhile" description = "Import photos from phones into PhotoPhile"
vendor = "bolenpad" vendor = "bolenpad"
copyright = "© 2026 Kyle Bolen"
macOS { macOS {
bundleID = "com.bolenpad.photophetch" bundleID = "com.bolenpad.photophetch"
iconFile.set(project.file("src/main/resources/icon.icns")) // Uncomment and add icon.icns to src/main/resources/ for a custom icon:
// iconFile.set(project.file("src/main/resources/icon.icns"))
signing {
sign.set(false) // set true + add identity for App Store / Gatekeeper
}
} }
} }

1
gradle.properties Normal file
View File

@@ -0,0 +1 @@
compose.desktop.packaging.checkJdkVendor=false

68
prepare-release.sh Executable file
View File

@@ -0,0 +1,68 @@
#!/bin/bash
# prepare-release.sh — prepare a release commit and tag (run on dev machine)
#
# Usage:
# ./prepare-release.sh 1.0.0 "Release notes here..."
#
# This script:
# 1. Writes release notes to releases/v<version>.md
# 2. Updates packageVersion in build.gradle.kts
# 3. Commits both
# 4. Creates and pushes the annotated tag
#
# After this, the Mac operator runs ./release.sh to build and upload.
set -euo pipefail
if [ $# -lt 2 ]; then
echo "Usage: $0 <version> <release-notes>"
echo " Example: $0 1.0.0 'First release with Android and iPhone support'"
exit 1
fi
VERSION="$1"
TAG="v${VERSION}"
NOTES="$2"
# ── Pre-flight ────────────────────────────────────────────────────────────────
if ! git diff --quiet || ! git diff --cached --quiet; then
echo "ERROR: Uncommitted changes. Commit or stash first."
exit 1
fi
if git tag | grep -q "^${TAG}$"; then
echo "ERROR: Tag $TAG already exists."
exit 1
fi
# ── Write release notes ───────────────────────────────────────────────────────
mkdir -p releases
NOTES_FILE="releases/${TAG}.md"
cat > "$NOTES_FILE" << EOF
$NOTES
EOF
echo "→ Wrote $NOTES_FILE"
# ── Update version ────────────────────────────────────────────────────────────
sed -i "s/packageVersion = \"[^\"]*\"/packageVersion = \"${VERSION}\"/" build.gradle.kts
echo "→ Updated packageVersion to $VERSION"
# ── Commit ────────────────────────────────────────────────────────────────────
git add "$NOTES_FILE" build.gradle.kts
git commit -m "Release $TAG"
echo "→ Committed release files"
# ── Tag ───────────────────────────────────────────────────────────────────────
git tag -a "$TAG" -m "$NOTES"
echo "→ Created tag $TAG"
# ── Push ──────────────────────────────────────────────────────────────────────
echo "→ Pushing to origin..."
GIT_ASKPASS='' GIT_TERMINAL_PROMPT=0 git push origin HEAD:refs/heads/main 2>&1 || \
echo " (push to main may require running from Mac — tag is local)"
GIT_ASKPASS='' GIT_TERMINAL_PROMPT=0 git push origin "$TAG" 2>&1 || \
echo " (tag push may require running from Mac)"
echo ""
echo "✓ Release $TAG prepared."
echo " On your Mac: git fetch --tags && git checkout $TAG && ./release.sh"

119
release.sh Executable file
View File

@@ -0,0 +1,119 @@
#!/bin/bash
# release.sh — build and publish a PhotoPhetch release to Gitea
#
# Usage (on your Mac after pulling tagged commit):
# ./release.sh
#
# The release version and notes are read from the current git tag and
# releases/<tag>.md — both committed by the dev machine before tagging.
#
# Requires GITEA_TOKEN environment variable.
# Get a token: https://git.bolenpad.com/user/settings/applications
# Add to ~/.zshrc: export GITEA_TOKEN=your_token_here
set -euo pipefail
# ── Config ───────────────────────────────────────────────────────────────────
GITEA_URL="https://git.bolenpad.com"
GITEA_USER="kbolen"
GITEA_REPO="photophetch"
# ── Resolve version from current git tag ─────────────────────────────────────
TAG=$(git describe --exact-match --tags HEAD 2>/dev/null || echo "")
if [ -z "$TAG" ]; then
echo "ERROR: HEAD is not on a tag. Pull the latest and checkout the release tag."
echo " git fetch --tags && git checkout v1.0.0"
exit 1
fi
VERSION="${TAG#v}" # strip leading 'v'
echo "→ Building release $TAG (version $VERSION)"
# ── Read release notes from releases/<tag>.md ────────────────────────────────
NOTES_FILE="releases/${TAG}.md"
if [ ! -f "$NOTES_FILE" ]; then
echo "ERROR: Release notes file not found: $NOTES_FILE"
echo " The dev machine should have committed this before tagging."
exit 1
fi
NOTES=$(cat "$NOTES_FILE")
echo "→ Release notes: $NOTES_FILE"
# ── Gitea token ───────────────────────────────────────────────────────────────
if [ -z "${GITEA_TOKEN:-}" ]; then
read -rsp "GITEA_TOKEN not set. Enter token: " GITEA_TOKEN
echo
fi
# ── Check packageVersion matches tag ─────────────────────────────────────────
GRADLE_VERSION=$(grep 'packageVersion' build.gradle.kts | sed 's/.*"\(.*\)".*/\1/')
if [ "$GRADLE_VERSION" != "$VERSION" ]; then
echo "WARNING: build.gradle.kts packageVersion ($GRADLE_VERSION) != tag version ($VERSION)"
echo "Continue anyway? [y/N]"
read -r CONFIRM
[[ "$CONFIRM" == "y" || "$CONFIRM" == "Y" ]] || exit 1
fi
# ── Build DMG ─────────────────────────────────────────────────────────────────
echo "→ Building DMG (this takes a few minutes)..."
./gradlew packageDmg
DMG_PATH=$(find build/compose/binaries/main/dmg -name "*.dmg" | head -1)
if [ -z "$DMG_PATH" ]; then
echo "ERROR: No DMG found in build/compose/binaries/main/dmg/"
exit 1
fi
DMG_NAME=$(basename "$DMG_PATH")
echo "→ Built: $DMG_PATH"
# ── Check release doesn't already exist ──────────────────────────────────────
EXISTING=$(curl -s -o /dev/null -w "%{http_code}" \
"${GITEA_URL}/api/v1/repos/${GITEA_USER}/${GITEA_REPO}/releases/tags/${TAG}" \
-H "Authorization: token ${GITEA_TOKEN}")
if [ "$EXISTING" = "200" ]; then
echo "ERROR: Release $TAG already exists on Gitea."
exit 1
fi
# ── Create Gitea release ──────────────────────────────────────────────────────
echo "→ Creating Gitea release $TAG..."
# Escape notes for JSON
NOTES_JSON=$(echo "$NOTES" | python3 -c "import sys,json; print(json.dumps(sys.stdin.read()))")
RELEASE_RESPONSE=$(curl -s -X POST \
"${GITEA_URL}/api/v1/repos/${GITEA_USER}/${GITEA_REPO}/releases" \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d "{
\"tag_name\": \"${TAG}\",
\"name\": \"PhotoPhetch ${TAG}\",
\"body\": ${NOTES_JSON},
\"draft\": false,
\"prerelease\": false
}")
RELEASE_ID=$(echo "$RELEASE_RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])" 2>/dev/null)
if [ -z "$RELEASE_ID" ]; then
echo "ERROR: Failed to create release. Response:"
echo "$RELEASE_RESPONSE"
exit 1
fi
echo "→ Created release ID $RELEASE_ID"
# ── Upload DMG ────────────────────────────────────────────────────────────────
echo "→ Uploading $DMG_NAME..."
UPLOAD_RESPONSE=$(curl -s -X POST \
"${GITEA_URL}/api/v1/repos/${GITEA_USER}/${GITEA_REPO}/releases/${RELEASE_ID}/assets?name=${DMG_NAME}" \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary "@${DMG_PATH}")
DOWNLOAD_URL=$(echo "$UPLOAD_RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('browser_download_url',''))" 2>/dev/null)
if [ -z "$DOWNLOAD_URL" ]; then
echo "WARNING: Upload response unexpected:"
echo "$UPLOAD_RESPONSE"
else
echo ""
echo "✓ Released: $DOWNLOAD_URL"
echo " Release page: ${GITEA_URL}/${GITEA_USER}/${GITEA_REPO}/releases/tag/${TAG}"
fi

0
releases/.gitkeep Normal file
View File

46
releases/v1.0.0.md Normal file
View 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

View File

@@ -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}")

View File

@@ -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 */

View File

@@ -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,
) )

View File

@@ -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),

View File

@@ -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("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
}
}
} }

View File

@@ -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
sips.inputStream.readBytes() val sips = ProcessBuilder(
sips.waitFor() "sips", "--resampleWidth", "240",
"--setProperty", "format", "jpeg",
tmpFile.toString(), "--out", cachePath.toString()
).redirectErrorStream(true).start()
sips.inputStream.readBytes()
sips.waitFor()
cachePath.toFile().exists() && cachePath.toFile().length() > 0
} else {
// JPEG/PNG: simple resample
val sips = ProcessBuilder(
"sips", "--resampleWidth", "240",
"--setProperty", "format", "jpeg",
tmpFile.toString(), "--out", cachePath.toString()
).redirectErrorStream(true).start()
sips.inputStream.readBytes()
sips.waitFor()
cachePath.toFile().exists() && cachePath.toFile().length() > 0
}
tmpFile.toFile().delete() 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,14 +426,38 @@ 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()

View File

@@ -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") }
}
}
}
}

View File

@@ -46,6 +46,18 @@ fun ConnectScreen(state: AppState) {
StagingNotConfiguredBanner(onConfigureClick = { state.navigateTo(Screen.SETTINGS) }) StagingNotConfiguredBanner(onConfigureClick = { state.navigateTo(Screen.SETTINGS) })
} }
// exiftool warning — checked once per session
val exiftoolMissing = remember {
try {
val p = ProcessBuilder("exiftool", "-ver").redirectErrorStream(true).start()
p.inputStream.readBytes()
p.waitFor() != 0
} catch (_: Exception) { true }
}
if (exiftoolMissing) {
ExiftoolMissingBanner()
}
// Device scan area // Device scan area
when (val scan = scanState) { when (val scan = scanState) {
is DeviceScanState.Idle -> { is DeviceScanState.Idle -> {
@@ -200,3 +212,25 @@ private fun StagingNotConfiguredBanner(onConfigureClick: () -> Unit) {
} }
} }
} }
@Composable
private fun ExiftoolMissingBanner() {
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.tertiaryContainer),
modifier = Modifier.width(400.dp),
) {
Column(modifier = Modifier.padding(12.dp)) {
Text(
"exiftool not installed",
style = MaterialTheme.typography.bodySmall,
fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onTertiaryContainer,
)
Text(
"EXIF verification will be skipped during import. Install with: brew install exiftool",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onTertiaryContainer,
)
}
}
}

View File

@@ -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) {
Icon(Icons.Default.CheckCircle, contentDescription = null,
tint = Color.White, modifier = Modifier.size(18.dp))
} }
} }
if (isSelected) {
Icon(Icons.Default.CheckCircle, contentDescription = null, // Bottom-left: LIVE badge or video size badge
tint = MaterialTheme.colorScheme.primary, if (photo.isLivePhotoStill) {
modifier = Modifier.align(Alignment.TopEnd).padding(2.dp).size(16.dp)) 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)
}
} }
} }
} }

View File

@@ -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",

View File

@@ -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)) Column(modifier = Modifier.fillMaxSize().padding(horizontal = 20.dp, vertical = 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 // ── Summary — counts exclude soft-removed files ───────────────────
val listState = rememberLazyListState() val activeResults = r.results.filter { it.photo.devicePath !in softRemoved }
val failCount = activeResults.count { it.error != null || !it.verified }
val unverifiedCount = activeResults.count { it.exifVerified == false }
val okCount = activeResults.count { it.verified && it.error == null && it.exifVerified != false }
val removedCount = softRemoved.size
val topShadowAlpha by remember { Card(colors = CardDefaults.cardColors(
derivedStateOf { containerColor = if (failCount == 0) MaterialTheme.colorScheme.primaryContainer
val scrolled = listState.firstVisibleItemScrollOffset + else MaterialTheme.colorScheme.errorContainer)) {
listState.firstVisibleItemIndex * 80 Row(modifier = Modifier.fillMaxWidth().padding(12.dp),
(scrolled / 60f).coerceIn(0f, 1f) horizontalArrangement = Arrangement.SpaceBetween,
} verticalAlignment = Alignment.CenterVertically) {
} Column {
val bottomShadowAlpha by remember { Text(if (failCount == 0) "Import complete ✓" else "Import complete with errors",
derivedStateOf { fontWeight = FontWeight.SemiBold)
val info = listState.layoutInfo Text("${r.job.stagingRoot}/${r.job.folderName}",
val lastVisible = info.visibleItemsInfo.lastOrNull() style = MaterialTheme.typography.bodySmall,
val total = info.totalItemsCount color = MaterialTheme.colorScheme.onSurfaceVariant)
if (lastVisible == null || lastVisible.index >= total - 1) 0f else 1f }
} Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
} if (failCount > 0) StatusChip("$failCount failed", MaterialTheme.colorScheme.error)
if (unverifiedCount > 0) StatusChip("$unverifiedCount", MaterialTheme.colorScheme.tertiary)
// Top edge: shadow drawn below the divider line above the list StatusChip("$okCount", MaterialTheme.colorScheme.primary)
if (topShadowAlpha > 0f) { if (removedCount > 0) StatusChip("$removedCount removed", MaterialTheme.colorScheme.outline)
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(
state = listState,
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp),
contentPadding = PaddingValues(vertical = 4.dp),
) {
items(r.results, key = { it.photo.devicePath }) { copyResult ->
CopyResultRow(copyResult)
}
}
// Bottom edge: shadow drawn above the divider line below the list
if (bottomShadowAlpha > 0f) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(16.dp)
.drawWithContent {
drawContent()
drawRect(
brush = Brush.verticalGradient(
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,
)
} }
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
Row( val rowBg = when {
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), isSoftRemoved -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
horizontalArrangement = Arrangement.spacedBy(8.dp), isFailed -> MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f)
verticalAlignment = Alignment.CenterVertically, exifUnverified -> MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.3f)
) { else -> Color.Transparent
Icon(
when {
isFailed -> Icons.Default.Error
else -> Icons.Default.CheckCircle
},
contentDescription = null,
tint = when {
isFailed -> MaterialTheme.colorScheme.error
isVerifiedWithHash -> MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.secondary // verified, no hash
},
modifier = Modifier.size(18.dp),
)
Column(modifier = Modifier.weight(1f)) {
val displayName = if (result.convertedFromHeic)
"${result.photo.filename}${result.destinationPath.fileName}"
else result.photo.filename
Text(displayName, style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Medium)
Text(
when {
isFailed -> result.error ?: "Failed"
isVerifiedWithHash -> "SHA-256 verified · ${result.photo.sizeBytes / 1024} KB"
else -> "Copied · ${result.photo.sizeBytes / 1024} KB · local SHA-256: ${result.destSha256.take(12)}"
},
style = MaterialTheme.typography.labelSmall,
color = when {
isFailed -> MaterialTheme.colorScheme.error
else -> MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
} }
}
@Composable Box(modifier = Modifier.fillMaxWidth()) {
private fun DeleteConfirmCard(count: Int, onConfirm: () -> Unit, onCancel: () -> Unit) { Row(
Card( modifier = Modifier.fillMaxWidth()
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer), .background(rowBg)
modifier = Modifier.fillMaxWidth(), .padding(vertical = 4.dp, horizontal = 4.dp)
) { .then(if (isSoftRemoved) Modifier.graphicsLayer { alpha = 0.4f } else Modifier),
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { horizontalArrangement = Arrangement.spacedBy(8.dp),
Text( verticalAlignment = Alignment.CenterVertically,
"Delete $count files from phone?", ) {
fontWeight = FontWeight.SemiBold, ThumbnailChip(destinationPath = result.destinationPath.toString(),
color = MaterialTheme.colorScheme.onErrorContainer, onClick = { if (!isSoftRemoved) onPreview(result) })
)
Text( if (showCheckbox && canCheck) {
"This is permanent. Only files that passed SHA-256 verification will be deleted.", Checkbox(checked = isChecked, onCheckedChange = { onToggleCheck() },
style = MaterialTheme.typography.bodySmall, modifier = Modifier.size(20.dp))
color = MaterialTheme.colorScheme.onErrorContainer, } else if (showCheckbox) {
) Spacer(Modifier.width(20.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { }
Button(
onClick = onConfirm, val icon = when { isFailed -> Icons.Default.Error; exifUnverified -> Icons.Default.Warning; else -> Icons.Default.CheckCircle }
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), val iconTint = when { isFailed -> MaterialTheme.colorScheme.error; exifUnverified -> MaterialTheme.colorScheme.tertiary; else -> MaterialTheme.colorScheme.primary }
) { Text("Delete from Phone") } Icon(icon, contentDescription = null, tint = iconTint, modifier = Modifier.size(16.dp))
OutlinedButton(onClick = onCancel) { Text("Cancel") }
Column(modifier = Modifier.weight(1f)) {
val displayName = if (result.convertedFromHeic)
"${result.photo.filename}${result.destinationPath.fileName}"
else result.photo.filename
Text(displayName, style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Medium,
maxLines = 1, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis)
Text(
when {
isFailed -> result.error ?: "Failed"
exifUnverified -> "⚠ Origin unknown" +
if (showCheckbox) " — kept on phone" else ""
exifUnavailable -> "${result.photo.sizeBytes / 1024} KB"
else -> "${result.exifMake} · ${result.photo.sizeBytes / 1024} KB"
},
style = MaterialTheme.typography.labelSmall,
color = when { isFailed -> MaterialTheme.colorScheme.error; exifUnverified -> MaterialTheme.colorScheme.tertiary; else -> MaterialTheme.colorScheme.onSurfaceVariant },
)
}
IconButton(onClick = onToggleRemove, modifier = Modifier.size(28.dp)) {
Icon(Icons.Default.Close, contentDescription = if (isSoftRemoved) "Undo remove" else "Remove from import",
tint = if (isSoftRemoved) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(16.dp))
}
}
// "Removed" overlay label when soft-removed
if (isSoftRemoved) {
Box(modifier = Modifier.matchParentSize(), contentAlignment = Alignment.Center) {
Surface(shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant) {
Text("Removed — tap ✕ to undo",
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Medium)
}
}
}
}
}
@Composable
private fun ThumbnailChip(destinationPath: String, onClick: () -> Unit) {
var bitmap by remember(destinationPath) { mutableStateOf<ImageBitmap?>(null) }
LaunchedEffect(destinationPath) {
val bytes = withContext(Dispatchers.IO) {
try { Files.readAllBytes(Paths.get(destinationPath)) } catch (_: Exception) { null }
}
if (bytes != null) runCatching { bitmap = SkiaImage.makeFromEncoded(bytes).toComposeImageBitmap() }
}
Box(
modifier = Modifier.size(44.dp).clip(MaterialTheme.shapes.small)
.background(MaterialTheme.colorScheme.surfaceVariant)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center,
) {
if (bitmap != null) {
Image(bitmap = bitmap!!, contentDescription = null,
contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize())
} else {
Text("👁", style = MaterialTheme.typography.labelSmall)
}
}
}
@Composable
private fun ResultPreviewDialog(result: CopyResult, onDismiss: () -> Unit) {
var bitmap by remember(result.destinationPath) { mutableStateOf<ImageBitmap?>(null) }
LaunchedEffect(result.destinationPath) {
val bytes = withContext(Dispatchers.IO) {
try { Files.readAllBytes(result.destinationPath) } catch (_: Exception) { null }
}
if (bytes != null) runCatching { bitmap = SkiaImage.makeFromEncoded(bytes).toComposeImageBitmap() }
}
Dialog(onDismissRequest = onDismiss) {
Card(modifier = Modifier.fillMaxWidth(0.9f)) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(result.photo.filename, fontWeight = FontWeight.SemiBold)
Text(when (result.exifVerified) {
true -> "✓ Camera original — Make: ${result.exifMake}"
false -> "⚠ Origin unknown — may not be your photo${if (!result.exifMake.isNullOrBlank()) " (Make: ${result.exifMake})" else ""}"
null -> "EXIF not checked (install exiftool)"
}, style = MaterialTheme.typography.bodySmall,
color = when (result.exifVerified) {
true -> MaterialTheme.colorScheme.primary
false -> MaterialTheme.colorScheme.tertiary
null -> MaterialTheme.colorScheme.onSurfaceVariant
})
HorizontalDivider()
Box(modifier = Modifier.fillMaxWidth().aspectRatio(4f / 3f),
contentAlignment = Alignment.Center) {
if (bitmap != null) Image(bitmap = bitmap!!, contentDescription = null,
contentScale = ContentScale.Fit, modifier = Modifier.fillMaxSize())
else CircularProgressIndicator()
}
Text("${result.photo.sizeBytes / 1024} KB · ${result.destinationPath}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant)
Button(onClick = onDismiss, modifier = Modifier.fillMaxWidth()) { Text("Close") }
} }
} }
} }

View File

@@ -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.

View File

@@ -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()

View File

@@ -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>

View File

@@ -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)
try: if is_heic:
data = await afc.fread(device_path, 512 * 1024, 0) # HEIC: pull full file — sips needs the full image to convert to JPEG
except Exception: # Cached permanently so only transferred once
await afc.pull(device_path, local_path) await afc.pull(device_path, local_path)
results.append({'devicePath': device_path, 'ok': True}) else:
continue # JPEG/PNG: 512KB partial read is sufficient for sips resample
with open(local_path, 'wb') as f: try:
f.write(data) data = await afc.fread(device_path, 512 * 1024, 0)
with open(local_path, 'wb') as f:
f.write(data)
except Exception:
await afc.pull(device_path, local_path)
results.append({'devicePath': device_path, 'ok': True}) 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)})