Compare commits
36 Commits
eef4f61050
...
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29595d5a7f | ||
|
|
ec1d2f1660 | ||
|
|
b437cbf6e1 | ||
|
|
0042f3b6ba | ||
|
|
694dca8817 | ||
|
|
c34e1201ab | ||
|
|
dc32a3a3d7 | ||
|
|
a8906fa265 | ||
|
|
6912deac14 | ||
|
|
bbb3bb1aae | ||
|
|
3c2ec07bdb | ||
|
|
d128835c0d | ||
|
|
f21e8dbd3a | ||
|
|
7b28c87f2c | ||
|
|
42039bade1 | ||
|
|
4171ff04d2 | ||
|
|
bc3a89e858 | ||
|
|
de240be014 | ||
|
|
8681bddb60 | ||
|
|
8cf8a7a197 | ||
|
|
80eee705a7 | ||
|
|
0139d47d5f | ||
|
|
6863bad7a0 | ||
|
|
f90baa92d4 | ||
|
|
35984b0b85 | ||
|
|
1d239fb60a | ||
|
|
347f182c30 | ||
|
|
595d583f6e | ||
|
|
6876709051 | ||
|
|
e6d27d3ab3 | ||
|
|
716124bff5 | ||
|
|
558a7add89 | ||
|
|
30b600e2a4 | ||
|
|
6ed4362856 | ||
|
|
fbd228b768 | ||
|
|
309031aed7 |
@@ -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
1
gradle.properties
Normal file
@@ -0,0 +1 @@
|
||||
compose.desktop.packaging.checkJdkVendor=false
|
||||
68
prepare-release.sh
Executable file
68
prepare-release.sh
Executable file
@@ -0,0 +1,68 @@
|
||||
#!/bin/bash
|
||||
# prepare-release.sh — prepare a release commit and tag (run on dev machine)
|
||||
#
|
||||
# Usage:
|
||||
# ./prepare-release.sh 1.0.0 "Release notes here..."
|
||||
#
|
||||
# This script:
|
||||
# 1. Writes release notes to releases/v<version>.md
|
||||
# 2. Updates packageVersion in build.gradle.kts
|
||||
# 3. Commits both
|
||||
# 4. Creates and pushes the annotated tag
|
||||
#
|
||||
# After this, the Mac operator runs ./release.sh to build and upload.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [ $# -lt 2 ]; then
|
||||
echo "Usage: $0 <version> <release-notes>"
|
||||
echo " Example: $0 1.0.0 'First release with Android and iPhone support'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VERSION="$1"
|
||||
TAG="v${VERSION}"
|
||||
NOTES="$2"
|
||||
|
||||
# ── Pre-flight ────────────────────────────────────────────────────────────────
|
||||
if ! git diff --quiet || ! git diff --cached --quiet; then
|
||||
echo "ERROR: Uncommitted changes. Commit or stash first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if git tag | grep -q "^${TAG}$"; then
|
||||
echo "ERROR: Tag $TAG already exists."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Write release notes ───────────────────────────────────────────────────────
|
||||
mkdir -p releases
|
||||
NOTES_FILE="releases/${TAG}.md"
|
||||
cat > "$NOTES_FILE" << EOF
|
||||
$NOTES
|
||||
EOF
|
||||
echo "→ Wrote $NOTES_FILE"
|
||||
|
||||
# ── Update version ────────────────────────────────────────────────────────────
|
||||
sed -i "s/packageVersion = \"[^\"]*\"/packageVersion = \"${VERSION}\"/" build.gradle.kts
|
||||
echo "→ Updated packageVersion to $VERSION"
|
||||
|
||||
# ── Commit ────────────────────────────────────────────────────────────────────
|
||||
git add "$NOTES_FILE" build.gradle.kts
|
||||
git commit -m "Release $TAG"
|
||||
echo "→ Committed release files"
|
||||
|
||||
# ── Tag ───────────────────────────────────────────────────────────────────────
|
||||
git tag -a "$TAG" -m "$NOTES"
|
||||
echo "→ Created tag $TAG"
|
||||
|
||||
# ── Push ──────────────────────────────────────────────────────────────────────
|
||||
echo "→ Pushing to origin..."
|
||||
GIT_ASKPASS='' GIT_TERMINAL_PROMPT=0 git push origin HEAD:refs/heads/main 2>&1 || \
|
||||
echo " (push to main may require running from Mac — tag is local)"
|
||||
GIT_ASKPASS='' GIT_TERMINAL_PROMPT=0 git push origin "$TAG" 2>&1 || \
|
||||
echo " (tag push may require running from Mac)"
|
||||
|
||||
echo ""
|
||||
echo "✓ Release $TAG prepared."
|
||||
echo " On your Mac: git fetch --tags && git checkout $TAG && ./release.sh"
|
||||
119
release.sh
Executable file
119
release.sh
Executable file
@@ -0,0 +1,119 @@
|
||||
#!/bin/bash
|
||||
# release.sh — build and publish a PhotoPhetch release to Gitea
|
||||
#
|
||||
# Usage (on your Mac after pulling tagged commit):
|
||||
# ./release.sh
|
||||
#
|
||||
# The release version and notes are read from the current git tag and
|
||||
# releases/<tag>.md — both committed by the dev machine before tagging.
|
||||
#
|
||||
# Requires GITEA_TOKEN environment variable.
|
||||
# Get a token: https://git.bolenpad.com/user/settings/applications
|
||||
# Add to ~/.zshrc: export GITEA_TOKEN=your_token_here
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ── Config ───────────────────────────────────────────────────────────────────
|
||||
GITEA_URL="https://git.bolenpad.com"
|
||||
GITEA_USER="kbolen"
|
||||
GITEA_REPO="photophetch"
|
||||
|
||||
# ── Resolve version from current git tag ─────────────────────────────────────
|
||||
TAG=$(git describe --exact-match --tags HEAD 2>/dev/null || echo "")
|
||||
if [ -z "$TAG" ]; then
|
||||
echo "ERROR: HEAD is not on a tag. Pull the latest and checkout the release tag."
|
||||
echo " git fetch --tags && git checkout v1.0.0"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VERSION="${TAG#v}" # strip leading 'v'
|
||||
echo "→ Building release $TAG (version $VERSION)"
|
||||
|
||||
# ── Read release notes from releases/<tag>.md ────────────────────────────────
|
||||
NOTES_FILE="releases/${TAG}.md"
|
||||
if [ ! -f "$NOTES_FILE" ]; then
|
||||
echo "ERROR: Release notes file not found: $NOTES_FILE"
|
||||
echo " The dev machine should have committed this before tagging."
|
||||
exit 1
|
||||
fi
|
||||
NOTES=$(cat "$NOTES_FILE")
|
||||
echo "→ Release notes: $NOTES_FILE"
|
||||
|
||||
# ── Gitea token ───────────────────────────────────────────────────────────────
|
||||
if [ -z "${GITEA_TOKEN:-}" ]; then
|
||||
read -rsp "GITEA_TOKEN not set. Enter token: " GITEA_TOKEN
|
||||
echo
|
||||
fi
|
||||
|
||||
# ── Check packageVersion matches tag ─────────────────────────────────────────
|
||||
GRADLE_VERSION=$(grep 'packageVersion' build.gradle.kts | sed 's/.*"\(.*\)".*/\1/')
|
||||
if [ "$GRADLE_VERSION" != "$VERSION" ]; then
|
||||
echo "WARNING: build.gradle.kts packageVersion ($GRADLE_VERSION) != tag version ($VERSION)"
|
||||
echo "Continue anyway? [y/N]"
|
||||
read -r CONFIRM
|
||||
[[ "$CONFIRM" == "y" || "$CONFIRM" == "Y" ]] || exit 1
|
||||
fi
|
||||
|
||||
# ── Build DMG ─────────────────────────────────────────────────────────────────
|
||||
echo "→ Building DMG (this takes a few minutes)..."
|
||||
./gradlew packageDmg
|
||||
|
||||
DMG_PATH=$(find build/compose/binaries/main/dmg -name "*.dmg" | head -1)
|
||||
if [ -z "$DMG_PATH" ]; then
|
||||
echo "ERROR: No DMG found in build/compose/binaries/main/dmg/"
|
||||
exit 1
|
||||
fi
|
||||
DMG_NAME=$(basename "$DMG_PATH")
|
||||
echo "→ Built: $DMG_PATH"
|
||||
|
||||
# ── Check release doesn't already exist ──────────────────────────────────────
|
||||
EXISTING=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
"${GITEA_URL}/api/v1/repos/${GITEA_USER}/${GITEA_REPO}/releases/tags/${TAG}" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}")
|
||||
if [ "$EXISTING" = "200" ]; then
|
||||
echo "ERROR: Release $TAG already exists on Gitea."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Create Gitea release ──────────────────────────────────────────────────────
|
||||
echo "→ Creating Gitea release $TAG..."
|
||||
# Escape notes for JSON
|
||||
NOTES_JSON=$(echo "$NOTES" | python3 -c "import sys,json; print(json.dumps(sys.stdin.read()))")
|
||||
|
||||
RELEASE_RESPONSE=$(curl -s -X POST \
|
||||
"${GITEA_URL}/api/v1/repos/${GITEA_USER}/${GITEA_REPO}/releases" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"tag_name\": \"${TAG}\",
|
||||
\"name\": \"PhotoPhetch ${TAG}\",
|
||||
\"body\": ${NOTES_JSON},
|
||||
\"draft\": false,
|
||||
\"prerelease\": false
|
||||
}")
|
||||
|
||||
RELEASE_ID=$(echo "$RELEASE_RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])" 2>/dev/null)
|
||||
if [ -z "$RELEASE_ID" ]; then
|
||||
echo "ERROR: Failed to create release. Response:"
|
||||
echo "$RELEASE_RESPONSE"
|
||||
exit 1
|
||||
fi
|
||||
echo "→ Created release ID $RELEASE_ID"
|
||||
|
||||
# ── Upload DMG ────────────────────────────────────────────────────────────────
|
||||
echo "→ Uploading $DMG_NAME..."
|
||||
UPLOAD_RESPONSE=$(curl -s -X POST \
|
||||
"${GITEA_URL}/api/v1/repos/${GITEA_USER}/${GITEA_REPO}/releases/${RELEASE_ID}/assets?name=${DMG_NAME}" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@${DMG_PATH}")
|
||||
|
||||
DOWNLOAD_URL=$(echo "$UPLOAD_RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('browser_download_url',''))" 2>/dev/null)
|
||||
if [ -z "$DOWNLOAD_URL" ]; then
|
||||
echo "WARNING: Upload response unexpected:"
|
||||
echo "$UPLOAD_RESPONSE"
|
||||
else
|
||||
echo ""
|
||||
echo "✓ Released: $DOWNLOAD_URL"
|
||||
echo " Release page: ${GITEA_URL}/${GITEA_USER}/${GITEA_REPO}/releases/tag/${TAG}"
|
||||
fi
|
||||
0
releases/.gitkeep
Normal file
0
releases/.gitkeep
Normal file
46
releases/v1.0.0.md
Normal file
46
releases/v1.0.0.md
Normal file
@@ -0,0 +1,46 @@
|
||||
## PhotoPhetch v1.0.0 — First Release
|
||||
|
||||
PhotoPhetch is a macOS desktop app for importing photos from Android and iPhone phones into a PhotoPhile staging directory.
|
||||
|
||||
### Features
|
||||
|
||||
**Android (Samsung, Pixel, and others)**
|
||||
- Connects via ADB — scans all DCIM and Pictures subfolders automatically
|
||||
- Smart filtering: excludes Screenshots, Screen Recordings, WhatsApp, Telegram, and other app folders
|
||||
- Folder structure preserved on import (Camera/, Screenshots/, etc.)
|
||||
- Thumbnails loaded and cached locally
|
||||
|
||||
**iPhone**
|
||||
- Connects via pymobiledevice3 (AFC protocol) — works on iOS 17+, no gphoto2 needed
|
||||
- Photos classified by filename pattern: Camera, Edited, Videos, Screenshots
|
||||
- Live Photo detection: companion MOV files hidden from grid, HEIC stills show LIVE badge
|
||||
- Atomic import/delete: HEIC and its MOV companion treated as a unit
|
||||
- Thumbnails batch pre-fetched newest-first and cached permanently
|
||||
|
||||
**Import flow**
|
||||
- Browse photos in a two-panel layout: folder jump list on left, dense photo grid on right
|
||||
- Date markers on first photo of each day; +/- buttons to adjust grid density
|
||||
- Tap + on any photo to preview full-size (HEIC converted to JPEG via sips)
|
||||
- Select individual photos, entire days, or entire folders
|
||||
- SHA-256 copy verification; size-based verification when remote hash unavailable
|
||||
- EXIF Make verification via exiftool: flags photos with absent Make as unverified
|
||||
|
||||
**Verify screen**
|
||||
- Per-file status: green (verified), yellow (origin unknown), red (failed)
|
||||
- Filter by: All / OK / Unverified / Failed
|
||||
- Thumbnail chip on each row — click to preview
|
||||
- Soft-remove any file with undo before committing
|
||||
- Single Done button commits all actions: delete from phone + clean staging
|
||||
|
||||
**Settings**
|
||||
- Staging path with native macOS folder picker
|
||||
- Folder name template with {date} and {device} tokens
|
||||
- Auto-increments folder name if it already exists (e.g. 2026-07-28_iPhone15Pro_2)
|
||||
- Persists HEIC conversion, folder structure, and other preferences
|
||||
|
||||
### Requirements
|
||||
- macOS (Intel or Apple Silicon)
|
||||
- Java 17+
|
||||
- ADB for Android: brew install android-platform-tools
|
||||
- pymobiledevice3 for iPhone: brew install pipx && pipx install pymobiledevice3
|
||||
- exiftool for EXIF verification (optional): brew install exiftool
|
||||
@@ -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}")
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +94,10 @@ class AppState(private val configStore: ConfigStore) {
|
||||
private val _selectedDevice = MutableStateFlow<DeviceInfo?>(null)
|
||||
val selectedDevice: StateFlow<DeviceInfo?> = _selectedDevice.asStateFlow()
|
||||
|
||||
/** Incremented after each thumbnail batch completes — DenseThumb observes this to retry */
|
||||
private val _thumbnailVersion = MutableStateFlow(0)
|
||||
val thumbnailVersion: StateFlow<Int> = _thumbnailVersion.asStateFlow()
|
||||
|
||||
fun scanForDevices() {
|
||||
scope.launch {
|
||||
_deviceScan.value = DeviceScanState.Scanning
|
||||
@@ -230,16 +234,37 @@ 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
|
||||
if (batchLoaded > 0) {
|
||||
// Notify UI that new thumbnails are available
|
||||
_thumbnailVersion.value++
|
||||
}
|
||||
log.info("iOS thumbnails: $loaded / ${needed.size} loaded (batch ${batchIdx + 1}/${(needed.size + batchSize - 1) / batchSize})")
|
||||
} catch (e: Exception) {
|
||||
log.warn("iOS thumbnail batch ${batchIdx + 1} failed: ${e.message}")
|
||||
@@ -271,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()
|
||||
@@ -325,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,
|
||||
)
|
||||
|
||||
@@ -344,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)
|
||||
}
|
||||
@@ -377,6 +404,7 @@ class AppState(private val configStore: ConfigStore) {
|
||||
stripLivePhotoMotion = importCfg.stripLivePhotoMotion,
|
||||
deleteAfterVerify = importCfg.deleteAfterVerify,
|
||||
preserveFolderStructure = importCfg.preserveFolderStructure,
|
||||
importLivePhotoCompanions = importCfg.importLivePhotoCompanions,
|
||||
)
|
||||
|
||||
navigateTo(Screen.IMPORTING)
|
||||
@@ -398,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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -417,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()
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -92,6 +117,7 @@ fun BrowseScreen(state: AppState) {
|
||||
|
||||
VerticalDivider()
|
||||
|
||||
val thumbnailVersion by state.thumbnailVersion.collectAsState()
|
||||
DensePhotoGrid(
|
||||
folderSections = folderSections,
|
||||
selectedPhotos = selectedPhotos,
|
||||
@@ -101,6 +127,7 @@ fun BrowseScreen(state: AppState) {
|
||||
state.connectorFor(d).pullToFile(d, devicePath, localPath)
|
||||
} ?: false
|
||||
},
|
||||
thumbnailVersion = thumbnailVersion,
|
||||
columnCount = columnCount,
|
||||
listState = listState,
|
||||
onTogglePhoto = { state.togglePhotoSelection(it) },
|
||||
@@ -110,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),
|
||||
)
|
||||
}
|
||||
@@ -132,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 }
|
||||
@@ -320,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") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -31,6 +33,8 @@ import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
private val thumbLog = org.slf4j.LoggerFactory.getLogger("DenseThumb")
|
||||
|
||||
/**
|
||||
* Dense grid: folder headers + flat photo grid per folder.
|
||||
* Each photo that is the first of its day gets a small date label
|
||||
@@ -43,13 +47,20 @@ fun DensePhotoGrid(
|
||||
selectedPhotos: Set<PhonePhoto>,
|
||||
cacheKey: String,
|
||||
pullFn: suspend (devicePath: String, localDestPath: Path) -> Boolean,
|
||||
thumbnailVersion: Int = 0,
|
||||
columnCount: Int,
|
||||
listState: LazyListState,
|
||||
onTogglePhoto: (PhonePhoto) -> Unit,
|
||||
onSelectAllFolder: (List<PhonePhoto>) -> Unit,
|
||||
onDeselectAllFolder: (List<PhonePhoto>) -> Unit,
|
||||
onPreviewPhoto: ((PhonePhoto) -> Unit)? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
// In-memory bitmap cache — survives cell recycling during scroll
|
||||
// keyed on cacheKey so it resets when device changes
|
||||
val bitmapCache = remember(cacheKey) {
|
||||
mutableMapOf<String, ImageBitmap>()
|
||||
}
|
||||
if (folderSections.isEmpty() || folderSections.all { it.allPhotos.isEmpty() }) {
|
||||
Box(modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(
|
||||
@@ -140,7 +151,10 @@ fun DensePhotoGrid(
|
||||
isSelected = labeled.photo in selectedPhotos,
|
||||
cacheKey = cacheKey,
|
||||
pullFn = pullFn,
|
||||
thumbnailVersion = thumbnailVersion,
|
||||
bitmapCache = bitmapCache,
|
||||
onToggle = { onTogglePhoto(labeled.photo) },
|
||||
onPreview = onPreviewPhoto,
|
||||
dateLabel = labeled.dateLabel,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
@@ -162,15 +176,20 @@ private fun DenseThumb(
|
||||
isSelected: Boolean,
|
||||
cacheKey: String,
|
||||
pullFn: suspend (devicePath: String, localDestPath: Path) -> Boolean,
|
||||
thumbnailVersion: Int,
|
||||
bitmapCache: MutableMap<String, ImageBitmap>,
|
||||
onToggle: () -> Unit,
|
||||
onPreview: ((PhonePhoto) -> Unit)?,
|
||||
dateLabel: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var thumbnail by remember(photo.devicePath) { mutableStateOf<ImageBitmap?>(null) }
|
||||
var thumbFailed by remember(photo.devicePath) { mutableStateOf(false) }
|
||||
// Check in-memory cache first (survives Compose cell recycling during scroll)
|
||||
var thumbnail by remember(photo.devicePath) {
|
||||
mutableStateOf(bitmapCache[photo.devicePath])
|
||||
}
|
||||
|
||||
LaunchedEffect(photo.devicePath) {
|
||||
if (!thumbFailed && thumbnail == null) {
|
||||
LaunchedEffect(photo.devicePath, thumbnailVersion) {
|
||||
if (thumbnail == null) {
|
||||
val bytes = withContext(Dispatchers.IO) {
|
||||
ThumbnailCache.get(
|
||||
cacheKey = cacheKey,
|
||||
@@ -179,12 +198,14 @@ private fun DenseThumb(
|
||||
pullFn = pullFn,
|
||||
)
|
||||
}
|
||||
if (bytes != null) {
|
||||
if (bytes != null && bytes.isNotEmpty()) {
|
||||
runCatching {
|
||||
thumbnail = SkiaImage.makeFromEncoded(bytes).toComposeImageBitmap()
|
||||
}.onFailure { thumbFailed = true }
|
||||
} else {
|
||||
thumbFailed = true
|
||||
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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -230,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)})
|
||||
|
||||
Reference in New Issue
Block a user