Compare commits

33 Commits

Author SHA1 Message Date
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
20 changed files with 1043 additions and 274 deletions

View File

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

1
gradle.properties Normal file
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

View File

@@ -121,6 +121,9 @@ class IosConnector(
exifModel = device.displayName,
mediaType = if (mediaTypeStr == "VIDEO") MediaType.VIDEO else MediaType.PHOTO,
sourceFolder = sourceFolder,
isLivePhotoMotion = obj["isLivePhotoMotion"]?.jsonPrimitive?.contentOrNull == "true",
isLivePhotoStill = obj["companionDevicePath"]?.jsonPrimitive?.contentOrNull != null,
companionDevicePath = obj["companionDevicePath"]?.jsonPrimitive?.contentOrNull,
)
} catch (e: Exception) {
log.debug("Failed to parse photo entry: ${e.message}")

View File

@@ -17,6 +17,8 @@ data class AppConfig(
val defaultStripLivePhotoMotion: Boolean = true,
/** Default: do not delete from phone (safer default) */
val defaultDeleteAfterVerify: Boolean = false,
/** Default: preserve folder structure on import */
val defaultPreserveFolderStructure: Boolean = true,
/** Whether the user has been warned about HEIC phone settings */
val heicWarningDismissed: Boolean = false,
/** Path to adb binary, or null to use PATH */

View File

@@ -19,6 +19,11 @@ data class ImportJob(
val stripLivePhotoMotion: Boolean = true,
/** Whether to delete photos from the phone after successful verification */
val deleteAfterVerify: Boolean = false,
/**
* Whether to import Live Photo .MOV companions alongside their HEIC stills.
* Default false — you said you don't want them.
*/
val importLivePhotoCompanions: Boolean = false,
/**
* Whether to preserve the device's subfolder structure under the staging folder.
* e.g. _staging/2026-07-28_Samsung/Camera/IMG_001.jpg
@@ -38,6 +43,14 @@ data class CopyResult(
val destSha256: String,
val verified: Boolean,
val convertedFromHeic: Boolean = false,
/**
* True if exiftool confirmed Make=Apple (or expected camera make) on the pulled file.
* False means EXIF was missing or Make was unexpected — file may not be a camera original.
* Null means exiftool was not available or not run.
*/
val exifVerified: Boolean? = null,
/** The Make value exiftool found, e.g. "Apple", "samsung", or null if absent */
val exifMake: String? = null,
val error: String? = null,
)

View File

@@ -26,6 +26,11 @@ data class PhonePhoto(
val isLivePhotoStill: Boolean = false,
/** True if this is the motion component of an Apple Live Photo (.MOV companion) */
val isLivePhotoMotion: Boolean = false,
/**
* Device path of the paired MOV companion (set on the HEIC when isLivePhotoStill=true).
* Used by the Copier to pull both files atomically and by deletePhoto to delete both.
*/
val companionDevicePath: String? = null,
/** True if the format is HEIC — user may want to convert to JPEG */
val isHeic: Boolean = filename.endsWith(".HEIC", ignoreCase = true) ||
filename.endsWith(".HEIF", ignoreCase = true),

View File

@@ -63,14 +63,10 @@ class Copier(
photosToImport.forEachIndexed { idx, photo ->
onProgress(idx, photosToImport.size, photo.filename)
// Determine destination directory
val destDir = if (job.preserveFolderStructure) {
// Always use sourceFolder regardless of how many folders are in the job
val subFolder = photo.sourceFolder.ifBlank {
photo.devicePath
.substringAfter("/DCIM/", "")
.substringBeforeLast("/", "")
.ifBlank { "Camera" }
photo.devicePath.substringAfter("/DCIM/", "")
.substringBeforeLast("/", "").ifBlank { "Camera" }
}
stagingDir.resolve(subFolder).also { it.toFile().mkdirs() }
} else {
@@ -81,6 +77,23 @@ class Copier(
if (result.error != null) {
log.error("Failed to copy ${photo.filename}: ${result.error}")
}
// Pull Live Photo companion MOV if requested
if (job.importLivePhotoCompanions && photo.isLivePhotoStill && photo.companionDevicePath != null) {
val companionFilename = photo.companionDevicePath.substringAfterLast("/")
val companionPhoto = photo.copy(
devicePath = photo.companionDevicePath,
filename = companionFilename,
isLivePhotoStill = false,
isLivePhotoMotion = true,
companionDevicePath = null,
)
val companionResult = copyOne(companionPhoto, destDir, convertHeic = false)
results.add(companionResult)
if (companionResult.error != null) {
log.error("Failed to copy companion ${companionFilename}: ${companionResult.error}")
}
}
}
onProgress(photosToImport.size, photosToImport.size, "")
@@ -122,18 +135,15 @@ class Copier(
val remoteHash = connector.remoteChecksum(device, photo)
val verified: Boolean
val verificationNote: String?
when {
wasConverted -> {
// HEIC converted — can't compare hashes, check file is non-empty
verified = destSize > 0
verificationNote = if (verified) "Converted HEIC→JPEG" else null
}
remoteHash != null -> {
// Full hash comparison
verified = remoteHash.equals(destHash, ignoreCase = true)
verificationNote = null
}
else -> {
// No remote hash — verify size matches what ADB reported
@@ -141,10 +151,11 @@ class Copier(
val sizeMatch = photo.sizeBytes == 0L || // unknown size is OK
destSize == photo.sizeBytes
verified = destSize > 0 && sizeMatch
verificationNote = if (verified) "Size verified (${destSize / 1024} KB)" else null
}
}
val exifMake = readExifMake(finalFile)
CopyResult(
photo = photo,
destinationPath = finalFile,
@@ -152,6 +163,8 @@ class Copier(
destSha256 = destHash,
verified = verified,
convertedFromHeic = wasConverted,
exifVerified = exifMake?.let { it.isNotBlank() },
exifMake = exifMake,
error = when {
!verified && remoteHash != null -> "Hash mismatch — file may be corrupt"
!verified -> "Copy failed — file missing or size mismatch"
@@ -226,4 +239,32 @@ class Copier(
}
return digest.digest().joinToString("") { "%02x".format(it) }
}
/**
* Read the EXIF Make tag from a local file using exiftool.
* Returns the Make value (e.g. "Apple"), empty string if Make tag absent,
* or null if exiftool is not installed or fails.
*/
private fun readExifMake(file: Path): String? {
return try {
val process = ProcessBuilder("exiftool", "-Make", "-s3", file.absolutePathString())
.redirectErrorStream(false) // keep stderr separate
.start()
val stdout = process.inputStream.bufferedReader().readText()
process.errorStream.readBytes() // drain stderr
process.waitFor()
// Strip warning lines (exiftool prints "Warning: ..." to stdout with -s3)
val make = stdout.lines()
.filter { !it.startsWith("Warning:") && !it.startsWith("Error:") && it.isNotBlank() }
.firstOrNull()
?.trim()
?: "" // empty string = tag absent, exiftool ran fine
make
} catch (e: Exception) {
log.debug("exiftool not available: ${e.message}")
null
}
}
}

View File

@@ -234,13 +234,30 @@ class AppState(private val configStore: ConfigStore) {
val tmpFile = tmpDir.resolve("$hash.$ext")
val cachePath = cacheDir.resolve("$hash.jpg")
if (tmpFile.toFile().exists() && tmpFile.toFile().length() > 0 && !cachePath.toFile().exists()) {
val sips = ProcessBuilder("sips", "--resampleWidth", "240",
tmpFile.toString(), "--out", cachePath.toString())
.redirectErrorStream(true).start()
val success = if (ext in setOf("heic", "heif")) {
// Convert partial HEIC to JPEG via sips
// sips can decode HEIC even from a partial file if the header is intact
val sips = ProcessBuilder(
"sips", "--resampleWidth", "240",
"--setProperty", "format", "jpeg",
tmpFile.toString(), "--out", cachePath.toString()
).redirectErrorStream(true).start()
sips.inputStream.readBytes()
sips.waitFor()
cachePath.toFile().exists() && cachePath.toFile().length() > 0
} else {
// JPEG/PNG: simple resample
val sips = ProcessBuilder(
"sips", "--resampleWidth", "240",
"--setProperty", "format", "jpeg",
tmpFile.toString(), "--out", cachePath.toString()
).redirectErrorStream(true).start()
sips.inputStream.readBytes()
sips.waitFor()
cachePath.toFile().exists() && cachePath.toFile().length() > 0
}
tmpFile.toFile().delete()
if (cachePath.toFile().exists()) batchLoaded++
if (success) batchLoaded++
}
}
loaded += batchLoaded
@@ -279,6 +296,7 @@ class AppState(private val configStore: ConfigStore) {
val zoneId = java.time.ZoneId.systemDefault()
return photos
.groupBy { it.sourceFolder }
.filterKeys { !ExifFilter.isHiddenFolder(it) } // hide Live Photo MOV folder
.map { (name, folderPhotos) ->
val dates = folderPhotos.map {
it.dateTaken.atZone(zoneId).toLocalDate()
@@ -333,6 +351,7 @@ class AppState(private val configStore: ConfigStore) {
val stripLivePhotoMotion: Boolean,
val deleteAfterVerify: Boolean,
val preserveFolderStructure: Boolean,
val importLivePhotoCompanions: Boolean = false,
val folderName: String,
)
@@ -352,11 +371,11 @@ class AppState(private val configStore: ConfigStore) {
StagingPath.uniqueFolderName(Paths.get(cfg.stagingRoot), 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
_importConfig.update { it.copy(
folderName = folderName,
preserveFolderStructure = multipleSourceFolders,
preserveFolderStructure = if (multipleSourceFolders) true else cfg.defaultPreserveFolderStructure,
) }
navigateTo(Screen.IMPORT_CONFIG)
}
@@ -385,6 +404,7 @@ class AppState(private val configStore: ConfigStore) {
stripLivePhotoMotion = importCfg.stripLivePhotoMotion,
deleteAfterVerify = importCfg.deleteAfterVerify,
preserveFolderStructure = importCfg.preserveFolderStructure,
importLivePhotoCompanions = importCfg.importLivePhotoCompanions,
)
navigateTo(Screen.IMPORTING)
@@ -406,13 +426,37 @@ class AppState(private val configStore: ConfigStore) {
// Post-import deletion
// -------------------------------------------------------------------------
fun deleteVerifiedFromDevice() {
deleteSpecificFromDevice(
_importResult.value?.results
?.filter { it.verified && it.exifVerified != false }
?.map { it.photo.devicePath }
?.toSet() ?: emptySet()
)
}
fun deleteSpecificFromDevice(devicePaths: Set<String>) {
val device = _selectedDevice.value ?: return
val result = _importResult.value ?: return
scope.launch(Dispatchers.IO) {
val connector = connectorFor(device)
result.results.filter { it.verified }.forEach { r ->
try { connector.deletePhoto(device, r.photo) }
catch (e: DeviceException) { log.error("Delete failed for ${r.photo.filename}: ${e.message}") }
result.results
.filter { it.photo.devicePath in devicePaths }
.forEach { r ->
try {
connector.deletePhoto(device, r.photo)
if (r.photo.isLivePhotoStill && r.photo.companionDevicePath != null) {
val companion = r.photo.copy(
devicePath = r.photo.companionDevicePath,
filename = r.photo.companionDevicePath.substringAfterLast("/"),
)
try { connector.deletePhoto(device, companion) }
catch (e: DeviceException) {
log.warn("Companion delete failed for ${r.photo.filename}: ${e.message}")
}
}
} catch (e: DeviceException) {
log.error("Delete failed for ${r.photo.filename}: ${e.message}")
}
}
}
}
@@ -425,6 +469,11 @@ class AppState(private val configStore: ConfigStore) {
saveConfig()
}
fun updateDefaultPreserveFolderStructure(value: Boolean) {
_config.update { it.copy(defaultPreserveFolderStructure = value) }
saveConfig()
}
fun dismissHeicWarning() {
_config.update { it.copy(heicWarningDismissed = true) }
saveConfig()

View File

@@ -4,6 +4,8 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Warning
@@ -11,11 +13,20 @@ import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
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.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.bolenpad.photophetch.model.PhonePhoto
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jetbrains.skia.Image as SkiaImage
import java.nio.file.Path
import java.nio.file.Paths
import java.time.ZoneId
@Composable
@@ -25,6 +36,20 @@ fun BrowseScreen(state: AppState) {
val device by state.selectedDevice.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()) {
var columnCount by remember { mutableStateOf(4) }
@@ -112,6 +137,7 @@ fun BrowseScreen(state: AppState) {
onDeselectAllFolder = { folder ->
folder.forEach { p -> if (p in selectedPhotos) state.togglePhotoSelection(p) }
},
onPreviewPhoto = { previewPhoto = it },
modifier = Modifier.weight(1f),
)
}
@@ -134,7 +160,8 @@ fun BrowseScreen(state: AppState) {
private fun buildFolderSections(pl: PhotoListState.Loaded): List<FolderSection> {
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
.filter { it.sourceFolder == folder.name }
.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) })
}
// exiftool warning — checked once per session
val exiftoolMissing = remember {
try {
val p = ProcessBuilder("exiftool", "-ver").redirectErrorStream(true).start()
p.inputStream.readBytes()
p.waitFor() != 0
} catch (_: Exception) { true }
}
if (exiftoolMissing) {
ExiftoolMissingBanner()
}
// Device scan area
when (val scan = scanState) {
is DeviceScanState.Idle -> {
@@ -200,3 +212,25 @@ private fun StagingNotConfiguredBanner(onConfigureClick: () -> Unit) {
}
}
}
@Composable
private fun ExiftoolMissingBanner() {
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.tertiaryContainer),
modifier = Modifier.width(400.dp),
) {
Column(modifier = Modifier.padding(12.dp)) {
Text(
"exiftool not installed",
style = MaterialTheme.typography.bodySmall,
fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onTertiaryContainer,
)
Text(
"EXIF verification will be skipped during import. Install with: brew install exiftool",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onTertiaryContainer,
)
}
}
}

View File

@@ -10,10 +10,12 @@ import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.RadioButtonUnchecked
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.toComposeImageBitmap
import androidx.compose.ui.layout.ContentScale
@@ -51,8 +53,14 @@ fun DensePhotoGrid(
onTogglePhoto: (PhonePhoto) -> Unit,
onSelectAllFolder: (List<PhonePhoto>) -> Unit,
onDeselectAllFolder: (List<PhonePhoto>) -> Unit,
onPreviewPhoto: ((PhonePhoto) -> Unit)? = null,
modifier: Modifier = Modifier,
) {
// In-memory bitmap cache — survives cell recycling during scroll
// keyed on cacheKey so it resets when device changes
val bitmapCache = remember(cacheKey) {
mutableMapOf<String, ImageBitmap>()
}
if (folderSections.isEmpty() || folderSections.all { it.allPhotos.isEmpty() }) {
Box(modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(
@@ -144,7 +152,9 @@ fun DensePhotoGrid(
cacheKey = cacheKey,
pullFn = pullFn,
thumbnailVersion = thumbnailVersion,
bitmapCache = bitmapCache,
onToggle = { onTogglePhoto(labeled.photo) },
onPreview = onPreviewPhoto,
dateLabel = labeled.dateLabel,
modifier = Modifier.weight(1f),
)
@@ -167,13 +177,17 @@ private fun DenseThumb(
cacheKey: String,
pullFn: suspend (devicePath: String, localDestPath: Path) -> Boolean,
thumbnailVersion: Int,
bitmapCache: MutableMap<String, ImageBitmap>,
onToggle: () -> Unit,
onPreview: ((PhonePhoto) -> Unit)?,
dateLabel: String?,
modifier: Modifier = Modifier,
) {
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) {
if (thumbnail == null) {
val bytes = withContext(Dispatchers.IO) {
@@ -186,7 +200,9 @@ private fun DenseThumb(
}
if (bytes != null && bytes.isNotEmpty()) {
runCatching {
thumbnail = SkiaImage.makeFromEncoded(bytes).toComposeImageBitmap()
val bitmap = SkiaImage.makeFromEncoded(bytes).toComposeImageBitmap()
bitmapCache[photo.devicePath] = bitmap // store in memory cache
thumbnail = bitmap
}.onFailure { e ->
thumbLog.warn("Decode failed for ${photo.filename} (${bytes.size} bytes): ${e.message}")
}
@@ -235,16 +251,74 @@ private fun DenseThumb(
tint = MaterialTheme.colorScheme.outline, modifier = Modifier.size(20.dp))
}
}
if (photo.mediaType == MediaType.VIDEO) {
Badge(containerColor = MaterialTheme.colorScheme.tertiary,
modifier = Modifier.align(Alignment.BottomStart).padding(2.dp)) {
Text("", style = MaterialTheme.typography.labelSmall)
}
}
// Top-right: high-contrast selection indicator
// White circle with drop shadow so it's visible on both light and dark thumbnails
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(4.dp)
.size(20.dp)
.background(
if (isSelected) MaterialTheme.colorScheme.primary
else Color.White.copy(alpha = 0.9f),
shape = androidx.compose.foundation.shape.CircleShape,
)
.border(
width = 1.5.dp,
color = if (isSelected) MaterialTheme.colorScheme.primary
else Color.Black.copy(alpha = 0.3f),
shape = androidx.compose.foundation.shape.CircleShape,
)
.clickable(onClick = onToggle),
contentAlignment = Alignment.Center,
) {
if (isSelected) {
Icon(Icons.Default.CheckCircle, contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.align(Alignment.TopEnd).padding(2.dp).size(16.dp))
tint = Color.White, modifier = Modifier.size(18.dp))
}
}
// Bottom-left: LIVE badge or video size badge
if (photo.isLivePhotoStill) {
Box(
modifier = Modifier
.align(Alignment.BottomStart)
.padding(3.dp)
.background(Color(0xFF00C853), shape = MaterialTheme.shapes.extraSmall)
.padding(horizontal = 4.dp, vertical = 1.dp),
) {
Text("LIVE", fontSize = 9.sp, fontWeight = FontWeight.Bold,
color = Color.White)
}
} else if (photo.mediaType == MediaType.VIDEO) {
val sizeLabel = if (photo.sizeBytes > 1024 * 1024)
"${photo.sizeBytes / (1024 * 1024)}MB"
else if (photo.sizeBytes > 0) "${photo.sizeBytes / 1024}KB"
else ""
Badge(containerColor = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.85f),
modifier = Modifier.align(Alignment.BottomStart).padding(2.dp)) {
Text(sizeLabel, style = MaterialTheme.typography.labelSmall)
}
}
// Bottom-right: + preview button
if (onPreview != null) {
Box(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(2.dp)
.size(20.dp)
.background(
MaterialTheme.colorScheme.surface.copy(alpha = 0.75f),
shape = MaterialTheme.shapes.extraSmall,
)
.clickable { onPreview(photo) },
contentAlignment = Alignment.Center,
) {
Text("+", style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurface)
}
}
}
}

View File

@@ -145,16 +145,19 @@ fun ImportScreen(state: AppState) {
onCheckedChange = { state.updateImportConfig { copy(convertHeicToJpeg = it) } },
)
ToggleRow(
label = "Strip Live Photo motion clips",
description = "Excludes .MOV companions from Live Photos.",
checked = importConfig.stripLivePhotoMotion,
onCheckedChange = { state.updateImportConfig { copy(stripLivePhotoMotion = it) } },
label = "Import Live Photo motion clips (.MOV)",
description = "Include the .MOV companion alongside each Live Photo still. Paired by filename match. Default off.",
checked = importConfig.importLivePhotoCompanions,
onCheckedChange = { state.updateImportConfig { copy(importLivePhotoCompanions = it) } },
)
ToggleRow(
label = "Preserve folder structure",
description = "Files go into subfolders matching their source (Camera/, Screenshots/, etc.).",
checked = importConfig.preserveFolderStructure,
onCheckedChange = { state.updateImportConfig { copy(preserveFolderStructure = it) } },
onCheckedChange = {
state.updateImportConfig { copy(preserveFolderStructure = it) }
state.updateDefaultPreserveFolderStructure(it)
},
)
ToggleRow(
label = "Delete from phone after verification",

View File

@@ -1,296 +1,406 @@
package com.bolenpad.photophetch.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Error
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.toComposeImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.bolenpad.photophetch.model.CopyResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jetbrains.skia.Image as SkiaImage
import java.nio.file.Files
import java.nio.file.Paths
private enum class VerifyFilter { ALL, OK, UNVERIFIED, FAILED }
/**
* Shown during active import (IMPORTING screen).
*/
@Composable
fun ImportingScreen(state: AppState) {
val progress by state.importProgress.collectAsState()
Column(
modifier = Modifier.fillMaxSize().padding(32.dp),
Column(modifier = Modifier.fillMaxSize().padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
verticalArrangement = Arrangement.Center) {
Text("Importing…", style = MaterialTheme.typography.headlineSmall)
Spacer(Modifier.height(32.dp))
val p = progress
if (p != null && p.total > 0) {
val fraction = p.completed.toFloat() / p.total
LinearProgressIndicator(progress = { fraction }, modifier = Modifier.fillMaxWidth(0.6f))
Spacer(Modifier.height(12.dp))
Text("${p.completed} / ${p.total} files")
if (p.currentFile.isNotBlank()) {
Text(
p.currentFile,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
CircularProgressIndicator()
}
if (p.currentFile.isNotBlank())
Text(p.currentFile, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant)
} else CircularProgressIndicator()
}
}
/**
* Shown after import completes (VERIFY screen).
*/
@Composable
fun VerifyScreen(state: AppState) {
val result by state.importResult.collectAsState()
val importConfig by state.importConfig.collectAsState()
var deleteConfirmShown by remember { mutableStateOf(false) }
var deleteDone by remember { mutableStateOf(false) }
var previewResult by remember { mutableStateOf<CopyResult?>(null) }
var activeFilter by remember { mutableStateOf(VerifyFilter.ALL) }
val r = result ?: return
Column(modifier = Modifier.fillMaxSize().padding(24.dp)) {
// 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
ResultSummaryHeader(
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
}
previewResult?.let { pr ->
ResultPreviewDialog(result = pr, onDismiss = { previewResult = null })
}
// Top edge: shadow drawn below the divider line above the list
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,
),
)
)
}
)
}
Column(modifier = Modifier.fillMaxSize().padding(horizontal = 20.dp, vertical = 16.dp)) {
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)
}
}
// ── Summary — counts exclude soft-removed files ───────────────────
val activeResults = r.results.filter { it.photo.devicePath !in softRemoved }
val failCount = activeResults.count { it.error != null || !it.verified }
val unverifiedCount = activeResults.count { it.exifVerified == false }
val okCount = activeResults.count { it.verified && it.error == null && it.exifVerified != false }
val removedCount = softRemoved.size
// 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,
Card(colors = CardDefaults.cardColors(
containerColor = if (failCount == 0) MaterialTheme.colorScheme.primaryContainer
else MaterialTheme.colorScheme.errorContainer)) {
Row(modifier = Modifier.fillMaxWidth().padding(12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically) {
Column {
Text(if (failCount == 0) "Import complete ✓" else "Import complete with errors",
fontWeight = FontWeight.SemiBold)
Text("${r.job.stagingRoot}/${r.job.folderName}",
style = MaterialTheme.typography.bodySmall,
)
color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (failCount > 0) StatusChip("$failCount failed", MaterialTheme.colorScheme.error)
if (unverifiedCount > 0) StatusChip("$unverifiedCount", MaterialTheme.colorScheme.tertiary)
StatusChip("$okCount", MaterialTheme.colorScheme.primary)
if (removedCount > 0) StatusChip("$removedCount removed", MaterialTheme.colorScheme.outline)
}
}
}
Spacer(Modifier.height(8.dp))
// ── Filter bar ─────────────────────────────────────────────────────
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
VerifyFilter.entries.forEach { filter ->
val label = when (filter) {
VerifyFilter.ALL -> "All (${activeResults.size})"
VerifyFilter.OK -> "✓ OK ($okCount)"
VerifyFilter.UNVERIFIED -> "⚠ Unverified ($unverifiedCount)"
VerifyFilter.FAILED -> "✗ Failed ($failCount)"
}
FilterChip(selected = activeFilter == filter, onClick = { activeFilter = filter },
label = { Text(label, style = MaterialTheme.typography.labelSmall) })
}
if (removedCount > 0) {
FilterChip(selected = false, onClick = { /* show removed inline */ },
label = { Text("$removedCount removed — tap to undo all",
style = MaterialTheme.typography.labelSmall) },
trailingIcon = {
IconButton(onClick = { softRemoved = emptySet() },
modifier = Modifier.size(16.dp)) {
Icon(Icons.Default.Close, contentDescription = "Undo all removals",
modifier = Modifier.size(12.dp))
}
}
)
}
}
Spacer(Modifier.height(4.dp))
// ── Results list ───────────────────────────────────────────────────
val filteredResults = r.results.filter { cr ->
when (activeFilter) {
VerifyFilter.ALL -> true // show all including soft-removed (they'll be greyed)
VerifyFilter.OK -> cr.verified && cr.error == null && cr.exifVerified != false && cr.photo.devicePath !in softRemoved
VerifyFilter.UNVERIFIED -> cr.exifVerified == false && cr.photo.devicePath !in softRemoved
VerifyFilter.FAILED -> (cr.error != null || !cr.verified) && cr.photo.devicePath !in softRemoved
}
}
val listState = rememberLazyListState()
val topAlpha by remember { derivedStateOf {
((listState.firstVisibleItemScrollOffset + listState.firstVisibleItemIndex * 80) / 60f).coerceIn(0f, 1f)
}}
val bottomAlpha by remember { derivedStateOf {
val info = listState.layoutInfo
val last = info.visibleItemsInfo.lastOrNull()
if (last == null || last.index >= info.totalItemsCount - 1) 0f else 1f
}}
if (topAlpha > 0f) Box(Modifier.fillMaxWidth().height(16.dp).drawWithContent {
drawContent()
drawRect(brush = Brush.verticalGradient(listOf(Color.Black.copy(0.08f * topAlpha), Color.Transparent)))
})
LazyColumn(state = listState, modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(2.dp)) {
items(filteredResults, key = { it.photo.devicePath }) { cr ->
val isSoftRemoved = cr.photo.devicePath in softRemoved
VerifyResultRow(
result = cr,
showCheckbox = importConfig.deleteAfterVerify && !isSoftRemoved,
isChecked = cr.photo.devicePath in toDelete,
canCheck = cr.verified && cr.error == null,
isSoftRemoved = isSoftRemoved,
onToggleCheck = {
toDelete = if (cr.photo.devicePath in toDelete)
toDelete - cr.photo.devicePath
else toDelete + cr.photo.devicePath
},
onPreview = { previewResult = it },
onToggleRemove = {
if (isSoftRemoved) {
softRemoved = softRemoved - cr.photo.devicePath
} else {
softRemoved = softRemoved + cr.photo.devicePath
toDelete = toDelete - cr.photo.devicePath
}
},
)
}
}
if (bottomAlpha > 0f) Box(Modifier.fillMaxWidth().height(16.dp).drawWithContent {
drawContent()
drawRect(brush = Brush.verticalGradient(listOf(Color.Transparent, Color.Black.copy(0.08f * bottomAlpha))))
})
HorizontalDivider()
Spacer(Modifier.height(8.dp))
// ── Commit button ──────────────────────────────────────────────────
val hasPendingPhoneDeletes = importConfig.deleteAfterVerify && toDelete.isNotEmpty()
val hasPendingStagingCleanup = softRemoved.isNotEmpty()
val doneLabel = when {
hasPendingPhoneDeletes && hasPendingStagingCleanup ->
"Confirm: delete ${toDelete.size} from phone + remove ${softRemoved.size} from staging"
hasPendingPhoneDeletes ->
"Confirm: delete ${toDelete.size} files from phone"
hasPendingStagingCleanup ->
"Confirm: remove ${softRemoved.size} files from staging"
else -> "Done — Back to Start"
}
val doneColor = if (hasPendingPhoneDeletes || hasPendingStagingCleanup)
ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)
else ButtonDefaults.buttonColors()
Button(
onClick = {
// 1. Delete phone files (if delete-after-verify was on and user confirmed via checkbox)
if (hasPendingPhoneDeletes) {
state.deleteSpecificFromDevice(toDelete)
}
// 2. Delete soft-removed files from staging
if (hasPendingStagingCleanup) {
r.results.filter { it.photo.devicePath in softRemoved }.forEach { cr ->
try { cr.destinationPath.toFile().delete() } catch (_: Exception) {}
}
}
state.navigateTo(Screen.CONNECT)
state.selectNone()
},
modifier = Modifier.fillMaxWidth(),
colors = doneColor,
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
) {
Icon(Icons.Default.Home, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Done — Back to Start")
Text(doneLabel)
}
}
}
@Composable
private fun ResultSummaryHeader(success: Int, failure: Int, destination: String) {
Card(
colors = CardDefaults.cardColors(
containerColor = if (failure == 0)
MaterialTheme.colorScheme.primaryContainer
else
MaterialTheme.colorScheme.errorContainer,
)
private fun StatusChip(label: String, color: Color) {
Surface(shape = MaterialTheme.shapes.small,
color = color.copy(alpha = 0.15f),
contentColor = color) {
Text(label, modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.SemiBold)
}
}
@Composable
private fun VerifyResultRow(
result: CopyResult,
showCheckbox: Boolean,
isChecked: Boolean,
canCheck: Boolean,
isSoftRemoved: Boolean,
onToggleCheck: () -> Unit,
onPreview: (CopyResult) -> Unit,
onToggleRemove: () -> Unit,
) {
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
private fun CopyResultRow(result: CopyResult) {
val hasRemoteHash = result.sourceSha256 != "(not available)"
val isVerifiedWithHash = result.verified && result.error == null && hasRemoteHash
val isVerifiedNoHash = result.verified && result.error == null && !hasRemoteHash
val isFailed = result.error != null || !result.verified
val exifUnverified = result.exifVerified == false
val exifUnavailable = result.exifVerified == null
val rowBg = when {
isSoftRemoved -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
isFailed -> MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f)
exifUnverified -> MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.3f)
else -> Color.Transparent
}
Box(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.fillMaxWidth().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),
verticalAlignment = Alignment.CenterVertically,
) {
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),
)
ThumbnailChip(destinationPath = result.destinationPath.toString(),
onClick = { if (!isSoftRemoved) onPreview(result) })
if (showCheckbox && canCheck) {
Checkbox(checked = isChecked, onCheckedChange = { onToggleCheck() },
modifier = Modifier.size(20.dp))
} else if (showCheckbox) {
Spacer(Modifier.width(20.dp))
}
val icon = when { isFailed -> Icons.Default.Error; exifUnverified -> Icons.Default.Warning; else -> Icons.Default.CheckCircle }
val iconTint = when { isFailed -> MaterialTheme.colorScheme.error; exifUnverified -> MaterialTheme.colorScheme.tertiary; else -> MaterialTheme.colorScheme.primary }
Icon(icon, contentDescription = null, tint = iconTint, modifier = Modifier.size(16.dp))
Column(modifier = Modifier.weight(1f)) {
val displayName = if (result.convertedFromHeic)
"${result.photo.filename}${result.destinationPath.fileName}"
else result.photo.filename
Text(displayName, style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Medium)
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"
isVerifiedWithHash -> "SHA-256 verified · ${result.photo.sizeBytes / 1024} KB"
else -> "Copied · ${result.photo.sizeBytes / 1024} KB · local SHA-256: ${result.destSha256.take(12)}"
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
else -> MaterialTheme.colorScheme.onSurfaceVariant
},
color = when { isFailed -> MaterialTheme.colorScheme.error; exifUnverified -> MaterialTheme.colorScheme.tertiary; else -> MaterialTheme.colorScheme.onSurfaceVariant },
)
}
IconButton(onClick = onToggleRemove, modifier = Modifier.size(28.dp)) {
Icon(Icons.Default.Close, contentDescription = if (isSoftRemoved) "Undo remove" else "Remove from import",
tint = if (isSoftRemoved) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(16.dp))
}
}
// "Removed" overlay label when soft-removed
if (isSoftRemoved) {
Box(modifier = Modifier.matchParentSize(), contentAlignment = Alignment.Center) {
Surface(shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant) {
Text("Removed — tap ✕ to undo",
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Medium)
}
}
}
}
}
@Composable
private fun DeleteConfirmCard(count: Int, onConfirm: () -> Unit, onCancel: () -> Unit) {
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer),
modifier = Modifier.fillMaxWidth(),
private fun ThumbnailChip(destinationPath: String, onClick: () -> Unit) {
var bitmap by remember(destinationPath) { mutableStateOf<ImageBitmap?>(null) }
LaunchedEffect(destinationPath) {
val bytes = withContext(Dispatchers.IO) {
try { Files.readAllBytes(Paths.get(destinationPath)) } catch (_: Exception) { null }
}
if (bytes != null) runCatching { bitmap = SkiaImage.makeFromEncoded(bytes).toComposeImageBitmap() }
}
Box(
modifier = Modifier.size(44.dp).clip(MaterialTheme.shapes.small)
.background(MaterialTheme.colorScheme.surfaceVariant)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center,
) {
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(
"Delete $count files from phone?",
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onErrorContainer,
)
Text(
"This is permanent. Only files that passed SHA-256 verification will be deleted.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
onClick = onConfirm,
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error),
) { Text("Delete from Phone") }
OutlinedButton(onClick = onCancel) { Text("Cancel") }
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
* (i.e. not screenshots, received images, etc.)
* Returns true if a folder name is likely a camera-roll source.
* Handles both Android path-based names and iOS classified group names.
*/
fun isCameraRollFolder(folderName: String): Boolean {
// iPhone classified groups (from iOS helper classify_iphone_file)
if (folderName == "Camera" || folderName == "Edited") return true
if (folderName in setOf("Screenshots", "Screen Recordings", "Videos", "Live Photo", "Other")) return false
// Android: path-based exclusion
val lower = folderName.lowercase()
return excludedPathFragments.none { lower.contains(it) } &&
screenshotFilePrefixes.none { lower.startsWith(it) }
}
/**
* Folders that should be completely hidden from the UI (not even in Other).
* Live Photo companions are proven by the LIVE badge on the HEIC — showing
* the MOVs separately adds noise with unverifiable placeholder thumbnails.
*/
fun isHiddenFolder(folderName: String): Boolean =
folderName == "Live Photo"
/**
* Returns photos that are HEIC files (and not Live Photo motion clips).
* Used to determine whether to show the HEIC conversion warning.

View File

@@ -56,10 +56,13 @@ object ThumbnailCache {
val success = pullFn(devicePath, tmpFile)
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(
"sips", "--resampleWidth", "240",
tmpFile.toString(), "--out", cachePath.toString()
"sips",
"--resampleWidth", "240",
"--setProperty", "format", "jpeg",
tmpFile.toString(),
"--out", cachePath.toString()
).redirectErrorStream(true).start()
sips.inputStream.readBytes()
sips.waitFor()

View File

@@ -1,13 +1,34 @@
<configuration>
<!-- Console output for development (./gradlew run) -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss} [%-5level] %logger{20} - %msg%n</pattern>
</encoder>
</appender>
<!-- Rolling file output for packaged app -->
<!-- Writes to ~/Library/Logs/PhotoPhetch/ on macOS (standard location) -->
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${user.home}/Library/Logs/PhotoPhetch/photophetch.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<!-- Keep 7 days of logs, max 10MB per file -->
<fileNamePattern>${user.home}/Library/Logs/PhotoPhetch/photophetch.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>7</maxHistory>
<totalSizeCap>50MB</totalSizeCap>
</rollingPolicy>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss} [%-5level] %logger{30} - %msg%n</pattern>
</encoder>
</appender>
<!-- photophetch package: DEBUG in dev, INFO when packaged -->
<logger name="com.bolenpad.photophetch" level="DEBUG" />
<!-- Always write to file; console only in dev -->
<root level="WARN">
<appender-ref ref="CONSOLE" />
<appender-ref ref="FILE" />
</root>
<logger name="com.bolenpad.photophetch" level="DEBUG" />
</configuration>

View File

@@ -20,6 +20,8 @@ import os
import asyncio
from datetime import datetime
import re
MEDIA_EXTENSIONS = {
'jpg', 'jpeg', 'heic', 'heif', 'avif', 'webp', 'png',
'dng', 'raw', 'arw', 'cr2', 'cr3', 'nef', 'orf', 'rw2',
@@ -31,6 +33,50 @@ VIDEO_EXTENSIONS = {
'mp4', 'm4v', 'mov', 'mpeg', 'mpg', '3gp', '3g2', 'avi', 'mkv', 'webm',
}
# iPhone camera originals: IMG_XXXX.{HEIC,JPG,MOV} — exactly 4 digits
CAMERA_ORIGINAL_RE = re.compile(r'^IMG_\d{4}\.(HEIC|HEIF|JPG|JPEG|MOV)$', re.IGNORECASE)
# Edited versions: IMG_EXXXX — edited in Photos.app
CAMERA_EDITED_RE = re.compile(r'^IMG_E\d{4}\.(HEIC|HEIF|JPG|JPEG)$', re.IGNORECASE)
# Screenshots: PNG files (iPhone camera never produces PNG)
SCREENSHOT_RE = re.compile(r'.*\.PNG$', re.IGNORECASE)
# Screen recordings
SCREEN_RECORDING_RE = re.compile(r'^RPReplay_Final.*\.mp4$', re.IGNORECASE)
def classify_iphone_file(filename: str, all_filenames: set = None, size_bytes: int = 0) -> str:
"""
Returns a group name for display in the folder panel.
Groups: 'Camera', 'Edited', 'Live Photo', 'Screenshots', 'Videos', 'Other'
all_filenames: set of all filenames in the same DCIM folder (for pairing detection).
size_bytes: used to distinguish Live Photo clips (<5MB) from real videos.
"""
ext = filename.rsplit('.', 1)[-1].lower() if '.' in filename else ''
if SCREEN_RECORDING_RE.match(filename):
return 'Screen Recordings'
if SCREENSHOT_RE.match(filename):
return 'Screenshots'
if CAMERA_ORIGINAL_RE.match(filename):
if ext in ('mov',):
# Check if this is a Live Photo companion
base = filename.rsplit('.', 1)[0] # IMG_XXXX
is_live_companion = False
if all_filenames is not None:
# Paired HEIC/JPEG exists with same base name → Live Photo companion
for still_ext in ('HEIC', 'HEIF', 'JPG', 'JPEG'):
if f'{base}.{still_ext}' in all_filenames:
is_live_companion = True
break
elif size_bytes > 0 and size_bytes < 5 * 1024 * 1024:
# No pairing info but very small MOV → likely Live Photo
is_live_companion = True
return 'Live Photo' if is_live_companion else 'Videos'
return 'Camera'
if CAMERA_EDITED_RE.match(filename):
return 'Edited'
if ext in VIDEO_EXTENSIONS:
return 'Videos'
return 'Other'
async def get_lockdown():
from pymobiledevice3.lockdown import create_using_usbmux
@@ -78,12 +124,43 @@ async def cmd_list_photos():
except Exception:
continue
# Build a set of all filenames in this folder for Live Photo pairing
all_filenames_in_folder = set(entries)
# Pre-compute Live Photo pairs: IMG_XXXX.MOV + IMG_XXXX.HEIC/JPG
# Key: base name (IMG_XXXX) -> MOV device path
live_photo_mov_paths = {}
for fname in entries:
if re.match(r'^IMG_\d{4}\.MOV$', fname, re.IGNORECASE):
base = fname.rsplit('.', 1)[0]
for still_ext in ('HEIC', 'HEIF', 'JPG', 'JPEG'):
if f'{base}.{still_ext}' in all_filenames_in_folder:
# Confirm it's small enough to be a Live Photo clip
# (we'll verify size when we hit the MOV entry)
live_photo_mov_paths[base] = f'{folder_path}/{fname}'
break
for filename in entries:
ext = filename.rsplit('.', 1)[-1].lower() if '.' in filename else ''
if ext not in MEDIA_EXTENSIONS:
continue
device_path = f'{folder_path}/{filename}'
base = filename.rsplit('.', 1)[0]
# Determine Live Photo status
is_live_motion = False
companion_device_path = None
if re.match(r'^IMG_\d{4}\.MOV$', filename, re.IGNORECASE):
if base in live_photo_mov_paths:
# This MOV is a companion — mark it but still include it
# (AppState will filter it from display)
is_live_motion = True
elif re.match(r'^IMG_\d{4}\.(HEIC|HEIF|JPG|JPEG)$', filename, re.IGNORECASE):
if base in live_photo_mov_paths:
companion_device_path = live_photo_mov_paths[base]
# Get file metadata via stat
size_bytes = 0
@@ -110,9 +187,11 @@ async def cmd_list_photos():
'filename': filename,
'sizeBytes': size_bytes,
'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',
'isHeic': ext in ('heic', 'heif'),
'isLivePhotoMotion': is_live_motion,
'companionDevicePath': companion_device_path,
})
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.
Input: JSON array of [device_path, local_path] pairs.
Output: JSON array of {devicePath, ok} results.
Strategy by format:
- HEIC/HEIF: read first 1MB (thumbnail track is near file start),
extract embedded thumbnail via sips. Falls back to full pull if needed.
- JPEG/PNG: read first 512KB (sufficient for sips resample).
"""
try:
pairs = json.loads(pairs_json)
@@ -169,16 +253,22 @@ async def cmd_batch_thumbs(pairs_json: str):
afc = await get_afc(lockdown)
results = []
for device_path, local_path in pairs:
ext = device_path.rsplit('.', 1)[-1].lower() if '.' in device_path else ''
is_heic = ext in ('heic', 'heif')
try:
os.makedirs(os.path.dirname(os.path.abspath(local_path)), exist_ok=True)
if is_heic:
# HEIC: pull full file — sips needs the full image to convert to JPEG
# Cached permanently so only transferred once
await afc.pull(device_path, local_path)
else:
# JPEG/PNG: 512KB partial read is sufficient for sips resample
try:
data = await afc.fread(device_path, 512 * 1024, 0)
except Exception:
await afc.pull(device_path, local_path)
results.append({'devicePath': device_path, 'ok': True})
continue
with open(local_path, 'wb') as f:
f.write(data)
except Exception:
await afc.pull(device_path, local_path)
results.append({'devicePath': device_path, 'ok': True})
except Exception as e:
results.append({'devicePath': device_path, 'ok': False, 'error': str(e)})