Compare commits

88 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

CopyResult: add exifVerified + exifMake fields.

deleteVerifiedFromDevice: skip files where exifVerified=false.

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

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

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

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

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

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

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

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

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

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

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

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

sips: add --setProperty format jpeg to all thumbnail conversions so
HEIC files are always converted to JPEG. SkiaImage on JVM desktop
cannot decode HEIC natively — JPEG is required.
2026-07-30 03:53:24 +00:00
Kyle Bolen
fbd228b768 Add decode failure logging to DenseThumb to diagnose sparse thumbnails 2026-07-30 03:30:47 +00:00
Kyle Bolen
309031aed7 Fix iOS thumbnails: thumbnailVersion triggers DenseThumb retry
When the background batch pre-fetch writes new thumbnails to cache,
thumbnailVersion increments. DenseThumb uses it as a LaunchedEffect key
so cells retry loading from cache after each batch completes.

Also removed thumbFailed permanent gate — thumbnails retry on each
thumbnailVersion change instead of giving up after one failure.
2026-07-30 03:28:31 +00:00
Kyle Bolen
eef4f61050 iOS thumbnails: progress logging + newest-first loading order
Sort photos by dateTaken descending before pre-fetching so most
recent photos get thumbnails first — the ones most likely to import.
Log progress after each batch: 'iOS thumbnails: 30/600 loaded (batch 1/20)'.
Log skip count for already-cached photos on subsequent launches.
2026-07-28 08:48:11 +00:00
Kyle Bolen
6e3f6aa20e iOS thumbnails: batch pre-fetch after photo listing
After loadPhotos completes for an iPhone, spawn a background coroutine
that runs batch-thumbs in chunks of 30 photos per Python process call.
One Python process handles 30 thumbnails instead of one per photo —
reduces startup overhead by 30x.

sips resizes each pulled file to 240px and writes to the cache.
DenseThumb then reads from cache instantly. Files already cached are
skipped.
2026-07-28 08:45:54 +00:00
Kyle Bolen
b12cb4dd1e iOS thumbnails: add batch-thumbs command for future optimization
batch-thumbs pulls multiple files in one Python process session,
amortising the pymobiledevice3 startup cost. Not yet wired into the
app — thumbnails load lazily per-cell and cache permanently.

Known limitation: first load of iPhone thumbnails is slow (~2-3s each)
because each pull spawns a new Python process. After first view they
are instant from the on-disk cache.
2026-07-28 08:44:54 +00:00
Kyle Bolen
66faf4c7e5 iOS thumbnails: use partial 512KB read for faster thumbnail generation
Add pull-thumb command to helper script that uses afc.fread() to read
only the first 512KB of each photo. EXIF thumbnails are in the first
few KB so sips can generate a resized thumbnail from the partial file.
Falls back to full afc.pull() if fread is unavailable.

IosConnector.pullToFile() uses pull-thumb for thumbnail requests.
Full afc.pull() still used for actual photo import.
2026-07-28 08:37:57 +00:00
Kyle Bolen
f739114f7d Fix iOS date: st_mtime is datetime.datetime object, use isoformat() directly 2026-07-28 08:33:36 +00:00
Kyle Bolen
fa2e95bb45 Simplify iOS date parsing: use datetime tuple, confirmed working format 2026-07-28 08:32:33 +00:00
Kyle Bolen
884dc7ec30 Fix iOS date: use 'datetime' tuple key from AFC stat
pymobiledevice3 AfcService.stat() returns 'datetime' as a tuple
(year, month, day, hour, minute, second, microsecond) not 'st_mtime'.
Parse it directly into a datetime object and emit as ISO string.
2026-07-28 08:31:56 +00:00
Kyle Bolen
6f7dad3296 Add debug logging to diagnose iOS stat date issue
iOS helper: log raw stat keys+values for first file to stderr.
IosConnector: already logs stderr at WARN level.
logback.xml: print DEBUG+ for photophetch package to console so
stat output is visible when running ./gradlew run.
2026-07-28 08:29:37 +00:00
Kyle Bolen
fecdef3acf Fix date parsing: handle both nanosecond and second AFC timestamps
Threshold: >1e15 = nanoseconds, >1e9 = seconds. Previous threshold of
1e18 was too high for nanosecond timestamps in the year 2025 range
(which are ~1.75e18, so actually fine). Also revert pull to use
afc.pull() since it exists in this pymobiledevice3 version.
2026-07-28 08:26:30 +00:00
Kyle Bolen
d947018f42 Fix iOS helper: correct date parsing and pull method
Date: AFC st_mtime is nanoseconds since epoch. Fix threshold check:
values > 1e18 are nanoseconds, > 1e9 are seconds. Dec 31 bug was
caused by mtime being treated as seconds when it was nanoseconds,
giving a timestamp near epoch (1970 = Dec 31 local time).

Pull: use get_file_contents() which is the correct async AFC method
(not pull/afc.open which don't exist in this pymobiledevice3 version).
2026-07-28 08:17:39 +00:00
Kyle Bolen
7f11a4b7d6 Fix iOS helper: async pymobiledevice3 API + auto-discover pipx Python
The helper script now uses asyncio.run() for all pymobiledevice3 calls
since create_using_usbmux() and AfcService methods are async in newer
versions.

IosConnector: auto-discovers the pipx venv Python at
~/Library/Application Support/pipx/venvs/pymobiledevice3/bin/python3
instead of using system python3 (which doesn't have the module).
Falls back to ~/.local/pipx/... paths too.

AppConfig python3Path acts as an override if auto-discovery fails.
2026-07-28 08:06:13 +00:00
Kyle Bolen
1ae16f0ead Thumbnails work for both Android and iOS
ThumbnailCache: refactored to accept a device-agnostic pullFn lambda
instead of hardcoded adb parameters.

DeviceConnector: add pullToFile() and cacheKey() to interface.
AndroidConnector: implement pullToFile via adb pull, cacheKey = serial.
IosConnector: implement pullToFile via AFC helper pull, cacheKey = UDID.

DensePhotoGrid/DenseThumb: now receive cacheKey + pullFn instead of
device/adbBin, built by BrowseScreen from the active connector.
2026-07-28 07:50:06 +00:00
Kyle Bolen
50d6f66116 Replace gphoto2 with pymobiledevice3 for iOS connectivity
gphoto2 stopped working on iOS 17+ (Apple tightened PTP access).
New approach uses pymobiledevice3 via AFC protocol — the same protocol
Finder uses to browse the iPhone.

- photophetch-ios-helper.py: bundled Python script (in JAR resources)
  that calls pymobiledevice3 AFC to list-devices, list-photos, pull, delete
- IosConnector: rewritten to shell out to the helper script instead of
  gphoto2. Extracts the script from JAR to a temp file on first use.
- AppConfig: gphoto2Path → python3Path
- AppState: IosConnector now receives python3Path

Prerequisites: brew install pipx && pipx install pymobiledevice3
2026-07-28 07:47:16 +00:00
Kyle Bolen
66378b4a02 Document and handle USB conflict between iPhone and Android in Charging mode
IosConnector: add comment explaining 0-folders causes.
DensePhotoGrid: show actionable hint when no photos found (unlock screen,
switch Android to File Transfer, killall PTPCamera).
README: add 'Using iPhone and Android at the same time' section.
2026-07-28 06:56:15 +00:00
Kyle Bolen
617ce03cf9 Remove grouped view mode — dense grid is the only view
Deleted: ViewMode enum, UnifiedPhotoScroll, DaySubHeader, PhotoThumb
(grouped-mode cell), view toggle button, groupedListState, groupedScrollIndex.

BrowseScreen is now ~100 lines simpler. DensePhotoGrid is the single
grid implementation. FolderJumpList and FolderDividerHeader remain.
2026-07-28 06:41:59 +00:00
Kyle Bolen
c84d3426a7 Dense date labels: rule + label above column, row alignment Bottom
Date label cells show 'Jul 28 ────' above the thumbnail.
Cells without a label have no spacer — they sit tightly against the
row above. Row uses Alignment.Bottom so all thumbnails in a row
align at the bottom edge even when some cells have a label header
pushing them down.
2026-07-28 06:39:19 +00:00
Kyle Bolen
a06c77f153 Fix DensePhotoGrid: date label above each photo that starts a new day
Each photo that is the first of its day in a folder gets a 16dp
'Jul 28' label above its thumbnail. Other photos in the same row get
an invisible 16dp spacer so all thumbnails stay vertically aligned.
Label is per-column, not full-width.
2026-07-28 06:37:21 +00:00
Kyle Bolen
fa1a42000e Dense view: date label above each specific photo that starts a new day
Each photo that is the first of its day gets a 14dp date label
('Jul 28') above its thumbnail only — within its column, not spanning
the row. Other photos in the same row get an invisible spacer so all
thumbnails stay aligned. Result: date markers appear above individual
photos exactly where the day changes.
2026-07-28 06:36:33 +00:00
Kyle Bolen
361eab792b Dense view: slim date label row above each day, including first day of folder
Each new day gets a 18dp full-width row: 'Jul 28 ────────────'
that appears above the first row of photos for that date.
First day of each folder section now also gets a date row (previously
skipped). No photo overlays. denseScrollIndex updated to count date rows.
2026-07-28 06:33:40 +00:00
Kyle Bolen
a6b489f133 Fix scroll spy + date overlay on first photo of each day
Scroll spy: use key(viewMode) around derivedStateOf so activeFolder
recomputes against the correct listState when switching modes.

Dense date markers: remove separate DAY_BOUNDARY row item entirely.
Date label ('Jul 28') now overlaid on the top-left corner of the first
photo in each new day — zero wasted vertical space.
2026-07-28 06:31:33 +00:00
Kyle Bolen
60d72ce0bd Dense view: inline day markers + fix folder jump list
Replace date scrubber strip with inline day boundary markers:
a thin rule + 'Mon, Jul 28' label that scrolls with the photos.
No separate panel — markers live in the grid flow.

Fix folder jump list in Dense mode: maintain separate denseListState
and denseScrollIndex (accounting for day boundary items). activeFolder
scroll spy now reads from whichever listState is active.
2026-07-28 06:27:41 +00:00
Kyle Bolen
6b624651fd Fix: extract FolderSection/GridItem to GridModels.kt, make FolderDividerHeader internal
FolderSection was private in BrowseScreen.kt, blocking DensePhotoGrid
from using it. Moved to GridModels.kt along with GridItemType and GridItem.
FolderDividerHeader made internal so both grid views can share it.
2026-07-28 06:24:08 +00:00
Kyle Bolen
f3e93c2bd0 Dense mode: add folder headers between folder sections
DensePhotoGrid now takes folderSections (same as Grouped) and renders
a FolderDividerHeader with Select All / Deselect All between each
folder's photos. The flat grid flows within each folder section.
Date scrubber indices account for the header items.
2026-07-28 06:23:16 +00:00
Kyle Bolen
69a5f451d5 Add Dense view mode with date scrubber (toggle in top bar)
DensePhotoGrid: flat grid with no day-header rows. A 28dp scrubber
strip on the left shows proportionally-positioned date labels (month
+ day). Clicking a date label jumps to that row via animateScrollToItem.

BrowseTopBar: 'Grouped/Dense' FilterChip toggles between views.
Dense mode passes all photos sorted newest-first; grouped mode keeps
the existing folder+day structure.

Both modes share the same column count and selection state.
2026-07-28 06:20:46 +00:00
Kyle Bolen
2eb19a8a5d Adaptive day headers + grid column controls
Day headers:
- Dense days (>=3 photos): full header with date, count, Select/Deselect day
- Sparse days (<3 photos): small date chip overlay on first photo, no
  header row — avoids wasted space when one photo per day for a stretch

Grid column controls:
- +/- buttons in top bar change column count (2-8)
- folderScrollIndex accounts for adaptive headers and column count so
  jump-to-folder stays accurate after column changes
2026-07-28 06:11:43 +00:00
Kyle Bolen
9390b24c7a Disable Android remote checksum to fix false hash mismatch errors
Samsung toybox sha256sum output format is inconsistent (CRLF, error
messages mixed with output) causing false positives. Verification now
uses: file size vs ADB-reported size + local SHA-256 recorded on dest.
This is sufficient — the local SHA-256 gives you a checksum you can
verify later if needed.
2026-07-28 06:04:13 +00:00
Kyle Bolen
3cbab36e82 Scan Downloads, expand media extensions to AVIF/HEIF/WEBP/RAW/MKV etc
AndroidConnector:
- Add /storage/emulated/0/Download to scan list (flat dir, no subdirs)
- Expand mediaExtensions: AVIF, HEIF, WEBP, DNG/RAW/ARW/CR2/NEF/ORF,
  TIFF, BMP, MPEG/MPG, M4V, MKV, WEBM, 3G2
- Update video detection set to match

ExifFilter: remove 'download' from excluded fragments so Downloads
folder shows in the Other section rather than being hidden.
2026-07-28 05:56:57 +00:00
Kyle Bolen
60421c9f74 Expand scan to Pictures/ subdirs, add app folder exclusions
AndroidConnector: scan both /storage/emulated/0/DCIM and
/storage/emulated/0/Pictures — all subdirs of each.

ExifFilter: add Slack, Teams, Zoom, Discord, sticker, gif, avatar,
profile, thumbnail, cache to excluded path fragments. These land in
the Other section of the folder panel.
2026-07-28 05:52:09 +00:00
Kyle Bolen
816d818625 Fix folder structure and improve no-remote-hash verification
Copier: preserveFolderStructure now uses photo.sourceFolder directly
(always correct) instead of parsing devicePath, and applies regardless
of how many source folders are in the job.

Verification without remote hash: compare file size against ADB-reported
size (from ls -la) in addition to checking non-empty. Record local
SHA-256 so there's always a checksum on file even without a remote one.

VerifyScreen: show 'local SHA-256: abc123...' for copied-without-remote-
hash files instead of '(no remote checksum)'.
2026-07-28 05:44:10 +00:00
Kyle Bolen
3c59638759 Fix verification messaging: distinguish three copy outcomes
Three states:
1. SHA-256 verified (green) — remote hash matched dest hash
2. Copied, no remote checksum (secondary color) — file copied OK,
   device doesn't support sha256sum (Samsung/toybox)
3. Failed (red) — copy exception or hash mismatch

Previously all non-checksum cases showed 'may be corrupt' which
was a false alarm on devices without sha256sum support.
2026-07-28 05:42:51 +00:00
Kyle Bolen
0be43f2236 VerifyScreen: pin scroll shadows to divider edges
Top shadow: 16dp box immediately below the top divider, drawn as a
downward gradient. Appears when scrolled past first item.
Bottom shadow: 16dp box immediately above the bottom divider, drawn as
an upward gradient. Disappears when last item is visible.
Content scrolls under both edges — shadows are structural, not floating.
2026-07-28 05:40:22 +00:00
Kyle Bolen
7aa7e4dae1 VerifyScreen: top + bottom scroll shadows on results list
Top shadow: fades in as you scroll down from the top (content hidden above).
Bottom shadow: present when last item is not yet visible (content below).
Both use drawWithContent overlay at 8% black opacity, 24dp strip.
2026-07-28 05:36:55 +00:00
Kyle Bolen
87241ba1d4 ImportScreen: shadow fades out over last 80px of scroll
shadowAlpha = (remaining / 80).coerceIn(0,1) — full strength when
far from bottom, smoothly fades to zero over the last 80px.
2026-07-28 05:34:38 +00:00
Kyle Bolen
8306aa2729 ImportScreen: replace fade with bottom edge shadow
24dp shadow strip on the bottom edge of the scroll area when more
content is below. Disappears when scrolled to bottom. Matches how
Material dialogs and macOS sheets indicate overflow.
2026-07-28 05:33:17 +00:00
Kyle Bolen
9c489306f4 ImportScreen: fade gradient indicates more content below
derivedStateOf tracks scrollState.value < maxValue. When not at
bottom, a 48dp white-to-transparent gradient overlays the bottom
of the scroll area. Disappears when fully scrolled.
2026-07-28 05:31:39 +00:00
Kyle Bolen
edaf4cb0d7 ImportScreen: scrollable content with pinned bottom button
- Body scrolls via verticalScroll
- Header and Start Import button stay fixed outside the scroll
- Summary card now shows source folder breakdown for multi-folder imports
- HorizontalDivider separates pinned button from scroll area
2026-07-28 05:29:17 +00:00
Kyle Bolen
fd5753382f Rewrite BrowseScreen: unified scroll with folder sections + jump list
- Single LazyColumn with folder divider headers embedded — no page switching
- Left panel is a jump list: click scrolls to that folder, highlights
  active folder as you scroll (derivedStateOf scroll spy)
- Each folder section header has Select All / Deselect All buttons
- Each day sub-header has Select Day / Deselect Day toggle
- Removed 'All Photos' merged view — folders are always separate sections
- PhotoThumb moved inline, cleaned up imports
2026-07-28 05:24:29 +00:00
Kyle Bolen
f6aede99f1 Fix device name, ImportScreen layout, and preserve folder default
- AndroidConnector: use model: field for display name instead of
  product: (which is a cryptic codename like 'o1quew' on Samsung)
- ImportScreen: clean up layout with ToggleRow helper, remove duplicate
  toggles, all 4 options in one Options section
- AppState: preserveFolderStructure defaults to true
2026-07-28 05:20:02 +00:00
Kyle Bolen
409cb3b4d1 Fix doubled photos: /sdcard is a symlink to /storage/emulated/0
Scanning both paths listed every photo twice. Now only scan
/storage/emulated/0/DCIM which is the canonical path.
2026-07-28 05:08:32 +00:00
Kyle Bolen
0e6f0d9c7e Rewrite BrowseScreen: folder panel + day-grouped photo grid
New two-panel layout:
- Left (200dp): FolderPanel lists all DCIM subfolders with photo count
  and date range. Camera-roll folders highlighted separately from other
  folders (Screenshots, Video Editor, etc.). Selection badge per folder.
- Right: DayGroupedGrid groups photos by date with day headers. Each day
  header shows count and a 'Select day' / 'Deselect day' toggle.
  Photos displayed in rows of 4 with thumbnails.

PhonePhoto now carries sourceFolder name. AndroidConnector tags each
photo with its DCIM subfolder. AppState manages selectedFolder and
visiblePhotos StateFlows. ExifFilter.isCameraRollFolder() classifies
folders as camera-roll vs other.

Preserves all existing functionality: thumbnails, import flow, HEIC
warning, selection.
2026-07-28 05:05:21 +00:00
Kyle Bolen
43086a91f1 Thumbnails: pull full file + sips resize, max 3 concurrent ADB pulls
MediaStore content query is unreliable on Samsung. Switch to:
1. adb pull full file to tmp
2. sips --resampleWidth 240 to generate thumbnail
3. Cache in ~/.photophetch/thumbcache/<serial>/
4. Semaphore(3) limits concurrent pulls — cells scrolled past are
   cancelled by Compose LaunchedEffect lifecycle automatically.

First load per photo takes 2-3s. Subsequent views instant from cache.
2026-07-28 04:57:13 +00:00
Kyle Bolen
301291616d Fix thumbnails: use MediaStore thumbnail DB instead of partial file pull
Previous approach pulled first 64KB of JPEG which produces a truncated
file Skia cannot decode. New approach:
1. Query MediaStore content:// URI to find the pre-generated thumbnail
   path for each photo (Android generates these automatically)
2. Pull just the thumbnail file (~20-50KB vs 3-5MB full file)
3. Cache locally — subsequent views are instant

Falls back gracefully (placeholder icon) if MediaStore query fails.
2026-07-28 04:49:20 +00:00
Kyle Bolen
770aa58dd0 Fix listing speed + add thumbnails
AndroidConnector: replace per-file ls -la with single directory ls -la
call — one ADB round trip per folder instead of one per file. Much
faster for large Camera folders.

BrowseScreen: add thumbnail loading via ThumbnailCache. Pulls first
64KB of each JPEG via adb exec-out dd (enough for embedded EXIF thumb),
caches to ~/.photophetch/thumbcache/<serial>/. Loads asynchronously
on Dispatchers.IO so UI stays responsive. Placeholder icon shown until
thumbnail arrives.
2026-07-28 04:40:42 +00:00
Kyle Bolen
ec114aa7ce Fix Android file listing: replace stat with ls -la for compatibility
Samsung (and other Android vendors using toybox) do not support GNU
stat -c flag syntax. Switch to find for file discovery + ls -la per
file for size/date metadata, which works on all Android versions.
2026-07-28 04:36:40 +00:00
Kyle Bolen
3da4e464d2 Add Show All mode with optional folder structure preservation
- BrowseScreen: Show All toggle chip in toolbar bypasses ExifFilter
  and shows every file from every DCIM subfolder
- ImportScreen: Preserve folder structure toggle (auto-enabled in Show
  All mode) — copies files into Camera/, Screenshots/ etc subfolders
  under the staging destination instead of flat
- AppState: _showAll StateFlow, toggleShowAll(), loadPhotos and
  setDateFilter respect the flag
- Copier: extracts DCIM subfolder from devicePath and creates matching
  subdirectory in staging when preserveFolderStructure=true
2026-07-28 04:28:17 +00:00
Kyle Bolen
ff812a9550 Tighten ExifFilter: exclude Screen Recordings, Videocaptures, Resize folders 2026-07-28 04:25:25 +00:00
Kyle Bolen
af36ae5663 Fix Android photo discovery: dynamically scan all DCIM subdirs
Instead of hardcoding /sdcard/DCIM/Camera, list all subdirectories
under /sdcard/DCIM and /storage/emulated/0/DCIM dynamically. This
handles Samsung, Pixel, OnePlus and other vendors that use different
DCIM subfolder names. Falls back to hardcoded list if discovery finds
nothing.
2026-07-28 04:22:23 +00:00
Kyle Bolen
5b17d28f66 docs: document PTPCamera + AMPDeviceDiscoveryAgent conflict and fix 2026-07-28 03:56:54 +00:00
Kyle Bolen
35b98e1857 Auto-increment folder name if staging folder already exists
StagingPath.uniqueFolderName() checks whether the base folder name is
already taken and appends _2, _3, etc. until a free name is found.
AppState.prepareImport() now uses this so the ImportScreen always
pre-fills a unique folder name.
2026-07-28 03:48:30 +00:00
Kyle Bolen
05897e6d62 Add native macOS folder picker for staging directory
Uses AWT FileDialog with apple.awt.fileDialogForDirectories=true to open
the native macOS folder chooser. Falls back gracefully (dialog cancelled)
with no change to the text field.
2026-07-28 03:47:31 +00:00
Kyle Bolen
105077baba docs: clarify Android USB mode selection (Transferring files, not PTP) 2026-07-28 03:44:19 +00:00
Kyle Bolen
bbc764dbcb Fix Gradle toolchain resolution on macOS/Corretto
Replace jvmToolchain(17) with jvmToolchain(21) + explicit kotlinOptions
jvmTarget=17 + java sourceCompatibility/targetCompatibility. Avoids
toolchain provisioning errors when the JDK is present but not registered
via the Gradle toolchain API.
2026-07-28 03:38:02 +00:00
27 changed files with 2650 additions and 745 deletions

View File

@@ -65,13 +65,35 @@ java -jar build/compose/jars/photophetch-macos-*.jar
## iPhone notes
When you plug in an iPhone, macOS's built-in `PTPCamera` daemon may grab the device before gphoto2 can. If PhotoPhetch finds the device but shows no photos:
When you plug in an iPhone, macOS's built-in `PTPCamera` and `AMPDeviceDiscoveryAgent` daemons grab the device before gphoto2 or ADB can. Symptoms: "Could not connect to device" errors, Image Capture showing the phone under DEVICES with nothing listed, or `LOC:something` entries cycling in Image Capture.
Kill them before scanning:
```bash
killall PTPCamera
killall AMPDeviceDiscoveryAgent
```
Then rescan in the app.
Close Image Capture first if it's open. Then rescan in PhotoPhetch.
To stop them from re-grabbing the device on every USB connect (recommended if you use PhotoPhetch regularly):
```bash
launchctl unload -w /System/Library/LaunchAgents/com.apple.ptpcamera.plist
```
To re-enable Image Capture later:
```bash
launchctl load -w /System/Library/LaunchAgents/com.apple.ptpcamera.plist
```
### Using iPhone and Android at the same time
If you have both phones plugged in, the Android phone must be in **Transferring files** mode (not Charging only). An Android phone in Charging mode can block macOS USB enumeration and cause gphoto2 to see 0 folders on the iPhone. Either:
- Switch the Android to **Transferring files** mode from its notification shade, or
- Unplug the Android while working with the iPhone
### HEIC settings
@@ -85,6 +107,8 @@ Enable USB debugging:
> Settings → About Phone → tap Build Number 7 times → Developer Options → USB Debugging
When you plug in the USB cable, a prompt will appear on the phone asking for the connection mode. Choose **"Transferring files / Android Auto"** (also called "File Transfer" on some phones). Do not choose "Transferring images" (PTP) — it restricts file access and breaks ADB browsing.
Accept the "Trust this computer?" prompt on the phone when connecting.
## Project structure

View File

@@ -10,7 +10,16 @@ group = "com.bolenpad.photophetch"
version = "1.0.0"
kotlin {
jvmToolchain(17)
jvmToolchain(21)
}
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
kotlinOptions.jvmTarget = "17"
}
java {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
dependencies {
@@ -34,9 +43,14 @@ compose.desktop {
packageVersion = "1.0.0"
description = "Import photos from phones into PhotoPhile"
vendor = "bolenpad"
copyright = "© 2026 Kyle Bolen"
macOS {
bundleID = "com.bolenpad.photophetch"
iconFile.set(project.file("src/main/resources/icon.icns"))
// Uncomment and add icon.icns to src/main/resources/ for a custom icon:
// iconFile.set(project.file("src/main/resources/icon.icns"))
signing {
sign.set(false) // set true + add identity for App Store / Gatekeeper
}
}
}

1
gradle.properties Normal file
View File

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

68
prepare-release.sh Executable file
View File

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

119
release.sh Executable file
View File

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

0
releases/.gitkeep Normal file
View File

46
releases/v1.0.0.md Normal file
View File

@@ -0,0 +1,46 @@
## PhotoPhetch v1.0.0 — First Release
PhotoPhetch is a macOS desktop app for importing photos from Android and iPhone phones into a PhotoPhile staging directory.
### Features
**Android (Samsung, Pixel, and others)**
- Connects via ADB — scans all DCIM and Pictures subfolders automatically
- Smart filtering: excludes Screenshots, Screen Recordings, WhatsApp, Telegram, and other app folders
- Folder structure preserved on import (Camera/, Screenshots/, etc.)
- Thumbnails loaded and cached locally
**iPhone**
- Connects via pymobiledevice3 (AFC protocol) — works on iOS 17+, no gphoto2 needed
- Photos classified by filename pattern: Camera, Edited, Videos, Screenshots
- Live Photo detection: companion MOV files hidden from grid, HEIC stills show LIVE badge
- Atomic import/delete: HEIC and its MOV companion treated as a unit
- Thumbnails batch pre-fetched newest-first and cached permanently
**Import flow**
- Browse photos in a two-panel layout: folder jump list on left, dense photo grid on right
- Date markers on first photo of each day; +/- buttons to adjust grid density
- Tap + on any photo to preview full-size (HEIC converted to JPEG via sips)
- Select individual photos, entire days, or entire folders
- SHA-256 copy verification; size-based verification when remote hash unavailable
- EXIF Make verification via exiftool: flags photos with absent Make as unverified
**Verify screen**
- Per-file status: green (verified), yellow (origin unknown), red (failed)
- Filter by: All / OK / Unverified / Failed
- Thumbnail chip on each row — click to preview
- Soft-remove any file with undo before committing
- Single Done button commits all actions: delete from phone + clean staging
**Settings**
- Staging path with native macOS folder picker
- Folder name template with {date} and {device} tokens
- Auto-increments folder name if it already exists (e.g. 2026-07-28_iPhone15Pro_2)
- Persists HEIC conversion, folder structure, and other preferences
### Requirements
- macOS (Intel or Apple Silicon)
- Java 17+
- ADB for Android: brew install android-platform-tools
- pymobiledevice3 for iPhone: brew install pipx && pipx install pymobiledevice3
- exiftool for EXIF verification (optional): brew install exiftool

View File

@@ -24,17 +24,32 @@ class AndroidConnector(
private val log = LoggerFactory.getLogger(AndroidConnector::class.java)
// Directories on Android that contain camera-roll originals
private val cameraDirs = listOf(
"/sdcard/DCIM/Camera",
"/sdcard/DCIM/100ANDRO",
"/sdcard/Pictures/Camera",
// Directories on Android that contain camera-roll originals.
// Used as fallback if dynamic DCIM discovery finds nothing.
private val fallbackCameraDirs = listOf(
"/storage/emulated/0/DCIM/Camera",
"/storage/emulated/0/Pictures/Camera",
)
// Photo/video extensions to include
// Photo/video extensions to include — covers all common camera and download formats
private val mediaExtensions = setOf(
"jpg", "jpeg", "png", "dng", "raw", // photos
"mp4", "mov", "3gp", "avi", // videos
// Photos
"jpg", "jpeg",
"heic", "heif",
"avif",
"webp",
"png",
"dng", "raw", "arw", "cr2", "cr3", "nef", "orf", "rw2",
"tiff", "tif",
"bmp",
// Videos
"mp4", "m4v",
"mov",
"mpeg", "mpg", "mpe",
"3gp", "3g2",
"avi",
"mkv",
"webm",
)
override suspend fun detectDevices(): List<DeviceInfo> {
@@ -49,12 +64,17 @@ class AndroidConnector(
if (parts.size < 2 || parts[1] != "device") return@mapNotNull null
val serial = parts[0]
val model = parts.firstOrNull { it.startsWith("model:") }
?.removePrefix("model:") ?: "Android Device"
?.removePrefix("model:")
?.replace("_", " ")
?: "Android Device"
val product = parts.firstOrNull { it.startsWith("product:") }
?.removePrefix("product:")
?.replace("_", " ")
// Prefer model name — it's human-readable (e.g. "SM G991B")
// product is often a cryptic codename (e.g. "o1quew")
val displayName = model.ifBlank { product ?: "Android Device" }
DeviceInfo(
displayName = product?.replace("_", " ")?.replaceFirstChar { it.uppercase() }
?: model.replace("_", " "),
displayName = displayName.trim(),
type = DeviceType.ANDROID,
adbSerial = serial,
)
@@ -65,45 +85,63 @@ class AndroidConnector(
val serial = requireSerial(device)
val photos = mutableListOf<PhonePhoto>()
for (dir in cameraDirs) {
// Check if directory exists first
// Scan all subdirectories under DCIM and Pictures, plus Downloads root.
// /sdcard is a symlink to /storage/emulated/0 — scan only the canonical path.
val scanRoots = listOf(
"/storage/emulated/0/DCIM",
"/storage/emulated/0/Pictures",
)
val cameraDirs = mutableListOf<String>()
for (root in scanRoots) {
val lsOutput = runAdb(serial, "shell", "ls", root, "2>/dev/null")
if (lsOutput.contains("No such file") || lsOutput.isBlank()) continue
lsOutput.lines()
.map { it.trim() }
.filter { it.isNotBlank() && !it.contains("No such file") }
.forEach { subdir -> cameraDirs.add("$root/$subdir") }
}
// Downloads is a flat directory (no subdirs worth scanning), add it directly
cameraDirs.add("/storage/emulated/0/Download")
// Fall back to hardcoded list if dynamic discovery found nothing
val dirsToScan = cameraDirs.ifEmpty { fallbackCameraDirs }
for (dir in dirsToScan) {
val check = runAdb(serial, "shell", "ls", dir, "2>/dev/null")
if (check.contains("No such file") || check.isBlank()) continue
// List files with metadata: size, date, time, name
// `stat` format: size=%s mtime=%Y path=%n
val statOutput = runAdb(
serial, "shell",
"find", dir, "-maxdepth", "1", "-type", "f",
"-exec", "stat", "-c", "%s %Y %n", "{}", ";"
)
// Derive the human-readable folder name from the path
val folderName = dir.substringAfterLast("/")
for (line in statOutput.lines()) {
val lsOutput = runAdb(serial, "shell", "ls", "-la", dir)
for (line in lsOutput.lines()) {
val trimmed = line.trim()
if (trimmed.isBlank()) continue
val parts = trimmed.split(" ", limit = 3)
if (parts.size < 3) continue
if (trimmed.isBlank() || trimmed.startsWith("total") || trimmed.startsWith("d")) continue
val sizeBytes = parts[0].toLongOrNull() ?: continue
val epochSecs = parts[1].toLongOrNull() ?: continue
val path = parts[2]
val filename = path.substringAfterLast("/")
val parts = trimmed.split(Regex("\\s+"), limit = 8)
if (parts.size < 8) continue
val filename = parts[7].trim()
val ext = filename.substringAfterLast(".", "").lowercase()
if (ext !in mediaExtensions) continue
val sizeBytes = parts[4].toLongOrNull() ?: 0L
val dateTaken = parseDate(parts[5], parts[6])
photos.add(
PhonePhoto(
devicePath = path,
devicePath = "$dir/$filename",
filename = filename,
sizeBytes = sizeBytes,
dateTaken = Instant.ofEpochSecond(epochSecs),
// Android stat doesn't give EXIF; we infer Make/Model from
// the device itself rather than per-file EXIF
dateTaken = dateTaken,
exifMake = device.displayName,
exifModel = device.displayName,
mediaType = if (ext in setOf("mp4", "mov", "3gp", "avi"))
mediaType = if (ext in setOf("mp4", "m4v", "mov", "mpeg", "mpg", "mpe", "3gp", "3g2", "avi", "mkv", "webm"))
MediaType.VIDEO else MediaType.PHOTO,
sourceFolder = folderName,
)
)
}
@@ -123,6 +161,14 @@ class AndroidConnector(
return destFile
}
override suspend fun pullToFile(device: DeviceInfo, devicePath: String, destinationPath: Path): Boolean {
val serial = requireSerial(device)
val output = runAdb(serial, "pull", devicePath, destinationPath.absolutePathString())
return output.contains("pulled") || output.contains("1 file")
}
override fun cacheKey(device: DeviceInfo): String = device.adbSerial ?: device.displayName
override suspend fun deletePhoto(device: DeviceInfo, photo: PhonePhoto) {
val serial = requireSerial(device)
val output = runAdb(serial, "shell", "rm", photo.devicePath)
@@ -132,23 +178,12 @@ class AndroidConnector(
log.info("Deleted ${photo.filename} from device")
}
override suspend fun remoteChecksum(device: DeviceInfo, photo: PhonePhoto): String? {
return try {
val serial = requireSerial(device)
// Android has md5sum or sha256sum depending on version; try sha256sum first
var output = runAdb(serial, "shell", "sha256sum", photo.devicePath)
if (output.contains("not found") || output.isBlank()) {
runAdb(serial, "shell", "md5sum", photo.devicePath)
// md5sum isn't SHA-256 — return null so Copier uses its own verification
return null
}
// sha256sum output: "<hash> <path>"
output.trim().split(Regex("\\s+")).firstOrNull()
} catch (e: Exception) {
log.warn("Remote checksum failed for ${photo.filename}: ${e.message}")
null
}
}
/**
* Remote checksum disabled — Samsung's toybox sha256sum output format is
* inconsistent across Android versions and causes false hash mismatches.
* Verification uses file size comparison + local SHA-256 instead.
*/
override suspend fun remoteChecksum(device: DeviceInfo, photo: PhonePhoto): String? = null
// -------------------------------------------------------------------------
// Helpers
@@ -157,6 +192,25 @@ class AndroidConnector(
private fun requireSerial(device: DeviceInfo): String =
device.adbSerial ?: throw DeviceException("No ADB serial for device: ${device.displayName}")
/**
* Parse date + time from ls -la tokens.
* Common formats: "2026-07-15" "14:22" or "2026-07-15" "14:22:01"
*/
private fun parseDate(datePart: String, timePart: String): java.time.Instant {
return try {
val timeClean = timePart.substringBefore(".") // strip sub-seconds if present
val formatter = if (timeClean.length == 5) {
java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
} else {
java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
}
java.time.LocalDateTime.parse("$datePart $timeClean", formatter)
.atZone(java.time.ZoneId.systemDefault()).toInstant()
} catch (_: Exception) {
java.time.Instant.EPOCH
}
}
private fun runAdb(serial: String?, vararg args: String): String {
val cmd = buildList {
add(adbBin)

View File

@@ -26,10 +26,22 @@ interface DeviceConnector {
/**
* Pull a single photo from the device to [destinationDir].
* Returns the local path of the pulled file.
* @throws DeviceException on adb/gphoto2 failure
* @throws DeviceException on adb/AFC failure
*/
suspend fun pullPhoto(device: DeviceInfo, photo: PhonePhoto, destinationDir: Path): Path
/**
* Pull a file from the device to an exact [destinationPath].
* Used for thumbnail fetching. Returns true on success.
*/
suspend fun pullToFile(device: DeviceInfo, devicePath: String, destinationPath: Path): Boolean
/**
* A stable cache key for this device (e.g. ADB serial or UDID).
* Used by ThumbnailCache to namespace cached thumbnails.
*/
fun cacheKey(device: DeviceInfo): String
/**
* Delete a photo from the device.
* Only called after the user explicitly confirms deletion.

View File

@@ -4,6 +4,16 @@ import com.bolenpad.photophetch.model.DeviceInfo
import com.bolenpad.photophetch.model.DeviceType
import com.bolenpad.photophetch.model.MediaType
import com.bolenpad.photophetch.model.PhonePhoto
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.longOrNull
import org.slf4j.LoggerFactory
import java.nio.file.Path
import java.time.Instant
@@ -13,206 +23,203 @@ import java.time.format.DateTimeFormatter
import kotlin.io.path.absolutePathString
/**
* DeviceConnector implementation for iPhones via gphoto2.
* DeviceConnector implementation for iPhones via pymobiledevice3.
*
* Requires `gphoto2` on PATH (or override via [gphoto2Bin]).
* Install via: brew install gphoto2
* Requires pymobiledevice3 installed via pipx:
* brew install pipx && pipx install pymobiledevice3
*
* Important macOS note: The built-in Image Capture daemon (PTPCamera) will
* conflict with gphoto2. If listing returns no files, run:
* killall PTPCamera
* PhotoPhetch shows instructions for this when no photos are found on a
* connected iPhone.
* The helper script is bundled in the JAR and extracted to a temp file.
* It must be run with the pipx virtualenv Python (not system python3)
* since pymobiledevice3 is installed in the pipx venv.
*
* Live Photo handling: gphoto2 lists both the .HEIC and the companion .MOV
* with matching base names. We detect the pair by comparing filenames and
* flag [PhonePhoto.isLivePhotoStill] / [PhonePhoto.isLivePhotoMotion].
* The MOV companion is excluded from import by default.
* Auto-discovers the pipx Python at:
* ~/Library/Application Support/pipx/venvs/pymobiledevice3/bin/python3
*/
class IosConnector(
private val gphoto2Bin: String = "gphoto2",
pythonBinOverride: String? = null,
) : DeviceConnector {
private val log = LoggerFactory.getLogger(IosConnector::class.java)
private val json = Json { ignoreUnknownKeys = true }
// gphoto2 date format in --list-files output
private val gphotoDateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
internal val pythonBin: String = pythonBinOverride ?: discoverPython()
companion object {
private val PIPX_PYTHON_CANDIDATES = listOf(
// macOS pipx default (Intel and Apple Silicon)
"${System.getProperty("user.home")}/Library/Application Support/pipx/venvs/pymobiledevice3/bin/python3",
"${System.getProperty("user.home")}/.local/pipx/venvs/pymobiledevice3/bin/python3",
"/usr/local/pipx/venvs/pymobiledevice3/bin/python3",
)
fun discoverPython(): String {
for (candidate in PIPX_PYTHON_CANDIDATES) {
if (java.io.File(candidate).exists()) return candidate
}
// Fallback to system python3 — will fail with "module not found" but gives a clear error
return "python3"
}
fun isPythonAvailable(): Boolean {
val python = discoverPython()
return java.io.File(python).exists() || python == "python3"
}
}
/** Path to the bundled helper script, extracted to a temp location on first use */
internal val helperScript: String by lazy { extractHelperScript() }
override suspend fun detectDevices(): List<DeviceInfo> {
// gphoto2 --auto-detect lists connected cameras/phones
val output = runGphoto2("--auto-detect")
/*
* Output format:
* Model Port
* ----------------------------------------------------------
* Apple iPhone usb:001,023
*/
val devices = mutableListOf<DeviceInfo>()
var parsingStarted = false
for (line in output.lines()) {
if (line.startsWith("---")) { parsingStarted = true; continue }
if (!parsingStarted || line.isBlank()) continue
return try {
val output = runHelper("list-devices")
if (output.isBlank() || output.startsWith("Error")) return emptyList()
// Split on 2+ spaces to separate model from port
val parts = line.trim().split(Regex("\\s{2,}"), limit = 2)
if (parts.size < 2) continue
val model = parts[0].trim()
val port = parts[1].trim()
if (model.contains("iPhone", ignoreCase = true) ||
model.contains("iPad", ignoreCase = true) ||
model.contains("Apple", ignoreCase = true)
) {
devices.add(
DeviceInfo(
displayName = model,
type = DeviceType.IOS,
gphotoPort = port,
)
val array = json.parseToJsonElement(output).jsonArray
array.map { element ->
val obj = element.jsonObject
DeviceInfo(
displayName = obj["name"]?.jsonPrimitive?.contentOrNull ?: "iPhone",
type = DeviceType.IOS,
gphotoPort = obj["udid"]?.jsonPrimitive?.contentOrNull,
)
}
} catch (e: Exception) {
log.warn("iOS device detection failed: ${e.message}")
emptyList()
}
return devices
}
override suspend fun listPhotos(device: DeviceInfo): List<PhonePhoto> {
val port = requirePort(device)
return try {
val output = runHelper("list-photos")
if (output.isBlank() || output.startsWith("Error")) return emptyList()
// gphoto2 --list-files gives index, name, size, date for each file in DCIM
val output = runGphoto2("--port", port, "--list-files")
val array = json.parseToJsonElement(output).jsonArray
val photos = array.mapNotNull { element ->
try {
val obj = element.jsonObject
val devicePath = obj["devicePath"]?.jsonPrimitive?.contentOrNull ?: return@mapNotNull null
val filename = obj["filename"]?.jsonPrimitive?.contentOrNull ?: return@mapNotNull null
val sizeBytes = obj["sizeBytes"]?.jsonPrimitive?.longOrNull ?: 0L
val dateIso = obj["dateIso"]?.jsonPrimitive?.contentOrNull
val sourceFolder = obj["sourceFolder"]?.jsonPrimitive?.contentOrNull ?: "DCIM"
val mediaTypeStr = obj["mediaType"]?.jsonPrimitive?.contentOrNull ?: "PHOTO"
/*
* Output format (each file):
* #1 IMG_0001.HEIC 5906 KB 2026-07-15 14:22:01
* #2 IMG_0001.MOV 12344 KB 2026-07-15 14:22:01
* #3 IMG_0002.JPG 3021 KB 2026-07-15 14:23:45
*/
val rawPhotos = mutableListOf<PhonePhoto>()
for (line in output.lines()) {
if (!line.trimStart().startsWith("#")) continue
val photo = parseGphoto2FileLine(line) ?: continue
rawPhotos.add(photo)
val dateTaken = if (dateIso != null) {
try { Instant.parse(dateIso + "Z") } catch (_: Exception) {
try { LocalDateTime.parse(dateIso).atZone(ZoneId.systemDefault()).toInstant() }
catch (_: Exception) { Instant.EPOCH }
}
} else Instant.EPOCH
PhonePhoto(
devicePath = devicePath,
filename = filename,
sizeBytes = sizeBytes,
dateTaken = dateTaken,
exifMake = "Apple",
exifModel = device.displayName,
mediaType = if (mediaTypeStr == "VIDEO") MediaType.VIDEO else MediaType.PHOTO,
sourceFolder = sourceFolder,
isLivePhotoMotion = obj["isLivePhotoMotion"]?.jsonPrimitive?.contentOrNull == "true",
isLivePhotoStill = obj["companionDevicePath"]?.jsonPrimitive?.contentOrNull != null,
companionDevicePath = obj["companionDevicePath"]?.jsonPrimitive?.contentOrNull,
)
} catch (e: Exception) {
log.debug("Failed to parse photo entry: ${e.message}")
null
}
}
// Tag Live Photo pairs (HEIC + MOV with same base name)
tagLivePhotoPairs(photos).sortedByDescending { it.dateTaken }
} catch (e: Exception) {
log.error("iOS photo listing failed: ${e.message}")
emptyList()
}
// Detect Live Photo pairs: HEIC + MOV with the same base name and close timestamps
return tagLivePhotoPairs(rawPhotos)
}
override suspend fun pullPhoto(device: DeviceInfo, photo: PhonePhoto, destinationDir: Path): Path {
val port = requirePort(device)
// gphoto2 uses 1-based file index; devicePath stores the index as "#N"
val fileIndex = photo.devicePath.removePrefix("#")
val destFile = destinationDir.resolve(photo.filename)
// --get-file downloads to current dir; we set working dir to destinationDir
val output = runGphoto2InDir(
destinationDir,
"--port", port,
"--get-file", fileIndex,
"--filename", photo.filename,
"--force-overwrite",
)
if (output.contains("Error") && !output.contains("Saving file as")) {
throw DeviceException("gphoto2 pull failed for ${photo.filename}: $output")
val output = runHelper("pull", photo.devicePath, destFile.absolutePathString())
if (output.contains("Error") || !destFile.toFile().exists()) {
throw DeviceException("AFC pull failed for ${photo.filename}: $output")
}
log.info("Pulled ${photo.filename}${destFile.absolutePathString()}")
return destFile
}
override suspend fun pullToFile(device: DeviceInfo, devicePath: String, destinationPath: Path): Boolean {
// Use pull-thumb (partial 512KB read) for speed — sips can extract EXIF thumbnail from partial file
val output = runHelper("pull-thumb", devicePath, destinationPath.absolutePathString())
return !output.contains("Error") && destinationPath.toFile().exists()
}
override fun cacheKey(device: DeviceInfo): String = device.gphotoPort ?: device.displayName
override suspend fun deletePhoto(device: DeviceInfo, photo: PhonePhoto) {
val port = requirePort(device)
val fileIndex = photo.devicePath.removePrefix("#")
val output = runGphoto2("--port", port, "--delete-file", fileIndex)
val output = runHelper("delete", photo.devicePath)
if (output.contains("Error")) {
throw DeviceException("Failed to delete ${photo.filename}: $output")
throw DeviceException("AFC delete failed for ${photo.filename}: $output")
}
log.info("Deleted ${photo.filename} from device")
}
/**
* gphoto2 doesn't provide a remote hash command, so we return null.
* The Copier will verify by comparing the local copy against the pulled bytes.
*/
/** AFC doesn't provide remote checksums — verified by size + local SHA-256 in Copier */
override suspend fun remoteChecksum(device: DeviceInfo, photo: PhonePhoto): String? = null
// -------------------------------------------------------------------------
// Internal helpers
// Helpers
// -------------------------------------------------------------------------
/**
* Parse a single line from `gphoto2 --list-files`.
* Example: "#1 IMG_0001.HEIC 5906 KB 2026-07-15 14:22:01"
*/
private fun parseGphoto2FileLine(line: String): PhonePhoto? {
return try {
val trimmed = line.trim()
val indexEnd = trimmed.indexOf(' ')
val index = trimmed.substring(0, indexEnd).trimStart('#')
private fun runHelper(vararg args: String): String {
val cmd = listOf(pythonBin, helperScript) + args.toList()
log.debug("Running: ${cmd.joinToString(" ")}")
// Split remainder on multiple spaces
val rest = trimmed.substring(indexEnd).trim().split(Regex("\\s{2,}"))
if (rest.size < 3) return null
val process = ProcessBuilder(cmd)
.redirectErrorStream(false)
.start()
val filename = rest[0].trim()
// rest[1] = "5906 KB" or "5906 kB"
val sizeStr = rest[1].trim().split(" ").firstOrNull() ?: "0"
val sizeKb = sizeStr.toLongOrNull() ?: 0L
// rest[2] could be "2026-07-15 14:22:01" (split might be further)
val datePart = rest.drop(2).joinToString(" ").trim()
val dateTaken = try {
LocalDateTime.parse(datePart, gphotoDateFormatter)
.atZone(ZoneId.systemDefault()).toInstant()
} catch (_: Exception) {
Instant.EPOCH
}
val stdout = process.inputStream.bufferedReader().readText()
val stderr = process.errorStream.bufferedReader().readText()
process.waitFor()
val ext = filename.substringAfterLast(".", "").lowercase()
val mediaType = when (ext) {
"mp4", "mov", "3gp", "avi" -> MediaType.VIDEO
"jpg", "jpeg", "heic", "heif", "png", "dng" -> MediaType.PHOTO
else -> MediaType.UNKNOWN
}
PhonePhoto(
devicePath = "#$index",
filename = filename,
sizeBytes = sizeKb * 1024,
dateTaken = dateTaken,
// iPhones always have camera Make/Model EXIF — we set it from device name
// since gphoto2 --list-files doesn't expose EXIF inline
exifMake = "Apple",
exifModel = null, // populated later if needed
mediaType = mediaType,
)
} catch (e: Exception) {
log.debug("Could not parse gphoto2 line: $line${e.message}")
null
}
if (stderr.isNotBlank()) log.warn("iOS helper stderr: $stderr")
return stdout.trim()
}
/**
* Detect Live Photo pairs by matching HEIC + MOV files with the same base name
* (e.g. IMG_0001.HEIC + IMG_0001.MOV). Tags both sides appropriately.
* Extract the bundled helper script from the JAR resources to a temp file.
* Returns the absolute path to the extracted script.
*/
private fun extractHelperScript(): String {
val resourcePath = "/scripts/photophetch-ios-helper.py"
val stream = IosConnector::class.java.getResourceAsStream(resourcePath)
?: throw IllegalStateException(
"Bundled iOS helper script not found in JAR. " +
"Expected resource: $resourcePath"
)
val tempFile = java.io.File.createTempFile("photophetch-ios-helper", ".py")
tempFile.deleteOnExit()
tempFile.outputStream().use { out -> stream.copyTo(out) }
tempFile.setExecutable(true)
log.info("Extracted iOS helper script to ${tempFile.absolutePath}")
return tempFile.absolutePath
}
/**
* Detect Live Photo pairs by matching HEIC/HEIF + MOV files with the same base name.
*/
private fun tagLivePhotoPairs(photos: List<PhonePhoto>): List<PhonePhoto> {
// Build a map from base name (no extension) → list of photos
val byBaseName = photos.groupBy { it.filename.substringBeforeLast(".", it.filename) }
return photos.map { photo ->
val baseName = photo.filename.substringBeforeLast(".", photo.filename)
val siblings = byBaseName[baseName] ?: return@map photo
val hasHeic = siblings.any {
it.filename.endsWith(".HEIC", ignoreCase = true) ||
it.filename.endsWith(".HEIF", ignoreCase = true)
}
val hasMov = siblings.any {
it.filename.endsWith(".MOV", ignoreCase = true)
}
val hasHeic = siblings.any { it.filename.endsWith(".HEIC", ignoreCase = true) || it.filename.endsWith(".HEIF", ignoreCase = true) }
val hasMov = siblings.any { it.filename.endsWith(".MOV", ignoreCase = true) }
if (!hasHeic || !hasMov) return@map photo
val ext = photo.filename.substringAfterLast(".", "").lowercase()
when (ext) {
"heic", "heif" -> photo.copy(isLivePhotoStill = true)
@@ -221,26 +228,4 @@ class IosConnector(
}
}
}
private fun requirePort(device: DeviceInfo): String =
device.gphotoPort ?: throw DeviceException("No gphoto2 port for device: ${device.displayName}")
private fun runGphoto2(vararg args: String): String = runGphoto2InDir(null, *args)
private fun runGphoto2InDir(workingDir: Path?, vararg args: String): String {
val cmd = buildList {
add(gphoto2Bin)
addAll(args)
}
log.debug("Running: ${cmd.joinToString(" ")}")
val builder = ProcessBuilder(cmd).redirectErrorStream(true)
if (workingDir != null) builder.directory(workingDir.toFile())
val process = builder.start()
val output = process.inputStream.bufferedReader().readText()
process.waitFor()
return output
}
}

View File

@@ -17,10 +17,12 @@ data class AppConfig(
val defaultStripLivePhotoMotion: Boolean = true,
/** Default: do not delete from phone (safer default) */
val defaultDeleteAfterVerify: Boolean = false,
/** Default: preserve folder structure on import */
val defaultPreserveFolderStructure: Boolean = true,
/** Whether the user has been warned about HEIC phone settings */
val heicWarningDismissed: Boolean = false,
/** Path to adb binary, or null to use PATH */
val adbPath: String? = null,
/** Path to gphoto2 binary, or null to use PATH */
val gphoto2Path: String? = null,
/** Path to python3 binary, or null to use PATH (needed for iOS via pymobiledevice3) */
val python3Path: String? = null,
)

View File

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

View File

@@ -20,10 +20,17 @@ data class PhonePhoto(
val exifModel: String?,
/** Media type */
val mediaType: MediaType,
/** Source folder name on the device (e.g. "Camera", "OpenCamera", "PeakLens") */
val sourceFolder: String = "Camera",
/** True if this is the still component of an Apple Live Photo (paired with a .MOV) */
val isLivePhotoStill: Boolean = false,
/** True if this is the motion component of an Apple Live Photo (.MOV companion) */
val isLivePhotoMotion: Boolean = false,
/**
* Device path of the paired MOV companion (set on the HEIC when isLivePhotoStill=true).
* Used by the Copier to pull both files atomically and by deletePhoto to delete both.
*/
val companionDevicePath: String? = null,
/** True if the format is HEIC — user may want to convert to JPEG */
val isHeic: Boolean = filename.endsWith(".HEIC", ignoreCase = true) ||
filename.endsWith(".HEIF", ignoreCase = true),

View File

@@ -63,11 +63,37 @@ class Copier(
photosToImport.forEachIndexed { idx, photo ->
onProgress(idx, photosToImport.size, photo.filename)
val result = copyOne(photo, stagingDir, job.convertHeicToJpeg)
val destDir = if (job.preserveFolderStructure) {
val subFolder = photo.sourceFolder.ifBlank {
photo.devicePath.substringAfter("/DCIM/", "")
.substringBeforeLast("/", "").ifBlank { "Camera" }
}
stagingDir.resolve(subFolder).also { it.toFile().mkdirs() }
} else {
stagingDir
}
val result = copyOne(photo, destDir, job.convertHeicToJpeg)
results.add(result)
if (result.error != null) {
log.error("Failed to copy ${photo.filename}: ${result.error}")
}
// Pull Live Photo companion MOV if requested
if (job.importLivePhotoCompanions && photo.isLivePhotoStill && photo.companionDevicePath != null) {
val companionFilename = photo.companionDevicePath.substringAfterLast("/")
val companionPhoto = photo.copy(
devicePath = photo.companionDevicePath,
filename = companionFilename,
isLivePhotoStill = false,
isLivePhotoMotion = true,
companionDevicePath = null,
)
val companionResult = copyOne(companionPhoto, destDir, convertHeic = false)
results.add(companionResult)
if (companionResult.error != null) {
log.error("Failed to copy companion ${companionFilename}: ${companionResult.error}")
}
}
}
onProgress(photosToImport.size, photosToImport.size, "")
@@ -101,29 +127,35 @@ class Copier(
pulledFile to false
}
// Step 3: Hash the destination file
// Step 3: Hash the destination file (always — gives us a local checksum on record)
val destHash = sha256(finalFile.inputStream())
val destSize = finalFile.toFile().length()
// Step 4: Get remote hash for cross-check (Android only; iOS returns null)
val remoteHash = connector.remoteChecksum(device, photo)
// Verification: if we have a remote hash, compare; otherwise mark verified
// (the copy itself is the verification for iOS since we have no remote hash)
val verified = when {
val verified: Boolean
when {
wasConverted -> {
// HEIC was converted — we can't compare against original hash.
// We accept the file if it exists and is non-zero.
finalFile.exists() && finalFile.toFile().length() > 0
// HEIC converted — can't compare hashes, check file is non-empty
verified = destSize > 0
}
remoteHash != null -> {
remoteHash.equals(destHash, ignoreCase = true)
// Full hash comparison
verified = remoteHash.equals(destHash, ignoreCase = true)
}
else -> {
// No remote hash — verify that file is non-empty and readable
finalFile.exists() && finalFile.toFile().length() > 0
// No remote hash — verify size matches what ADB reported
// (sizeBytes from ls -la, so we know what the device said)
val sizeMatch = photo.sizeBytes == 0L || // unknown size is OK
destSize == photo.sizeBytes
verified = destSize > 0 && sizeMatch
}
}
val exifMake = readExifMake(finalFile)
CopyResult(
photo = photo,
destinationPath = finalFile,
@@ -131,7 +163,13 @@ class Copier(
destSha256 = destHash,
verified = verified,
convertedFromHeic = wasConverted,
error = if (!verified) "Hash mismatch — file may be corrupt" else null,
exifVerified = exifMake?.let { it.isNotBlank() },
exifMake = exifMake,
error = when {
!verified && remoteHash != null -> "Hash mismatch — file may be corrupt"
!verified -> "Copy failed — file missing or size mismatch"
else -> null
},
)
} catch (e: Exception) {
pulledFile?.deleteIfExists()
@@ -201,4 +239,32 @@ class Copier(
}
return digest.digest().joinToString("") { "%02x".format(it) }
}
/**
* Read the EXIF Make tag from a local file using exiftool.
* Returns the Make value (e.g. "Apple"), empty string if Make tag absent,
* or null if exiftool is not installed or fails.
*/
private fun readExifMake(file: Path): String? {
return try {
val process = ProcessBuilder("exiftool", "-Make", "-s3", file.absolutePathString())
.redirectErrorStream(false) // keep stderr separate
.start()
val stdout = process.inputStream.bufferedReader().readText()
process.errorStream.readBytes() // drain stderr
process.waitFor()
// Strip warning lines (exiftool prints "Warning: ..." to stdout with -s3)
val make = stdout.lines()
.filter { !it.startsWith("Warning:") && !it.startsWith("Error:") && it.isNotBlank() }
.firstOrNull()
?.trim()
?: "" // empty string = tag absent, exiftool ran fine
make
} catch (e: Exception) {
log.debug("exiftool not available: ${e.message}")
null
}
}
}

View File

@@ -5,7 +5,6 @@ import com.bolenpad.photophetch.device.DeviceConnector
import com.bolenpad.photophetch.device.DeviceException
import com.bolenpad.photophetch.device.IosConnector
import com.bolenpad.photophetch.model.AppConfig
import com.bolenpad.photophetch.model.CopyResult
import com.bolenpad.photophetch.model.DeviceInfo
import com.bolenpad.photophetch.model.DeviceType
import com.bolenpad.photophetch.model.ImportJob
@@ -31,15 +30,9 @@ import java.time.LocalDate
/** Top-level navigation destinations */
enum class Screen {
CONNECT,
BROWSE,
IMPORT_CONFIG,
IMPORTING,
VERIFY,
SETTINGS,
CONNECT, BROWSE, IMPORT_CONFIG, IMPORTING, VERIFY, SETTINGS,
}
/** Sealed state for the device scan */
sealed class DeviceScanState {
object Idle : DeviceScanState()
object Scanning : DeviceScanState()
@@ -47,37 +40,40 @@ sealed class DeviceScanState {
data class Error(val message: String) : DeviceScanState()
}
/** Sealed state for the photo listing */
sealed class PhotoListState {
object Idle : PhotoListState()
object Loading : PhotoListState()
data class Loaded(
/** All photos from the device, unfiltered */
val all: List<PhonePhoto>,
val filtered: List<PhonePhoto>,
val shootingInHeic: Boolean,
/** Folders derived from all photos, sorted: camera-roll folders first then alphabetical */
val folders: List<FolderInfo>,
) : PhotoListState()
data class Error(val message: String) : PhotoListState()
}
/** Progress during active import */
/** Summary of a DCIM subfolder */
data class FolderInfo(
val name: String,
val photoCount: Int,
val earliestDate: LocalDate,
val latestDate: LocalDate,
/** True if this folder passes the camera-roll heuristic (not screenshots etc.) */
val isCameraRoll: Boolean,
)
data class ImportProgress(
val completed: Int,
val total: Int,
val currentFile: String,
)
/**
* Application state container — the single source of truth for all UI state.
* All mutations happen via public methods; UI observes via StateFlows.
*/
class AppState(
private val configStore: ConfigStore,
) {
class AppState(private val configStore: ConfigStore) {
private val log = LoggerFactory.getLogger(AppState::class.java)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
// -------------------------------------------------------------------------
// Persisted config
// Config
// -------------------------------------------------------------------------
private val _config = MutableStateFlow(configStore.load())
val config: StateFlow<AppConfig> = _config.asStateFlow()
@@ -87,7 +83,6 @@ class AppState(
// -------------------------------------------------------------------------
private val _currentScreen = MutableStateFlow(Screen.CONNECT)
val currentScreen: StateFlow<Screen> = _currentScreen.asStateFlow()
fun navigateTo(screen: Screen) { _currentScreen.value = screen }
// -------------------------------------------------------------------------
@@ -99,20 +94,20 @@ class AppState(
private val _selectedDevice = MutableStateFlow<DeviceInfo?>(null)
val selectedDevice: StateFlow<DeviceInfo?> = _selectedDevice.asStateFlow()
/** Incremented after each thumbnail batch completes — DenseThumb observes this to retry */
private val _thumbnailVersion = MutableStateFlow(0)
val thumbnailVersion: StateFlow<Int> = _thumbnailVersion.asStateFlow()
fun scanForDevices() {
scope.launch {
_deviceScan.value = DeviceScanState.Scanning
try {
val devices = withContext(Dispatchers.IO) {
val android = AndroidConnector(_config.value.adbPath ?: "adb")
val ios = IosConnector(_config.value.gphoto2Path ?: "gphoto2")
val ios = IosConnector(_config.value.python3Path.takeIf { !it.isNullOrBlank() })
android.detectDevices() + ios.detectDevices()
}
_deviceScan.value = if (devices.isEmpty()) {
DeviceScanState.Found(emptyList())
} else {
DeviceScanState.Found(devices)
}
_deviceScan.value = DeviceScanState.Found(devices)
} catch (e: Exception) {
log.error("Device scan failed", e)
_deviceScan.value = DeviceScanState.Error(e.message ?: "Unknown error")
@@ -127,30 +122,52 @@ class AppState(
}
// -------------------------------------------------------------------------
// Photo listing
// Photo listing + folder state
// -------------------------------------------------------------------------
private val _photoList = MutableStateFlow<PhotoListState>(PhotoListState.Idle)
val photoList: StateFlow<PhotoListState> = _photoList.asStateFlow()
/** Currently active folder name — null means "all camera-roll folders" */
private val _selectedFolder = MutableStateFlow<String?>(null)
val selectedFolder: StateFlow<String?> = _selectedFolder.asStateFlow()
/** Photos currently visible in the grid (filtered by folder) */
private val _visiblePhotos = MutableStateFlow<List<PhonePhoto>>(emptyList())
val visiblePhotos: StateFlow<List<PhonePhoto>> = _visiblePhotos.asStateFlow()
/** Selected photo set */
private val _selectedPhotos = MutableStateFlow<Set<PhonePhoto>>(emptySet())
val selectedPhotos: StateFlow<Set<PhonePhoto>> = _selectedPhotos.asStateFlow()
private val _dateFilter = MutableStateFlow<Pair<LocalDate?, LocalDate?>>(null to null)
val dateFilter: StateFlow<Pair<LocalDate?, LocalDate?>> = _dateFilter.asStateFlow()
/** Whether HEIC warning should be shown */
val shootingInHeic: Boolean
get() = (_photoList.value as? PhotoListState.Loaded)
?.all?.let { ExifFilter.isShootingInHeic(it) } ?: false
fun loadPhotos(device: DeviceInfo) {
scope.launch {
_photoList.value = PhotoListState.Loading
_selectedFolder.value = null
_selectedPhotos.value = emptySet()
try {
val connector = connectorFor(device)
val raw = withContext(Dispatchers.IO) { connector.listPhotos(device) }
val filtered = ExifFilter.filter(raw, _config.value.defaultStripLivePhotoMotion)
val shootingInHeic = ExifFilter.isShootingInHeic(filtered)
_photoList.value = PhotoListState.Loaded(
all = raw,
filtered = filtered,
shootingInHeic = shootingInHeic,
)
// Build folder summaries
val folders = buildFolderList(raw)
_photoList.value = PhotoListState.Loaded(all = raw, folders = folders)
// Default: select the first camera-roll folder
val defaultFolder = folders.firstOrNull { it.isCameraRoll }?.name
selectFolder(defaultFolder)
// For iOS: batch pre-fetch thumbnails in background (amortises Python startup cost)
if (device.type == DeviceType.IOS && connector is com.bolenpad.photophetch.device.IosConnector) {
launch(Dispatchers.IO) {
prefetchIosThumbnails(device, raw, connector)
}
}
} catch (e: Exception) {
log.error("Photo listing failed", e)
_photoList.value = PhotoListState.Error(e.message ?: "Unknown error")
@@ -158,40 +175,161 @@ class AppState(
}
}
fun setDateFilter(from: LocalDate?, to: LocalDate?) {
_dateFilter.value = from to to
// Re-apply filter over already-loaded photos
val current = _photoList.value as? PhotoListState.Loaded ?: return
val filtered = ExifFilter.filter(current.all, _config.value.defaultStripLivePhotoMotion)
.let { applyDateFilter(it, from, to) }
_photoList.value = current.copy(filtered = filtered)
private suspend fun prefetchIosThumbnails(
device: DeviceInfo,
photos: List<PhonePhoto>,
connector: com.bolenpad.photophetch.device.IosConnector,
) {
val cacheKey = connector.cacheKey(device)
val cacheRoot = java.nio.file.Paths.get(System.getProperty("user.home"), ".photophetch", "thumbcache")
val cacheDir = cacheRoot.resolve(cacheKey.replace("/", "_").replace(":", "_"))
if (!cacheDir.toFile().exists()) cacheDir.toFile().mkdirs()
val tmpDir = java.nio.file.Paths.get(System.getProperty("java.io.tmpdir"), "photophetch-thumbs")
if (!tmpDir.toFile().exists()) tmpDir.toFile().mkdirs()
// Only fetch photos not already cached, sorted newest first
val needed = photos
.sortedByDescending { it.dateTaken }
.filter { photo ->
val ext = photo.filename.substringAfterLast(".", "").lowercase()
if (ext in setOf("mp4", "mov", "3gp", "avi", "mkv", "m4v", "webm")) return@filter false
val hash = (cacheKey + photo.devicePath).hashCode().toString(16)
val cachePath = cacheDir.resolve("$hash.jpg")
!cachePath.toFile().exists() || cachePath.toFile().length() == 0L
}
if (needed.isEmpty()) {
log.info("iOS thumbnails: all ${photos.size} already cached")
return
}
log.info("iOS thumbnails: pre-fetching ${needed.size} / ${photos.size} (${photos.size - needed.size} already cached)")
var loaded = 0
val batchSize = 30
// Build pairs JSON and run batch-thumbs command
needed.chunked(batchSize).forEachIndexed { batchIdx, batch ->
val pairs = batch.joinToString(",") { photo ->
val ext = photo.filename.substringAfterLast(".", "").lowercase()
val hash = (cacheKey + photo.devicePath).hashCode().toString(16)
val tmpPath = tmpDir.resolve("$hash.$ext")
val src = photo.devicePath.replace("\"", "\\\"")
val dst = tmpPath.toString().replace("\"", "\\\"")
"[\"$src\",\"$dst\"]"
}
val pairsJson = "[$pairs]"
try {
val process = ProcessBuilder(connector.pythonBin, connector.helperScript, "batch-thumbs", pairsJson)
.redirectErrorStream(false).start()
process.inputStream.readBytes()
process.errorStream.readBytes()
process.waitFor()
// Run sips on each successfully pulled file
var batchLoaded = 0
batch.forEach { photo ->
val ext = photo.filename.substringAfterLast(".", "").lowercase()
val hash = (cacheKey + photo.devicePath).hashCode().toString(16)
val tmpFile = tmpDir.resolve("$hash.$ext")
val cachePath = cacheDir.resolve("$hash.jpg")
if (tmpFile.toFile().exists() && tmpFile.toFile().length() > 0 && !cachePath.toFile().exists()) {
val success = if (ext in setOf("heic", "heif")) {
// Convert partial HEIC to JPEG via sips
// sips can decode HEIC even from a partial file if the header is intact
val sips = ProcessBuilder(
"sips", "--resampleWidth", "240",
"--setProperty", "format", "jpeg",
tmpFile.toString(), "--out", cachePath.toString()
).redirectErrorStream(true).start()
sips.inputStream.readBytes()
sips.waitFor()
cachePath.toFile().exists() && cachePath.toFile().length() > 0
} else {
// JPEG/PNG: simple resample
val sips = ProcessBuilder(
"sips", "--resampleWidth", "240",
"--setProperty", "format", "jpeg",
tmpFile.toString(), "--out", cachePath.toString()
).redirectErrorStream(true).start()
sips.inputStream.readBytes()
sips.waitFor()
cachePath.toFile().exists() && cachePath.toFile().length() > 0
}
tmpFile.toFile().delete()
if (success) batchLoaded++
}
}
loaded += batchLoaded
if (batchLoaded > 0) {
// Notify UI that new thumbnails are available
_thumbnailVersion.value++
}
log.info("iOS thumbnails: $loaded / ${needed.size} loaded (batch ${batchIdx + 1}/${(needed.size + batchSize - 1) / batchSize})")
} catch (e: Exception) {
log.warn("iOS thumbnail batch ${batchIdx + 1} failed: ${e.message}")
}
}
log.info("iOS thumbnails: done — $loaded / ${needed.size} loaded successfully")
}
fun togglePhotoSelection(photo: PhonePhoto) {
_selectedPhotos.update { current ->
if (photo in current) current - photo else current + photo
fun selectFolder(folderName: String?) {
_selectedFolder.value = folderName
_selectedPhotos.value = emptySet()
refreshVisiblePhotos()
}
private fun refreshVisiblePhotos() {
val loaded = _photoList.value as? PhotoListState.Loaded ?: return
val folder = _selectedFolder.value
_visiblePhotos.value = if (folder == null) {
// All camera-roll folders merged
ExifFilter.filter(loaded.all, _config.value.defaultStripLivePhotoMotion)
.sortedByDescending { it.dateTaken }
} else {
loaded.all.filter { it.sourceFolder == folder }
.sortedByDescending { it.dateTaken }
}
}
fun selectAll() {
val photos = (_photoList.value as? PhotoListState.Loaded)?.filtered ?: return
_selectedPhotos.value = photos.toSet()
private fun buildFolderList(photos: List<PhonePhoto>): List<FolderInfo> {
val zoneId = java.time.ZoneId.systemDefault()
return photos
.groupBy { it.sourceFolder }
.filterKeys { !ExifFilter.isHiddenFolder(it) } // hide Live Photo MOV folder
.map { (name, folderPhotos) ->
val dates = folderPhotos.map {
it.dateTaken.atZone(zoneId).toLocalDate()
}
FolderInfo(
name = name,
photoCount = folderPhotos.size,
earliestDate = dates.min(),
latestDate = dates.max(),
isCameraRoll = ExifFilter.isCameraRollFolder(name),
)
}
// Camera-roll folders first, then alphabetical
.sortedWith(compareByDescending<FolderInfo> { it.isCameraRoll }.thenBy { it.name })
}
fun togglePhotoSelection(photo: PhonePhoto) {
_selectedPhotos.update { if (photo in it) it - photo else it + photo }
}
fun selectAllVisible() {
_selectedPhotos.value = _visiblePhotos.value.toSet()
}
fun selectNone() {
_selectedPhotos.value = emptySet()
}
private fun applyDateFilter(
photos: List<PhonePhoto>,
from: LocalDate?,
to: LocalDate?,
): List<PhonePhoto> {
if (from == null && to == null) return photos
return photos.filter { photo ->
val date = photo.dateTaken.atZone(java.time.ZoneId.systemDefault()).toLocalDate()
(from == null || !date.isBefore(from)) && (to == null || !date.isAfter(to))
}
fun selectFolder_All() {
val loaded = _photoList.value as? PhotoListState.Loaded ?: return
val folder = _selectedFolder.value ?: return
val folderPhotos = loaded.all.filter { it.sourceFolder == folder }.toSet()
_selectedPhotos.update { it + folderPhotos }
}
// -------------------------------------------------------------------------
@@ -202,6 +340,7 @@ class AppState(
convertHeicToJpeg = true,
stripLivePhotoMotion = true,
deleteAfterVerify = false,
preserveFolderStructure = true,
folderName = "",
)
)
@@ -211,6 +350,8 @@ class AppState(
val convertHeicToJpeg: Boolean,
val stripLivePhotoMotion: Boolean,
val deleteAfterVerify: Boolean,
val preserveFolderStructure: Boolean,
val importLivePhotoCompanions: Boolean = false,
val folderName: String,
)
@@ -220,11 +361,22 @@ class AppState(
fun prepareImport() {
val device = _selectedDevice.value ?: return
val cfg = _config.value
val today = LocalDate.now()
val folderName = _config.value.folderNameTemplate
val baseName = cfg.folderNameTemplate
.replace("{date}", today.toString())
.replace("{device}", StagingPath.deviceLabel(device))
_importConfig.update { it.copy(folderName = folderName) }
val folderName = if (cfg.stagingRoot.isNotBlank()) {
StagingPath.uniqueFolderName(Paths.get(cfg.stagingRoot), baseName)
} else baseName
// Multi-folder selection → preserve structure; otherwise use saved config default
val multipleSourceFolders = _selectedPhotos.value.map { it.sourceFolder }.distinct().size > 1
_importConfig.update { it.copy(
folderName = folderName,
preserveFolderStructure = if (multipleSourceFolders) true else cfg.defaultPreserveFolderStructure,
) }
navigateTo(Screen.IMPORT_CONFIG)
}
@@ -251,6 +403,8 @@ class AppState(
convertHeicToJpeg = importCfg.convertHeicToJpeg,
stripLivePhotoMotion = importCfg.stripLivePhotoMotion,
deleteAfterVerify = importCfg.deleteAfterVerify,
preserveFolderStructure = importCfg.preserveFolderStructure,
importLivePhotoCompanions = importCfg.importLivePhotoCompanions,
)
navigateTo(Screen.IMPORTING)
@@ -272,19 +426,38 @@ class AppState(
// Post-import deletion
// -------------------------------------------------------------------------
fun deleteVerifiedFromDevice() {
deleteSpecificFromDevice(
_importResult.value?.results
?.filter { it.verified && it.exifVerified != false }
?.map { it.photo.devicePath }
?.toSet() ?: emptySet()
)
}
fun deleteSpecificFromDevice(devicePaths: Set<String>) {
val device = _selectedDevice.value ?: return
val result = _importResult.value ?: return
val toDelete = result.results.filter { it.verified }.map { it.photo }
scope.launch(Dispatchers.IO) {
val connector = connectorFor(device)
toDelete.forEach { photo ->
try {
connector.deletePhoto(device, photo)
} catch (e: DeviceException) {
log.error("Delete failed for ${photo.filename}: ${e.message}")
result.results
.filter { it.photo.devicePath in devicePaths }
.forEach { r ->
try {
connector.deletePhoto(device, r.photo)
if (r.photo.isLivePhotoStill && r.photo.companionDevicePath != null) {
val companion = r.photo.copy(
devicePath = r.photo.companionDevicePath,
filename = r.photo.companionDevicePath.substringAfterLast("/"),
)
try { connector.deletePhoto(device, companion) }
catch (e: DeviceException) {
log.warn("Companion delete failed for ${r.photo.filename}: ${e.message}")
}
}
} catch (e: DeviceException) {
log.error("Delete failed for ${r.photo.filename}: ${e.message}")
}
}
}
}
}
@@ -296,20 +469,23 @@ class AppState(
saveConfig()
}
fun updateDefaultPreserveFolderStructure(value: Boolean) {
_config.update { it.copy(defaultPreserveFolderStructure = value) }
saveConfig()
}
fun dismissHeicWarning() {
_config.update { it.copy(heicWarningDismissed = true) }
saveConfig()
}
fun saveConfig() {
configStore.save(_config.value)
}
fun saveConfig() { configStore.save(_config.value) }
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private fun connectorFor(device: DeviceInfo): DeviceConnector = when (device.type) {
internal fun connectorFor(device: DeviceInfo): DeviceConnector = when (device.type) {
DeviceType.ANDROID -> AndroidConnector(_config.value.adbPath ?: "adb")
DeviceType.IOS -> IosConnector(_config.value.gphoto2Path ?: "gphoto2")
DeviceType.IOS -> IosConnector(_config.value.python3Path.takeIf { !it.isNullOrBlank() })
}
}

View File

@@ -1,51 +1,72 @@
package com.bolenpad.photophetch.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.DateRange
import androidx.compose.material.icons.filled.RadioButtonUnchecked
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.toComposeImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.bolenpad.photophetch.model.DeviceInfo
import com.bolenpad.photophetch.model.MediaType
import androidx.compose.ui.window.Dialog
import com.bolenpad.photophetch.model.PhonePhoto
import java.time.LocalDate
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jetbrains.skia.Image as SkiaImage
import java.nio.file.Path
import java.nio.file.Paths
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@Composable
fun BrowseScreen(state: AppState) {
val photoList by state.photoList.collectAsState()
val selected by state.selectedPhotos.collectAsState()
val selectedPhotos by state.selectedPhotos.collectAsState()
val device by state.selectedDevice.collectAsState()
val config by state.config.collectAsState()
val dateFilter by state.dateFilter.collectAsState()
var previewPhoto by remember { mutableStateOf<PhonePhoto?>(null) }
// Preview dialog
previewPhoto?.let { photo ->
PhotoPreviewDialog(
photo = photo,
cacheKey = device?.let { state.connectorFor(it).cacheKey(it) } ?: "unknown",
pullFn = { devicePath, localPath ->
device?.let { d -> state.connectorFor(d).pullToFile(d, devicePath, localPath) } ?: false
},
onDismiss = { previewPhoto = null },
)
}
Column(modifier = Modifier.fillMaxSize()) {
// Top bar
var columnCount by remember { mutableStateOf(4) }
BrowseTopBar(
device = device,
selected = selected,
deviceName = device?.displayName ?: "Browse Photos",
selectedCount = selectedPhotos.size,
columnCount = columnCount,
onBack = { state.navigateTo(Screen.CONNECT) },
onSelectAll = { state.selectAll() },
onSelectNone = { state.selectNone() },
onImport = { state.prepareImport() },
onColumnChange = { columnCount = (columnCount + it).coerceIn(2, 8) },
)
if (state.shootingInHeic && !config.heicWarningDismissed) {
HeicWarningBanner(onDismiss = { state.dismissHeicWarning() })
}
when (val pl = photoList) {
is PhotoListState.Idle, is PhotoListState.Loading -> {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
@@ -57,52 +78,68 @@ fun BrowseScreen(state: AppState) {
}
}
is PhotoListState.Loaded -> {
// HEIC warning banner
if (pl.shootingInHeic && !config.heicWarningDismissed) {
HeicWarningBanner(
convertByDefault = config.defaultConvertHeicToJpeg,
onDismiss = { state.dismissHeicWarning() },
)
val folderSections = buildFolderSections(pl)
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
// Folder scroll index for jump list
val folderScrollIndex = remember(folderSections, columnCount) {
var idx = 0
folderSections.associate { section ->
val startIdx = idx
idx += 1 // folder header
idx += ((section.allPhotos.size + columnCount - 1) / columnCount)
section.folder.name to startIdx
}
}
// Date filter row
DateFilterRow(
from = dateFilter.first,
to = dateFilter.second,
onFilterChange = { from, to -> state.setDateFilter(from, to) },
)
// Photo count summary
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
"${pl.filtered.size} photos • ${selected.size} selected",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
val activeFolder by remember(folderScrollIndex) {
derivedStateOf {
val firstVisible = listState.firstVisibleItemIndex
folderScrollIndex.entries
.filter { it.value <= firstVisible }
.maxByOrNull { it.value }?.key
?: folderSections.firstOrNull()?.folder?.name
}
}
if (pl.filtered.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("No photos match the current filter", color = MaterialTheme.colorScheme.onSurfaceVariant)
}
} else {
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 110.dp),
modifier = Modifier.fillMaxSize().padding(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
items(pl.filtered, key = { it.devicePath }) { photo ->
PhotoCell(
photo = photo,
isSelected = photo in selected,
onToggle = { state.togglePhotoSelection(photo) },
)
}
}
Row(modifier = Modifier.fillMaxSize()) {
FolderJumpList(
folders = pl.folders,
activeFolder = activeFolder,
selectedPhotos = selectedPhotos,
onJump = { name ->
val idx = folderScrollIndex[name] ?: return@FolderJumpList
scope.launch { listState.animateScrollToItem(idx) }
},
modifier = Modifier.width(190.dp).fillMaxHeight(),
)
VerticalDivider()
val thumbnailVersion by state.thumbnailVersion.collectAsState()
DensePhotoGrid(
folderSections = folderSections,
selectedPhotos = selectedPhotos,
cacheKey = device?.let { state.connectorFor(it).cacheKey(it) } ?: "unknown",
pullFn = { devicePath, localPath ->
device?.let { d ->
state.connectorFor(d).pullToFile(d, devicePath, localPath)
} ?: false
},
thumbnailVersion = thumbnailVersion,
columnCount = columnCount,
listState = listState,
onTogglePhoto = { state.togglePhotoSelection(it) },
onSelectAllFolder = { folder ->
folder.forEach { p -> if (p !in selectedPhotos) state.togglePhotoSelection(p) }
},
onDeselectAllFolder = { folder ->
folder.forEach { p -> if (p in selectedPhotos) state.togglePhotoSelection(p) }
},
onPreviewPhoto = { previewPhoto = it },
modifier = Modifier.weight(1f),
)
}
}
is PhotoListState.Error -> {
@@ -119,160 +156,284 @@ fun BrowseScreen(state: AppState) {
}
}
// ─── Data helpers ─────────────────────────────────────────────────────────────
private fun buildFolderSections(pl: PhotoListState.Loaded): List<FolderSection> {
val zoneId = ZoneId.systemDefault()
return pl.folders // already filtered by buildFolderList (no hidden folders)
.map { folder ->
val photos = pl.all
.filter { it.sourceFolder == folder.name }
.sortedByDescending { it.dateTaken }
val byDay = photos
.groupBy { it.dateTaken.atZone(zoneId).toLocalDate() }
.entries
.sortedByDescending { it.key }
.map { it.key to it.value.sortedByDescending { p -> p.dateTaken } }
FolderSection(folder, byDay, photos)
}
}
// ─── Folder jump list ─────────────────────────────────────────────────────────
@Composable
private fun FolderJumpList(
folders: List<FolderInfo>,
activeFolder: String?,
selectedPhotos: Set<PhonePhoto>,
onJump: (String) -> Unit,
modifier: Modifier = Modifier,
) {
Surface(tonalElevation = 1.dp, modifier = modifier) {
androidx.compose.foundation.lazy.LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 8.dp),
) {
val (cameraRoll, other) = folders.partition { it.isCameraRoll }
if (cameraRoll.isNotEmpty()) {
item {
Text("CAMERA", style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 12.dp, top = 8.dp, bottom = 2.dp))
}
items(cameraRoll.size) { i ->
JumpRow(cameraRoll[i], cameraRoll[i].name == activeFolder,
selectedPhotos.count { it.sourceFolder == cameraRoll[i].name }) { onJump(cameraRoll[i].name) }
}
}
if (other.isNotEmpty()) {
item {
Text("OTHER", style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 12.dp, top = 12.dp, bottom = 2.dp))
}
items(other.size) { i ->
JumpRow(other[i], other[i].name == activeFolder,
selectedPhotos.count { it.sourceFolder == other[i].name }) { onJump(other[i].name) }
}
}
}
}
}
@Composable
private fun JumpRow(folder: FolderInfo, isActive: Boolean, selectedCount: Int, onClick: () -> Unit) {
Row(
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick)
.background(if (isActive) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surface)
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(modifier = Modifier.weight(1f)) {
Text(folder.name, style = MaterialTheme.typography.bodySmall,
fontWeight = if (isActive) FontWeight.SemiBold else FontWeight.Normal,
maxLines = 1, overflow = TextOverflow.Ellipsis)
Text("${folder.photoCount}", style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant)
}
if (selectedCount > 0) {
Badge(containerColor = MaterialTheme.colorScheme.primary) {
Text("$selectedCount", style = MaterialTheme.typography.labelSmall)
}
}
}
}
// ─── Folder divider header ────────────────────────────────────────────────────
@Composable
internal fun FolderDividerHeader(
name: String,
total: Int,
selected: Int,
allSelected: Boolean,
onSelectAll: () -> Unit,
onDeselectAll: () -> Unit,
) {
Column {
HorizontalDivider(thickness = 2.dp, modifier = Modifier.padding(top = 8.dp))
Row(
modifier = Modifier.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column {
Text(name, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold)
Text(
if (selected > 0) "$selected / $total selected" else "$total photos",
style = MaterialTheme.typography.labelSmall,
color = if (selected > 0) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
TextButton(onClick = onSelectAll, enabled = !allSelected,
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp)) {
Text("Select all", style = MaterialTheme.typography.labelMedium)
}
if (selected > 0) {
TextButton(onClick = onDeselectAll,
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp)) {
Text("Deselect all", style = MaterialTheme.typography.labelMedium)
}
}
}
}
}
}
// ─── Top bar ──────────────────────────────────────────────────────────────────
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun BrowseTopBar(
device: DeviceInfo?,
selected: Set<PhonePhoto>,
deviceName: String,
selectedCount: Int,
columnCount: Int,
onBack: () -> Unit,
onSelectAll: () -> Unit,
onSelectNone: () -> Unit,
onImport: () -> Unit,
onColumnChange: (Int) -> Unit,
) {
TopAppBar(
title = { Text(device?.displayName ?: "Browse Photos") },
title = { Text(deviceName) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
actions = {
TextButton(onClick = onSelectAll) { Text("All") }
TextButton(onClick = onSelectNone) { Text("None") }
IconButton(onClick = { onColumnChange(-1) }, enabled = columnCount > 2) {
Text("", style = MaterialTheme.typography.titleMedium)
}
Text("$columnCount", style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(horizontal = 2.dp))
IconButton(onClick = { onColumnChange(1) }, enabled = columnCount < 8) {
Text("+", style = MaterialTheme.typography.titleMedium)
}
if (selectedCount > 0) {
TextButton(onClick = onSelectNone) { Text("Clear") }
}
Button(
onClick = onImport,
enabled = selected.isNotEmpty(),
enabled = selectedCount > 0,
modifier = Modifier.padding(end = 8.dp),
) {
Text("Import ${if (selected.isNotEmpty()) "(${selected.size})" else ""}")
Text(if (selectedCount > 0) "Import ($selectedCount)" else "Import")
}
},
)
}
@Composable
private fun PhotoCell(
photo: PhonePhoto,
isSelected: Boolean,
onToggle: () -> Unit,
) {
val date = photo.dateTaken.atZone(ZoneId.systemDefault()).toLocalDate()
val dateStr = date.format(DateTimeFormatter.ofPattern("MMM d"))
Box(
modifier = Modifier
.aspectRatio(1f)
.clickable(onClick = onToggle)
.background(
if (isSelected) MaterialTheme.colorScheme.primaryContainer
else MaterialTheme.colorScheme.surfaceVariant
)
.then(
if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary)
else Modifier
)
.padding(4.dp),
) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.SpaceBetween,
) {
// Type badge
Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) {
if (photo.isHeic) {
Badge { Text("HEIC", style = MaterialTheme.typography.labelSmall) }
}
if (photo.mediaType == MediaType.VIDEO) {
Badge(containerColor = MaterialTheme.colorScheme.tertiary) {
Text("VID", style = MaterialTheme.typography.labelSmall)
}
}
if (photo.isLivePhotoStill) {
Badge(containerColor = MaterialTheme.colorScheme.secondary) {
Text("LIVE", style = MaterialTheme.typography.labelSmall)
}
}
}
// Filename and date at bottom
Column {
Text(
photo.filename,
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
)
Text(dateStr, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
// Selection checkmark
Icon(
if (isSelected) Icons.Default.CheckCircle else Icons.Default.RadioButtonUnchecked,
contentDescription = if (isSelected) "Selected" else "Not selected",
tint = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline,
modifier = Modifier.align(Alignment.TopEnd).size(20.dp),
)
}
}
@Composable
private fun DateFilterRow(
from: LocalDate?,
to: LocalDate?,
onFilterChange: (from: LocalDate?, to: LocalDate?) -> Unit,
) {
// Simple quick-filter buttons: Last 7 days, Last 30 days, All
val today = LocalDate.now()
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(Icons.Default.DateRange, contentDescription = null, modifier = Modifier.size(16.dp))
Text("Filter:", style = MaterialTheme.typography.labelMedium)
FilterChip(
selected = from == null && to == null,
onClick = { onFilterChange(null, null) },
label = { Text("All") },
)
FilterChip(
selected = from == today.minusDays(7) && to == null,
onClick = { onFilterChange(today.minusDays(7), null) },
label = { Text("Last 7 days") },
)
FilterChip(
selected = from == today.minusDays(30) && to == null,
onClick = { onFilterChange(today.minusDays(30), null) },
label = { Text("Last 30 days") },
)
}
}
@Composable
private fun HeicWarningBanner(convertByDefault: Boolean, onDismiss: () -> Unit) {
private fun HeicWarningBanner(onDismiss: () -> Unit) {
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.tertiaryContainer),
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
) {
Row(
modifier = Modifier.padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Row(modifier = Modifier.padding(12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.Warning, contentDescription = null, modifier = Modifier.size(20.dp))
Column(modifier = Modifier.weight(1f)) {
Text("Phone is shooting in HEIC", fontWeight = FontWeight.SemiBold, style = MaterialTheme.typography.bodySmall)
Text(
if (convertByDefault)
"Files will be converted to JPEG during import. To avoid this, go to Settings → Camera → Formats → Most Compatible on your iPhone."
else
"HEIC files will be kept as-is. PhotoPhile may not handle them on Linux. Consider Settings → Camera → Formats → Most Compatible.",
Text("Phone is shooting in HEIC", fontWeight = FontWeight.SemiBold,
style = MaterialTheme.typography.bodySmall)
Text("Files will be converted to JPEG. To avoid: Settings → Camera → Formats → Most Compatible.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onTertiaryContainer,
)
color = MaterialTheme.colorScheme.onTertiaryContainer)
}
TextButton(onClick = onDismiss) { Text("Dismiss") }
}
}
}
// ─── Photo preview dialog ─────────────────────────────────────────────────────
@Composable
private fun PhotoPreviewDialog(
photo: PhonePhoto,
cacheKey: String,
pullFn: suspend (devicePath: String, localDestPath: Path) -> Boolean,
onDismiss: () -> Unit,
) {
var bitmap by remember(photo.devicePath) { mutableStateOf<ImageBitmap?>(null) }
val cacheRoot = Paths.get(System.getProperty("user.home"), ".photophetch", "thumbcache")
val cacheDir = cacheRoot.resolve(cacheKey.replace("/", "_").replace(":", "_"))
val hash = (cacheKey + photo.devicePath).hashCode().toString(16)
val cachePath = cacheDir.resolve("$hash.jpg")
LaunchedEffect(photo.devicePath) {
val bytes = withContext(Dispatchers.IO) {
if (cachePath.toFile().exists() && cachePath.toFile().length() > 0) {
java.nio.file.Files.readAllBytes(cachePath)
} else {
val tmpDir = Paths.get(System.getProperty("java.io.tmpdir"), "photophetch-preview")
tmpDir.toFile().mkdirs()
val tmpFile = tmpDir.resolve("preview-${photo.filename}")
val ok = pullFn(photo.devicePath, tmpFile)
if (ok && tmpFile.toFile().exists()) {
val sips = ProcessBuilder("sips", "--resampleWidth", "800",
"--setProperty", "format", "jpeg",
tmpFile.toString(), "--out", cachePath.toString())
.redirectErrorStream(true).start()
sips.inputStream.readBytes()
sips.waitFor()
tmpFile.toFile().delete()
if (cachePath.toFile().exists()) java.nio.file.Files.readAllBytes(cachePath) else null
} else null
}
}
if (bytes != null) runCatching { bitmap = SkiaImage.makeFromEncoded(bytes).toComposeImageBitmap() }
}
Dialog(onDismissRequest = onDismiss) {
Card(modifier = Modifier.fillMaxWidth(0.85f)) {
Column(modifier = Modifier.padding(16.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()) {
Column {
Text(photo.filename, fontWeight = FontWeight.SemiBold,
style = MaterialTheme.typography.bodyMedium)
val date = photo.dateTaken.atZone(ZoneId.systemDefault())
Text("${date.toLocalDate()} ${date.toLocalTime().toString().take(5)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant)
}
if (photo.isLivePhotoStill) {
Box(modifier = Modifier
.background(androidx.compose.ui.graphics.Color(0xFF00C853),
shape = MaterialTheme.shapes.extraSmall)
.padding(horizontal = 6.dp, vertical = 2.dp)) {
Text("LIVE", style = MaterialTheme.typography.labelSmall,
color = androidx.compose.ui.graphics.Color.White,
fontWeight = FontWeight.Bold)
}
}
}
Box(modifier = Modifier.fillMaxWidth().aspectRatio(4f / 3f),
contentAlignment = Alignment.Center) {
if (bitmap != null) {
androidx.compose.foundation.Image(bitmap = bitmap!!,
contentDescription = photo.filename,
contentScale = ContentScale.Fit, modifier = Modifier.fillMaxSize())
} else {
CircularProgressIndicator()
}
}
Text("${photo.sizeBytes / 1024} KB · ${photo.sourceFolder}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant)
Button(onClick = onDismiss, modifier = Modifier.fillMaxWidth()) { Text("Close") }
}
}
}
}

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

@@ -0,0 +1,325 @@
package com.bolenpad.photophetch.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.RadioButtonUnchecked
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.toComposeImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.bolenpad.photophetch.model.MediaType
import com.bolenpad.photophetch.model.PhonePhoto
import com.bolenpad.photophetch.util.ThumbnailCache
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jetbrains.skia.Image as SkiaImage
import java.nio.file.Path
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
private val thumbLog = org.slf4j.LoggerFactory.getLogger("DenseThumb")
/**
* Dense grid: folder headers + flat photo grid per folder.
* Each photo that is the first of its day gets a small date label
* ("Jul 28") above its thumbnail only — within its column.
* Other photos in the same row get an invisible spacer so thumbnails stay aligned.
*/
@Composable
fun DensePhotoGrid(
folderSections: List<FolderSection>,
selectedPhotos: Set<PhonePhoto>,
cacheKey: String,
pullFn: suspend (devicePath: String, localDestPath: Path) -> Boolean,
thumbnailVersion: Int = 0,
columnCount: Int,
listState: LazyListState,
onTogglePhoto: (PhonePhoto) -> Unit,
onSelectAllFolder: (List<PhonePhoto>) -> Unit,
onDeselectAllFolder: (List<PhonePhoto>) -> Unit,
onPreviewPhoto: ((PhonePhoto) -> Unit)? = null,
modifier: Modifier = Modifier,
) {
// In-memory bitmap cache — survives cell recycling during scroll
// keyed on cacheKey so it resets when device changes
val bitmapCache = remember(cacheKey) {
mutableMapOf<String, ImageBitmap>()
}
if (folderSections.isEmpty() || folderSections.all { it.allPhotos.isEmpty() }) {
Box(modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(32.dp),
) {
Text("No photos found", style = MaterialTheme.typography.titleMedium)
Text(
"• Make sure the iPhone screen is unlocked\n" +
"• If another phone is plugged in, switch it to \"Transferring files\" mode\n" +
"• Run: killall PTPCamera",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
return
}
val zoneId = ZoneId.systemDefault()
val dayFmt = remember { DateTimeFormatter.ofPattern("MMM d") }
// Each photo carries a date label if it is the first of its day within its folder
data class LabeledPhoto(val photo: PhonePhoto, val dateLabel: String?)
data class PhotoRow(val photos: List<LabeledPhoto>)
data class Item(
val isFolderHeader: Boolean,
val folderSection: FolderSection? = null,
val photoRow: PhotoRow? = null,
)
val items: List<Item> = buildList {
folderSections.forEach { section ->
if (section.allPhotos.isEmpty()) return@forEach
add(Item(isFolderHeader = true, folderSection = section))
val sorted = section.allPhotos.sortedByDescending { it.dateTaken }
var lastDate: LocalDate? = null
val labeled: List<LabeledPhoto> = sorted.map { photo ->
val date = photo.dateTaken.atZone(zoneId).toLocalDate()
val label = if (date != lastDate) { lastDate = date; dayFmt.format(date) } else null
LabeledPhoto(photo, label)
}
labeled.chunked(columnCount).forEach { rowPhotos ->
add(Item(isFolderHeader = false, photoRow = PhotoRow(rowPhotos)))
}
}
}
LazyColumn(
state = listState,
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 32.dp),
) {
items(
count = items.size,
key = { idx ->
val item = items[idx]
if (item.isFolderHeader) "hdr-${item.folderSection!!.folder.name}"
else "row-${item.photoRow!!.photos.first().photo.devicePath}"
},
) { idx ->
val item = items[idx]
if (item.isFolderHeader) {
val section = item.folderSection!!
val folderSelected = section.allPhotos.count { it in selectedPhotos }
FolderDividerHeader(
name = section.folder.name,
total = section.allPhotos.size,
selected = folderSelected,
allSelected = folderSelected == section.allPhotos.size,
onSelectAll = { onSelectAllFolder(section.allPhotos) },
onDeselectAll = { onDeselectAllFolder(section.allPhotos) },
)
} else {
val row = item.photoRow!!
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalAlignment = Alignment.Bottom,
) {
row.photos.forEach { labeled ->
DenseThumb(
photo = labeled.photo,
isSelected = labeled.photo in selectedPhotos,
cacheKey = cacheKey,
pullFn = pullFn,
thumbnailVersion = thumbnailVersion,
bitmapCache = bitmapCache,
onToggle = { onTogglePhoto(labeled.photo) },
onPreview = onPreviewPhoto,
dateLabel = labeled.dateLabel,
modifier = Modifier.weight(1f),
)
}
repeat(columnCount - row.photos.size) {
// Empty spacer — needs the same height as a thumb cell with label
Spacer(Modifier.weight(1f).aspectRatio(1f).padding(top = 16.dp))
}
}
Spacer(Modifier.height(2.dp))
}
}
}
}
@Composable
private fun DenseThumb(
photo: PhonePhoto,
isSelected: Boolean,
cacheKey: String,
pullFn: suspend (devicePath: String, localDestPath: Path) -> Boolean,
thumbnailVersion: Int,
bitmapCache: MutableMap<String, ImageBitmap>,
onToggle: () -> Unit,
onPreview: ((PhonePhoto) -> Unit)?,
dateLabel: String?,
modifier: Modifier = Modifier,
) {
// Check in-memory cache first (survives Compose cell recycling during scroll)
var thumbnail by remember(photo.devicePath) {
mutableStateOf(bitmapCache[photo.devicePath])
}
LaunchedEffect(photo.devicePath, thumbnailVersion) {
if (thumbnail == null) {
val bytes = withContext(Dispatchers.IO) {
ThumbnailCache.get(
cacheKey = cacheKey,
devicePath = photo.devicePath,
filename = photo.filename,
pullFn = pullFn,
)
}
if (bytes != null && bytes.isNotEmpty()) {
runCatching {
val bitmap = SkiaImage.makeFromEncoded(bytes).toComposeImageBitmap()
bitmapCache[photo.devicePath] = bitmap // store in memory cache
thumbnail = bitmap
}.onFailure { e ->
thumbLog.warn("Decode failed for ${photo.filename} (${bytes.size} bytes): ${e.message}")
}
}
}
}
Column(modifier = modifier) {
if (dateLabel != null) {
// Date label with trailing rule — sits above this specific column
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth().padding(top = 6.dp, bottom = 2.dp),
) {
Text(
dateLabel,
fontSize = 10.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
HorizontalDivider(
modifier = Modifier.weight(1f),
thickness = 0.5.dp,
color = MaterialTheme.colorScheme.outlineVariant,
)
}
}
// No spacer for cells without a label — rows collapse tightly.
// Alignment within a row is handled by the Row using Alignment.Bottom
// so thumbnails sit flush even when some cells have a label header.
Box(
modifier = Modifier
.aspectRatio(1f)
.clickable(onClick = onToggle)
.then(if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary) else Modifier),
) {
if (thumbnail != null) {
Image(bitmap = thumbnail!!, contentDescription = null,
contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize())
} else {
Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center) {
Icon(Icons.Default.Image, contentDescription = null,
tint = MaterialTheme.colorScheme.outline, modifier = Modifier.size(20.dp))
}
}
// Top-right: high-contrast selection indicator
// White circle with drop shadow so it's visible on both light and dark thumbnails
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(4.dp)
.size(20.dp)
.background(
if (isSelected) MaterialTheme.colorScheme.primary
else Color.White.copy(alpha = 0.9f),
shape = androidx.compose.foundation.shape.CircleShape,
)
.border(
width = 1.5.dp,
color = if (isSelected) MaterialTheme.colorScheme.primary
else Color.Black.copy(alpha = 0.3f),
shape = androidx.compose.foundation.shape.CircleShape,
)
.clickable(onClick = onToggle),
contentAlignment = Alignment.Center,
) {
if (isSelected) {
Icon(Icons.Default.CheckCircle, contentDescription = null,
tint = Color.White, modifier = Modifier.size(18.dp))
}
}
// Bottom-left: LIVE badge or video size badge
if (photo.isLivePhotoStill) {
Box(
modifier = Modifier
.align(Alignment.BottomStart)
.padding(3.dp)
.background(Color(0xFF00C853), shape = MaterialTheme.shapes.extraSmall)
.padding(horizontal = 4.dp, vertical = 1.dp),
) {
Text("LIVE", fontSize = 9.sp, fontWeight = FontWeight.Bold,
color = Color.White)
}
} else if (photo.mediaType == MediaType.VIDEO) {
val sizeLabel = if (photo.sizeBytes > 1024 * 1024)
"${photo.sizeBytes / (1024 * 1024)}MB"
else if (photo.sizeBytes > 0) "${photo.sizeBytes / 1024}KB"
else ""
Badge(containerColor = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.85f),
modifier = Modifier.align(Alignment.BottomStart).padding(2.dp)) {
Text(sizeLabel, style = MaterialTheme.typography.labelSmall)
}
}
// Bottom-right: + preview button
if (onPreview != null) {
Box(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(2.dp)
.size(20.dp)
.background(
MaterialTheme.colorScheme.surface.copy(alpha = 0.75f),
shape = MaterialTheme.shapes.extraSmall,
)
.clickable { onPreview(photo) },
contentAlignment = Alignment.Center,
) {
Text("+", style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurface)
}
}
}
}
}

View File

@@ -0,0 +1,14 @@
package com.bolenpad.photophetch.ui
import com.bolenpad.photophetch.model.PhonePhoto
import java.time.LocalDate
/** A folder's photos grouped by day */
data class FolderSection(
val folder: FolderInfo,
val byDay: List<Pair<LocalDate, List<PhonePhoto>>>,
val allPhotos: List<PhonePhoto>,
)
/** Item types used in DensePhotoGrid flat list */
enum class GridItemType { FOLDER_HEADER, PHOTO_ROW }

View File

@@ -1,22 +1,22 @@
package com.bolenpad.photophetch.ui
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.FolderOpen
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.bolenpad.photophetch.util.StagingPath
import java.nio.file.Paths
/**
* Import configuration screen — shown after photo selection, before starting the copy.
* User confirms destination folder name, conversion settings, and delete-after-verify preference.
*/
@Composable
fun ImportScreen(state: AppState) {
val importConfig by state.importConfig.collectAsState()
@@ -24,131 +24,190 @@ fun ImportScreen(state: AppState) {
val selected by state.selectedPhotos.collectAsState()
val device by state.selectedDevice.collectAsState()
// Validate staging root
val stagingError = if (config.stagingRoot.isNotBlank()) {
StagingPath.validateStagingRoot(Paths.get(config.stagingRoot))
} else "Staging directory not configured"
Column(
modifier = Modifier.fillMaxSize().padding(32.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
// Header
Row(verticalAlignment = Alignment.CenterVertically) {
val canImport = stagingError == null && importConfig.folderName.isNotBlank() && selected.isNotEmpty()
// Outer column: header + scrollable body + pinned button
Column(modifier = Modifier.fillMaxSize()) {
// ── Header (always visible) ──────────────────────────────────────────
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 4.dp, top = 8.dp, end = 16.dp, bottom = 4.dp),
) {
IconButton(onClick = { state.navigateTo(Screen.BROWSE) }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
Text("Import Configuration", style = MaterialTheme.typography.headlineSmall)
}
// Summary card
Card {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("${selected.size} files selected", fontWeight = FontWeight.SemiBold)
device?.let {
Text("From: ${it.displayName}", style = MaterialTheme.typography.bodySmall)
}
HorizontalDivider()
// ── Scrollable body with bottom shadow indicator ─────────────────────
val scrollState = rememberScrollState()
// shadowAlpha: 1.0 when far from bottom, fades to 0.0 over the last 80px
val shadowAlpha by remember {
derivedStateOf {
val remaining = scrollState.maxValue - scrollState.value
(remaining / 80f).coerceIn(0f, 1f)
}
}
HorizontalDivider()
// Destination folder name
Text("Destination Folder", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
"${config.stagingRoot}/",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedTextField(
value = importConfig.folderName,
onValueChange = { state.updateImportConfig { copy(folderName = it) } },
label = { Text("Folder name") },
modifier = Modifier.weight(1f),
singleLine = true,
)
}
if (stagingError != null) {
Text(stagingError, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
}
HorizontalDivider()
// Conversion options
Text("Conversion", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
Box(
modifier = Modifier
.weight(1f)
.drawWithContent {
drawContent()
if (shadowAlpha > 0f) {
drawRect(
brush = Brush.verticalGradient(
colors = listOf(
Color.Transparent,
Color.Black.copy(alpha = 0.10f * shadowAlpha),
),
startY = size.height - 24.dp.toPx(),
endY = size.height,
)
)
}
}
) {
Column(modifier = Modifier.weight(1f)) {
Text("Convert HEIC → JPEG")
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scrollState)
.padding(horizontal = 32.dp, vertical = 20.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
// Summary card
Card {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text("${selected.size} files selected", fontWeight = FontWeight.SemiBold)
device?.let {
Text("From: ${it.displayName}", style = MaterialTheme.typography.bodySmall)
}
// Show source folders breakdown if multi-folder selection
val folders = selected.map { it.sourceFolder }.distinct().sorted()
if (folders.size > 1) {
Text(
"Folders: ${folders.joinToString(", ")}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
HorizontalDivider()
// Destination
Text("Destination Folder", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
"Recommended for PhotoPhile on Linux. Uses macOS sips (built-in).",
"${config.stagingRoot}/",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedTextField(
value = importConfig.folderName,
onValueChange = { state.updateImportConfig { copy(folderName = it) } },
label = { Text("Folder name") },
modifier = Modifier.weight(1f),
singleLine = true,
)
}
Switch(
if (stagingError != null) {
Text(
stagingError,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
HorizontalDivider()
// Options
Text("Options", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
ToggleRow(
label = "Convert HEIC → JPEG",
description = "Recommended for PhotoPhile on Linux. Uses macOS sips (built-in).",
checked = importConfig.convertHeicToJpeg,
onCheckedChange = { state.updateImportConfig { copy(convertHeicToJpeg = it) } },
)
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(modifier = Modifier.weight(1f)) {
Text("Strip Live Photo motion clips")
Text(
"Excludes .MOV companions from Live Photos. You want JPEGs, not motion clips.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(
checked = importConfig.stripLivePhotoMotion,
onCheckedChange = { state.updateImportConfig { copy(stripLivePhotoMotion = it) } },
ToggleRow(
label = "Import Live Photo motion clips (.MOV)",
description = "Include the .MOV companion alongside each Live Photo still. Paired by filename match. Default off.",
checked = importConfig.importLivePhotoCompanions,
onCheckedChange = { state.updateImportConfig { copy(importLivePhotoCompanions = it) } },
)
}
HorizontalDivider()
// Delete after verify
Text("After Import", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(modifier = Modifier.weight(1f)) {
Text("Delete from phone after verification")
Text(
"Only deletes files that passed SHA-256 verification. You'll confirm on the next screen.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(
ToggleRow(
label = "Preserve folder structure",
description = "Files go into subfolders matching their source (Camera/, Screenshots/, etc.).",
checked = importConfig.preserveFolderStructure,
onCheckedChange = {
state.updateImportConfig { copy(preserveFolderStructure = it) }
state.updateDefaultPreserveFolderStructure(it)
},
)
ToggleRow(
label = "Delete from phone after verification",
description = "Only deletes files that passed SHA-256 check. You'll confirm on the next screen.",
checked = importConfig.deleteAfterVerify,
onCheckedChange = { state.updateImportConfig { copy(deleteAfterVerify = it) } },
)
}
Spacer(Modifier.weight(1f))
// Extra bottom padding so last item isn't flush against the button
Spacer(Modifier.height(8.dp))
} // end scroll Column
} // end Box
// Start import button
Button(
onClick = { state.startImport() },
enabled = stagingError == null && importConfig.folderName.isNotBlank() && selected.isNotEmpty(),
modifier = Modifier.fillMaxWidth().height(52.dp),
) {
Text("Start Import (${selected.size} files)", style = MaterialTheme.typography.titleMedium)
// ── Pinned bottom button ─────────────────────────────────────────────
HorizontalDivider()
Box(modifier = Modifier.padding(horizontal = 32.dp, vertical = 16.dp)) {
Button(
onClick = { state.startImport() },
enabled = canImport,
modifier = Modifier.fillMaxWidth().height(52.dp),
) {
Text(
"Start Import (${selected.size} files)",
style = MaterialTheme.typography.titleMedium,
)
}
}
}
}
@Composable
private fun ToggleRow(
label: String,
description: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(modifier = Modifier.weight(1f).padding(end = 8.dp)) {
Text(label)
Text(
description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(checked = checked, onCheckedChange = onCheckedChange)
}
}

View File

@@ -3,12 +3,16 @@ package com.bolenpad.photophetch.ui
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.FolderOpen
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import java.awt.FileDialog
import java.awt.Frame
import java.io.File
@Composable
fun SettingsScreen(state: AppState) {
@@ -33,14 +37,30 @@ fun SettingsScreen(state: AppState) {
// Staging directory
Text("Staging Directory", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
OutlinedTextField(
value = stagingRoot,
onValueChange = { stagingRoot = it },
label = { Text("Path to _staging/ directory") },
placeholder = { Text("/Volumes/delphi/_staging") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
value = stagingRoot,
onValueChange = { stagingRoot = it },
label = { Text("Path to _staging/ directory") },
placeholder = { Text("/Volumes/delphi/_staging") },
modifier = Modifier.weight(1f),
singleLine = true,
)
OutlinedIconButton(
onClick = {
val chosen = pickFolder(
title = "Select _staging directory",
initialDir = stagingRoot.ifBlank { "/Volumes" },
)
if (chosen != null) stagingRoot = chosen
},
) {
Icon(Icons.Default.FolderOpen, contentDescription = "Browse…")
}
}
Text(
"This should be the _staging directory on your NAS (must be mounted).",
style = MaterialTheme.typography.bodySmall,
@@ -78,7 +98,6 @@ fun SettingsScreen(state: AppState) {
Button(
onClick = {
state.updateStagingRoot(stagingRoot)
// Also save template update
state.saveConfig()
},
modifier = Modifier.fillMaxWidth().height(52.dp),
@@ -87,3 +106,24 @@ fun SettingsScreen(state: AppState) {
}
}
}
/**
* Opens the native macOS folder picker (System.setProperty trick to make
* AWT FileDialog show directories instead of files).
* Returns the chosen absolute path, or null if cancelled.
*/
private fun pickFolder(title: String, initialDir: String): String? {
// On macOS, this system property switches FileDialog to directory-selection mode
System.setProperty("apple.awt.fileDialogForDirectories", "true")
return try {
val dialog = FileDialog(null as Frame?, title, FileDialog.LOAD).apply {
directory = initialDir
isVisible = true
}
val dir = dialog.directory
val file = dialog.file
if (file != null) File(dir, file).absolutePath else null
} finally {
System.setProperty("apple.awt.fileDialogForDirectories", "false")
}
}

View File

@@ -1,51 +1,61 @@
package com.bolenpad.photophetch.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Error
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.toComposeImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.bolenpad.photophetch.model.CopyResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jetbrains.skia.Image as SkiaImage
import java.nio.file.Files
import java.nio.file.Paths
private enum class VerifyFilter { ALL, OK, UNVERIFIED, FAILED }
/**
* Shown during active import (IMPORTING screen) and after import completes (VERIFY screen).
*/
@Composable
fun ImportingScreen(state: AppState) {
val progress by state.importProgress.collectAsState()
Column(
modifier = Modifier.fillMaxSize().padding(32.dp),
Column(modifier = Modifier.fillMaxSize().padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
verticalArrangement = Arrangement.Center) {
Text("Importing…", style = MaterialTheme.typography.headlineSmall)
Spacer(Modifier.height(32.dp))
val p = progress
if (p != null && p.total > 0) {
val fraction = p.completed.toFloat() / p.total
LinearProgressIndicator(progress = { fraction }, modifier = Modifier.fillMaxWidth(0.6f))
Spacer(Modifier.height(12.dp))
Text("${p.completed} / ${p.total} files")
if (p.currentFile.isNotBlank()) {
Text(
p.currentFile,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
CircularProgressIndicator()
}
if (p.currentFile.isNotBlank())
Text(p.currentFile, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant)
} else CircularProgressIndicator()
}
}
@@ -53,166 +63,344 @@ fun ImportingScreen(state: AppState) {
fun VerifyScreen(state: AppState) {
val result by state.importResult.collectAsState()
val importConfig by state.importConfig.collectAsState()
var deleteConfirmShown by remember { mutableStateOf(false) }
var deleteDone by remember { mutableStateOf(false) }
var previewResult by remember { mutableStateOf<CopyResult?>(null) }
var activeFilter by remember { mutableStateOf(VerifyFilter.ALL) }
val r = result ?: return
Column(modifier = Modifier.fillMaxSize().padding(24.dp)) {
// Summary header
ResultSummaryHeader(
success = r.successCount,
failure = r.failureCount,
destination = "${r.job.stagingRoot}/${r.job.folderName}",
)
// Build initial delete set: verified + EXIF-confirmed only
val deletableResults = r.results.filter { it.verified && it.error == null }
val initiallyChecked = deletableResults
.filter { it.exifVerified != false }
.map { it.photo.devicePath }.toSet()
var toDelete by remember(r) { mutableStateOf(initiallyChecked) }
var softRemoved by remember { mutableStateOf(setOf<String>()) }
Spacer(Modifier.height(16.dp))
previewResult?.let { pr ->
ResultPreviewDialog(result = pr, onDismiss = { previewResult = null })
}
// File-by-file results
Text("Results", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.height(8.dp))
LazyColumn(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
items(r.results, key = { it.photo.devicePath }) { copyResult ->
CopyResultRow(copyResult)
}
}
Column(modifier = Modifier.fillMaxSize().padding(horizontal = 20.dp, vertical = 16.dp)) {
Spacer(Modifier.height(16.dp))
HorizontalDivider()
Spacer(Modifier.height(12.dp))
// ── Summary — counts exclude soft-removed files ───────────────────
val activeResults = r.results.filter { it.photo.devicePath !in softRemoved }
val failCount = activeResults.count { it.error != null || !it.verified }
val unverifiedCount = activeResults.count { it.exifVerified == false }
val okCount = activeResults.count { it.verified && it.error == null && it.exifVerified != false }
val removedCount = softRemoved.size
// Actions
if (importConfig.deleteAfterVerify && r.successCount > 0 && !deleteDone) {
if (deleteConfirmShown) {
DeleteConfirmCard(
count = r.successCount,
onConfirm = {
state.deleteVerifiedFromDevice()
deleteDone = true
deleteConfirmShown = false
},
onCancel = { deleteConfirmShown = false },
)
} else {
OutlinedButton(
onClick = { deleteConfirmShown = true },
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
modifier = Modifier.fillMaxWidth(),
) {
Text("Delete ${r.successCount} verified files from phone")
Card(colors = CardDefaults.cardColors(
containerColor = if (failCount == 0) MaterialTheme.colorScheme.primaryContainer
else MaterialTheme.colorScheme.errorContainer)) {
Row(modifier = Modifier.fillMaxWidth().padding(12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically) {
Column {
Text(if (failCount == 0) "Import complete ✓" else "Import complete with errors",
fontWeight = FontWeight.SemiBold)
Text("${r.job.stagingRoot}/${r.job.folderName}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (failCount > 0) StatusChip("$failCount failed", MaterialTheme.colorScheme.error)
if (unverifiedCount > 0) StatusChip("$unverifiedCount", MaterialTheme.colorScheme.tertiary)
StatusChip("$okCount", MaterialTheme.colorScheme.primary)
if (removedCount > 0) StatusChip("$removedCount removed", MaterialTheme.colorScheme.outline)
}
}
} else if (deleteDone) {
Text(
"${r.successCount} files deleted from phone",
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.bodySmall,
)
}
Spacer(Modifier.height(8.dp))
// ── Filter bar ─────────────────────────────────────────────────────
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
VerifyFilter.entries.forEach { filter ->
val label = when (filter) {
VerifyFilter.ALL -> "All (${activeResults.size})"
VerifyFilter.OK -> "✓ OK ($okCount)"
VerifyFilter.UNVERIFIED -> "⚠ Unverified ($unverifiedCount)"
VerifyFilter.FAILED -> "✗ Failed ($failCount)"
}
FilterChip(selected = activeFilter == filter, onClick = { activeFilter = filter },
label = { Text(label, style = MaterialTheme.typography.labelSmall) })
}
if (removedCount > 0) {
FilterChip(selected = false, onClick = { /* show removed inline */ },
label = { Text("$removedCount removed — tap to undo all",
style = MaterialTheme.typography.labelSmall) },
trailingIcon = {
IconButton(onClick = { softRemoved = emptySet() },
modifier = Modifier.size(16.dp)) {
Icon(Icons.Default.Close, contentDescription = "Undo all removals",
modifier = Modifier.size(12.dp))
}
}
)
}
}
Spacer(Modifier.height(4.dp))
// ── Results list ───────────────────────────────────────────────────
val filteredResults = r.results.filter { cr ->
when (activeFilter) {
VerifyFilter.ALL -> true // show all including soft-removed (they'll be greyed)
VerifyFilter.OK -> cr.verified && cr.error == null && cr.exifVerified != false && cr.photo.devicePath !in softRemoved
VerifyFilter.UNVERIFIED -> cr.exifVerified == false && cr.photo.devicePath !in softRemoved
VerifyFilter.FAILED -> (cr.error != null || !cr.verified) && cr.photo.devicePath !in softRemoved
}
}
val listState = rememberLazyListState()
val topAlpha by remember { derivedStateOf {
((listState.firstVisibleItemScrollOffset + listState.firstVisibleItemIndex * 80) / 60f).coerceIn(0f, 1f)
}}
val bottomAlpha by remember { derivedStateOf {
val info = listState.layoutInfo
val last = info.visibleItemsInfo.lastOrNull()
if (last == null || last.index >= info.totalItemsCount - 1) 0f else 1f
}}
if (topAlpha > 0f) Box(Modifier.fillMaxWidth().height(16.dp).drawWithContent {
drawContent()
drawRect(brush = Brush.verticalGradient(listOf(Color.Black.copy(0.08f * topAlpha), Color.Transparent)))
})
LazyColumn(state = listState, modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(2.dp)) {
items(filteredResults, key = { it.photo.devicePath }) { cr ->
val isSoftRemoved = cr.photo.devicePath in softRemoved
VerifyResultRow(
result = cr,
showCheckbox = importConfig.deleteAfterVerify && !isSoftRemoved,
isChecked = cr.photo.devicePath in toDelete,
canCheck = cr.verified && cr.error == null,
isSoftRemoved = isSoftRemoved,
onToggleCheck = {
toDelete = if (cr.photo.devicePath in toDelete)
toDelete - cr.photo.devicePath
else toDelete + cr.photo.devicePath
},
onPreview = { previewResult = it },
onToggleRemove = {
if (isSoftRemoved) {
softRemoved = softRemoved - cr.photo.devicePath
} else {
softRemoved = softRemoved + cr.photo.devicePath
toDelete = toDelete - cr.photo.devicePath
}
},
)
}
}
if (bottomAlpha > 0f) Box(Modifier.fillMaxWidth().height(16.dp).drawWithContent {
drawContent()
drawRect(brush = Brush.verticalGradient(listOf(Color.Transparent, Color.Black.copy(0.08f * bottomAlpha))))
})
HorizontalDivider()
Spacer(Modifier.height(8.dp))
// ── Commit button ──────────────────────────────────────────────────
val hasPendingPhoneDeletes = importConfig.deleteAfterVerify && toDelete.isNotEmpty()
val hasPendingStagingCleanup = softRemoved.isNotEmpty()
val doneLabel = when {
hasPendingPhoneDeletes && hasPendingStagingCleanup ->
"Confirm: delete ${toDelete.size} from phone + remove ${softRemoved.size} from staging"
hasPendingPhoneDeletes ->
"Confirm: delete ${toDelete.size} files from phone"
hasPendingStagingCleanup ->
"Confirm: remove ${softRemoved.size} files from staging"
else -> "Done — Back to Start"
}
val doneColor = if (hasPendingPhoneDeletes || hasPendingStagingCleanup)
ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)
else ButtonDefaults.buttonColors()
Button(
onClick = {
// 1. Delete phone files (if delete-after-verify was on and user confirmed via checkbox)
if (hasPendingPhoneDeletes) {
state.deleteSpecificFromDevice(toDelete)
}
// 2. Delete soft-removed files from staging
if (hasPendingStagingCleanup) {
r.results.filter { it.photo.devicePath in softRemoved }.forEach { cr ->
try { cr.destinationPath.toFile().delete() } catch (_: Exception) {}
}
}
state.navigateTo(Screen.CONNECT)
state.selectNone()
},
modifier = Modifier.fillMaxWidth(),
colors = doneColor,
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
) {
Icon(Icons.Default.Home, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Done — Back to Start")
Text(doneLabel)
}
}
}
@Composable
private fun ResultSummaryHeader(success: Int, failure: Int, destination: String) {
Card(
colors = CardDefaults.cardColors(
containerColor = if (failure == 0)
MaterialTheme.colorScheme.primaryContainer
else
MaterialTheme.colorScheme.errorContainer,
)
) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
if (failure == 0) "Import complete ✓" else "Import complete with errors",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Text("$success verified, $failure failed")
Text(
"$destination",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
private fun StatusChip(label: String, color: Color) {
Surface(shape = MaterialTheme.shapes.small,
color = color.copy(alpha = 0.15f),
contentColor = color) {
Text(label, modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.SemiBold)
}
}
@Composable
private fun CopyResultRow(result: CopyResult) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
if (result.verified && result.error == null) Icons.Default.CheckCircle else Icons.Default.Error,
contentDescription = null,
tint = if (result.verified && result.error == null)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.error,
modifier = Modifier.size(18.dp),
)
Column(modifier = Modifier.weight(1f)) {
val displayName = if (result.convertedFromHeic) {
"${result.photo.filename}${result.destinationPath.fileName}"
} else {
result.photo.filename
private fun VerifyResultRow(
result: CopyResult,
showCheckbox: Boolean,
isChecked: Boolean,
canCheck: Boolean,
isSoftRemoved: Boolean,
onToggleCheck: () -> Unit,
onPreview: (CopyResult) -> Unit,
onToggleRemove: () -> Unit,
) {
val isFailed = result.error != null || !result.verified
val exifUnverified = result.exifVerified == false
val exifUnavailable = result.exifVerified == null
val rowBg = when {
isSoftRemoved -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
isFailed -> MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f)
exifUnverified -> MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.3f)
else -> Color.Transparent
}
Box(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.fillMaxWidth()
.background(rowBg)
.padding(vertical = 4.dp, horizontal = 4.dp)
.then(if (isSoftRemoved) Modifier.graphicsLayer { alpha = 0.4f } else Modifier),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
ThumbnailChip(destinationPath = result.destinationPath.toString(),
onClick = { if (!isSoftRemoved) onPreview(result) })
if (showCheckbox && canCheck) {
Checkbox(checked = isChecked, onCheckedChange = { onToggleCheck() },
modifier = Modifier.size(20.dp))
} else if (showCheckbox) {
Spacer(Modifier.width(20.dp))
}
Text(displayName, style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Medium)
if (result.error != null) {
Text(result.error, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.error)
} else {
val icon = when { isFailed -> Icons.Default.Error; exifUnverified -> Icons.Default.Warning; else -> Icons.Default.CheckCircle }
val iconTint = when { isFailed -> MaterialTheme.colorScheme.error; exifUnverified -> MaterialTheme.colorScheme.tertiary; else -> MaterialTheme.colorScheme.primary }
Icon(icon, contentDescription = null, tint = iconTint, modifier = Modifier.size(16.dp))
Column(modifier = Modifier.weight(1f)) {
val displayName = if (result.convertedFromHeic)
"${result.photo.filename}${result.destinationPath.fileName}"
else result.photo.filename
Text(displayName, style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Medium,
maxLines = 1, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis)
Text(
"${result.photo.sizeBytes / 1024} KB • SHA-256: ${result.destSha256.take(12)}",
when {
isFailed -> result.error ?: "Failed"
exifUnverified -> "⚠ Origin unknown" +
if (showCheckbox) " — kept on phone" else ""
exifUnavailable -> "${result.photo.sizeBytes / 1024} KB"
else -> "${result.exifMake} · ${result.photo.sizeBytes / 1024} KB"
},
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
color = when { isFailed -> MaterialTheme.colorScheme.error; exifUnverified -> MaterialTheme.colorScheme.tertiary; else -> MaterialTheme.colorScheme.onSurfaceVariant },
)
}
IconButton(onClick = onToggleRemove, modifier = Modifier.size(28.dp)) {
Icon(Icons.Default.Close, contentDescription = if (isSoftRemoved) "Undo remove" else "Remove from import",
tint = if (isSoftRemoved) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(16.dp))
}
}
// "Removed" overlay label when soft-removed
if (isSoftRemoved) {
Box(modifier = Modifier.matchParentSize(), contentAlignment = Alignment.Center) {
Surface(shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant) {
Text("Removed — tap ✕ to undo",
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Medium)
}
}
}
}
}
@Composable
private fun DeleteConfirmCard(count: Int, onConfirm: () -> Unit, onCancel: () -> Unit) {
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer),
modifier = Modifier.fillMaxWidth(),
private fun ThumbnailChip(destinationPath: String, onClick: () -> Unit) {
var bitmap by remember(destinationPath) { mutableStateOf<ImageBitmap?>(null) }
LaunchedEffect(destinationPath) {
val bytes = withContext(Dispatchers.IO) {
try { Files.readAllBytes(Paths.get(destinationPath)) } catch (_: Exception) { null }
}
if (bytes != null) runCatching { bitmap = SkiaImage.makeFromEncoded(bytes).toComposeImageBitmap() }
}
Box(
modifier = Modifier.size(44.dp).clip(MaterialTheme.shapes.small)
.background(MaterialTheme.colorScheme.surfaceVariant)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center,
) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
"Delete $count files from phone?",
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onErrorContainer,
)
Text(
"This is permanent. Only files that passed SHA-256 verification will be deleted.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
onClick = onConfirm,
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error),
) { Text("Delete from Phone") }
OutlinedButton(onClick = onCancel) { Text("Cancel") }
if (bitmap != null) {
Image(bitmap = bitmap!!, contentDescription = null,
contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize())
} else {
Text("👁", style = MaterialTheme.typography.labelSmall)
}
}
}
@Composable
private fun ResultPreviewDialog(result: CopyResult, onDismiss: () -> Unit) {
var bitmap by remember(result.destinationPath) { mutableStateOf<ImageBitmap?>(null) }
LaunchedEffect(result.destinationPath) {
val bytes = withContext(Dispatchers.IO) {
try { Files.readAllBytes(result.destinationPath) } catch (_: Exception) { null }
}
if (bytes != null) runCatching { bitmap = SkiaImage.makeFromEncoded(bytes).toComposeImageBitmap() }
}
Dialog(onDismissRequest = onDismiss) {
Card(modifier = Modifier.fillMaxWidth(0.9f)) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(result.photo.filename, fontWeight = FontWeight.SemiBold)
Text(when (result.exifVerified) {
true -> "✓ Camera original — Make: ${result.exifMake}"
false -> "⚠ Origin unknown — may not be your photo${if (!result.exifMake.isNullOrBlank()) " (Make: ${result.exifMake})" else ""}"
null -> "EXIF not checked (install exiftool)"
}, style = MaterialTheme.typography.bodySmall,
color = when (result.exifVerified) {
true -> MaterialTheme.colorScheme.primary
false -> MaterialTheme.colorScheme.tertiary
null -> MaterialTheme.colorScheme.onSurfaceVariant
})
HorizontalDivider()
Box(modifier = Modifier.fillMaxWidth().aspectRatio(4f / 3f),
contentAlignment = Alignment.Center) {
if (bitmap != null) Image(bitmap = bitmap!!, contentDescription = null,
contentScale = ContentScale.Fit, modifier = Modifier.fillMaxSize())
else CircularProgressIndicator()
}
Text("${result.photo.sizeBytes / 1024} KB · ${result.destinationPath}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant)
Button(onClick = onDismiss, modifier = Modifier.fillMaxWidth()) { Text("Close") }
}
}
}

View File

@@ -25,19 +25,34 @@ object ExifFilter {
private val excludedPathFragments = listOf(
"screenshot",
"screen_record",
"screen recording",
"screenrecord",
"videocapture",
"video editor",
"video_editor",
"whatsapp",
"telegram",
"signal",
"viber",
"received",
"download",
"snapchat",
"instagram",
"twitter",
"facebook",
"tiktok",
"slack",
"teams",
"zoom",
"discord",
"share",
"temp",
"resize",
"sticker",
"gif",
"avatar",
"profile",
"thumbnail",
"cache",
)
/**
@@ -84,6 +99,29 @@ object ExifFilter {
return true
}
/**
* Returns true if a folder name is likely a camera-roll source.
* Handles both Android path-based names and iOS classified group names.
*/
fun isCameraRollFolder(folderName: String): Boolean {
// iPhone classified groups (from iOS helper classify_iphone_file)
if (folderName == "Camera" || folderName == "Edited") return true
if (folderName in setOf("Screenshots", "Screen Recordings", "Videos", "Live Photo", "Other")) return false
// Android: path-based exclusion
val lower = folderName.lowercase()
return excludedPathFragments.none { lower.contains(it) } &&
screenshotFilePrefixes.none { lower.startsWith(it) }
}
/**
* Folders that should be completely hidden from the UI (not even in Other).
* Live Photo companions are proven by the LIVE badge on the HEIC — showing
* the MOVs separately adds noise with unverifiable placeholder thumbnails.
*/
fun isHiddenFolder(folderName: String): Boolean =
folderName == "Live Photo"
/**
* Returns photos that are HEIC files (and not Live Photo motion clips).
* Used to determine whether to show the HEIC conversion warning.

View File

@@ -49,6 +49,26 @@ object StagingPath {
return stagingRoot.resolve(folderName)
}
/**
* Returns a folder name that doesn't already exist under [stagingRoot].
* If the base name is free, returns it as-is.
* If it already exists, appends _2, _3, … until a free name is found.
*
* Examples:
* "2026-07-28_iPhone15Pro" → free → returns as-is
* "2026-07-28_iPhone15Pro" taken → returns "2026-07-28_iPhone15Pro_2"
* "2026-07-28_iPhone15Pro_2" taken → returns "2026-07-28_iPhone15Pro_3"
*/
fun uniqueFolderName(stagingRoot: Path, baseName: String): String {
if (!stagingRoot.resolve(baseName).exists()) return baseName
var counter = 2
while (true) {
val candidate = "${baseName}_$counter"
if (!stagingRoot.resolve(candidate).exists()) return candidate
counter++
}
}
/**
* Create the staging directory if it doesn't already exist.
* @throws IllegalStateException if the parent is not writable

View File

@@ -0,0 +1,85 @@
package com.bolenpad.photophetch.util
import org.slf4j.LoggerFactory
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
import kotlin.io.path.createDirectories
import kotlin.io.path.exists
/**
* Pulls and caches JPEG thumbnails from a connected phone.
*
* Device-agnostic: caller provides a [pullFn] lambda that pulls a remote file
* to a local destination path. Works for both Android (adb pull) and
* iOS (pymobiledevice3 AFC pull).
*
* Cache: ~/.photophetch/thumbcache/<cacheKey>/<hash>.jpg
*/
object ThumbnailCache {
private val log = LoggerFactory.getLogger(ThumbnailCache::class.java)
private val cacheRoot: Path = Paths.get(System.getProperty("user.home"), ".photophetch", "thumbcache")
private val tmpDir: Path = Paths.get(System.getProperty("java.io.tmpdir"), "photophetch-thumbs")
/**
* Returns raw JPEG bytes for the thumbnail, pulling from device if not cached.
* Returns null for videos or on any error.
*
* @param cacheKey Unique key for the cache directory (e.g. adb serial or device UDID)
* @param devicePath Remote path on the device
* @param filename Local filename (used to determine extension and media type)
* @param pullFn Lambda that pulls [devicePath] to [localDestPath]. Returns true on success.
*/
suspend fun get(
cacheKey: String,
devicePath: String,
filename: String,
pullFn: suspend (devicePath: String, localDestPath: Path) -> Boolean,
): ByteArray? {
val ext = filename.substringAfterLast(".", "").lowercase()
if (ext in setOf("mp4", "mov", "3gp", "avi", "mkv", "m4v", "webm", "mpeg", "mpg")) return null
val cacheDir = cacheRoot.resolve(cacheKey.replace("/", "_").replace(":", "_"))
if (!cacheDir.exists()) cacheDir.createDirectories()
if (!tmpDir.exists()) tmpDir.createDirectories()
val hash = (cacheKey + devicePath).hashCode().toString(16)
val cachePath = cacheDir.resolve("$hash.jpg")
if (cachePath.exists() && cachePath.toFile().length() > 0) {
return try { Files.readAllBytes(cachePath) } catch (_: Exception) { null }
}
return try {
val tmpFile = tmpDir.resolve("$hash.$ext")
val success = pullFn(devicePath, tmpFile)
if (!success || !tmpFile.exists() || tmpFile.toFile().length() == 0L) return null
// Resize to thumbnail via sips — also converts HEIC→JPEG
val sips = ProcessBuilder(
"sips",
"--resampleWidth", "240",
"--setProperty", "format", "jpeg",
tmpFile.toString(),
"--out", cachePath.toString()
).redirectErrorStream(true).start()
sips.inputStream.readBytes()
sips.waitFor()
tmpFile.toFile().delete()
if (cachePath.exists() && cachePath.toFile().length() > 0) {
Files.readAllBytes(cachePath)
} else null
} catch (e: Exception) {
log.debug("Thumbnail generation failed for $filename: ${e.message}")
null
}
}
fun clearForKey(cacheKey: String) {
val sanitized = cacheKey.replace("/", "_").replace(":", "_")
cacheRoot.resolve(sanitized).toFile().deleteRecursively()
}
}

View File

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

View File

@@ -0,0 +1,313 @@
#!/usr/bin/env python3
"""
photophetch-ios-helper.py
Async wrapper around pymobiledevice3 AFC for PhotoPhetch iOS connectivity.
Called by IosConnector as a subprocess.
Usage:
python3 photophetch-ios-helper.py list-devices
python3 photophetch-ios-helper.py list-photos
python3 photophetch-ios-helper.py pull /DCIM/148APPLE/IMG_001.HEIC /tmp/dest.heic
python3 photophetch-ios-helper.py delete /DCIM/148APPLE/IMG_001.HEIC
Output is JSON on stdout. Errors go to stderr with exit code 1.
"""
import sys
import json
import os
import asyncio
from datetime import datetime
import re
MEDIA_EXTENSIONS = {
'jpg', 'jpeg', 'heic', 'heif', 'avif', 'webp', 'png',
'dng', 'raw', 'arw', 'cr2', 'cr3', 'nef', 'orf', 'rw2',
'tiff', 'tif', 'bmp',
'mp4', 'm4v', 'mov', 'mpeg', 'mpg', '3gp', '3g2', 'avi', 'mkv', 'webm',
}
VIDEO_EXTENSIONS = {
'mp4', 'm4v', 'mov', 'mpeg', 'mpg', '3gp', '3g2', 'avi', 'mkv', 'webm',
}
# iPhone camera originals: IMG_XXXX.{HEIC,JPG,MOV} — exactly 4 digits
CAMERA_ORIGINAL_RE = re.compile(r'^IMG_\d{4}\.(HEIC|HEIF|JPG|JPEG|MOV)$', re.IGNORECASE)
# Edited versions: IMG_EXXXX — edited in Photos.app
CAMERA_EDITED_RE = re.compile(r'^IMG_E\d{4}\.(HEIC|HEIF|JPG|JPEG)$', re.IGNORECASE)
# Screenshots: PNG files (iPhone camera never produces PNG)
SCREENSHOT_RE = re.compile(r'.*\.PNG$', re.IGNORECASE)
# Screen recordings
SCREEN_RECORDING_RE = re.compile(r'^RPReplay_Final.*\.mp4$', re.IGNORECASE)
def classify_iphone_file(filename: str, all_filenames: set = None, size_bytes: int = 0) -> str:
"""
Returns a group name for display in the folder panel.
Groups: 'Camera', 'Edited', 'Live Photo', 'Screenshots', 'Videos', 'Other'
all_filenames: set of all filenames in the same DCIM folder (for pairing detection).
size_bytes: used to distinguish Live Photo clips (<5MB) from real videos.
"""
ext = filename.rsplit('.', 1)[-1].lower() if '.' in filename else ''
if SCREEN_RECORDING_RE.match(filename):
return 'Screen Recordings'
if SCREENSHOT_RE.match(filename):
return 'Screenshots'
if CAMERA_ORIGINAL_RE.match(filename):
if ext in ('mov',):
# Check if this is a Live Photo companion
base = filename.rsplit('.', 1)[0] # IMG_XXXX
is_live_companion = False
if all_filenames is not None:
# Paired HEIC/JPEG exists with same base name → Live Photo companion
for still_ext in ('HEIC', 'HEIF', 'JPG', 'JPEG'):
if f'{base}.{still_ext}' in all_filenames:
is_live_companion = True
break
elif size_bytes > 0 and size_bytes < 5 * 1024 * 1024:
# No pairing info but very small MOV → likely Live Photo
is_live_companion = True
return 'Live Photo' if is_live_companion else 'Videos'
return 'Camera'
if CAMERA_EDITED_RE.match(filename):
return 'Edited'
if ext in VIDEO_EXTENSIONS:
return 'Videos'
return 'Other'
async def get_lockdown():
from pymobiledevice3.lockdown import create_using_usbmux
return await create_using_usbmux()
async def get_afc(lockdown):
from pymobiledevice3.services.afc import AfcService
return AfcService(lockdown)
async def cmd_list_devices():
try:
lockdown = await get_lockdown()
result = [{
'udid': lockdown.udid,
'name': lockdown.display_name or lockdown.udid,
}]
print(json.dumps(result))
except Exception as e:
print(f'Error: {e}', file=sys.stderr)
# Return empty list rather than failing — device not connected
print(json.dumps([]))
async def cmd_list_photos():
try:
lockdown = await get_lockdown()
afc = await get_afc(lockdown)
try:
dcim_entries = await afc.listdir('/DCIM')
except Exception as e:
print(f'Error listing DCIM: {e}', file=sys.stderr)
print(json.dumps([]))
return
photos = []
debug_logged = False # log raw stat for first file to help debug date issues
for folder in sorted(dcim_entries):
folder_path = f'/DCIM/{folder}'
try:
entries = await afc.listdir(folder_path)
except Exception:
continue
# Build a set of all filenames in this folder for Live Photo pairing
all_filenames_in_folder = set(entries)
# Pre-compute Live Photo pairs: IMG_XXXX.MOV + IMG_XXXX.HEIC/JPG
# Key: base name (IMG_XXXX) -> MOV device path
live_photo_mov_paths = {}
for fname in entries:
if re.match(r'^IMG_\d{4}\.MOV$', fname, re.IGNORECASE):
base = fname.rsplit('.', 1)[0]
for still_ext in ('HEIC', 'HEIF', 'JPG', 'JPEG'):
if f'{base}.{still_ext}' in all_filenames_in_folder:
# Confirm it's small enough to be a Live Photo clip
# (we'll verify size when we hit the MOV entry)
live_photo_mov_paths[base] = f'{folder_path}/{fname}'
break
for filename in entries:
ext = filename.rsplit('.', 1)[-1].lower() if '.' in filename else ''
if ext not in MEDIA_EXTENSIONS:
continue
device_path = f'{folder_path}/{filename}'
base = filename.rsplit('.', 1)[0]
# Determine Live Photo status
is_live_motion = False
companion_device_path = None
if re.match(r'^IMG_\d{4}\.MOV$', filename, re.IGNORECASE):
if base in live_photo_mov_paths:
# This MOV is a companion — mark it but still include it
# (AppState will filter it from display)
is_live_motion = True
elif re.match(r'^IMG_\d{4}\.(HEIC|HEIF|JPG|JPEG)$', filename, re.IGNORECASE):
if base in live_photo_mov_paths:
companion_device_path = live_photo_mov_paths[base]
# Get file metadata via stat
size_bytes = 0
date_iso = None
try:
info = await afc.stat(device_path)
if not debug_logged:
print(f'DEBUG stat: {dict(info)}', file=sys.stderr)
debug_logged = True
size_bytes = int(info.get('st_size', 0))
# st_mtime is a datetime.datetime object in this version
dt = info.get('st_mtime') or info.get('datetime') or info.get('st_birthtime')
if dt is not None:
if hasattr(dt, 'isoformat'):
date_iso = dt.isoformat()
elif isinstance(dt, (list, tuple)) and len(dt) >= 6:
date_iso = datetime(int(dt[0]), int(dt[1]), int(dt[2]),
int(dt[3]), int(dt[4]), int(dt[5])).isoformat()
except Exception:
pass
photos.append({
'devicePath': device_path,
'filename': filename,
'sizeBytes': size_bytes,
'dateIso': date_iso,
'sourceFolder': classify_iphone_file(filename, all_filenames_in_folder, size_bytes),
'mediaType': 'VIDEO' if ext in VIDEO_EXTENSIONS else 'PHOTO',
'isHeic': ext in ('heic', 'heif'),
'isLivePhotoMotion': is_live_motion,
'companionDevicePath': companion_device_path,
})
print(json.dumps(photos))
except Exception as e:
print(f'Error: {e}', file=sys.stderr)
sys.exit(1)
async def cmd_pull(device_path: str, local_path: str):
try:
lockdown = await get_lockdown()
afc = await get_afc(lockdown)
os.makedirs(os.path.dirname(os.path.abspath(local_path)), exist_ok=True)
# afc.pull(src, dst) — copies remote file to local path
await afc.pull(device_path, local_path)
print(json.dumps({'ok': True, 'path': local_path}))
except Exception as e:
print(f'Error: {e}', file=sys.stderr)
sys.exit(1)
async def cmd_pull_thumb(device_path: str, local_path: str):
"""Pull first 512KB — enough for EXIF thumbnail extraction via sips."""
try:
lockdown = await get_lockdown()
afc = await get_afc(lockdown)
os.makedirs(os.path.dirname(os.path.abspath(local_path)), exist_ok=True)
try:
# fread(path, size, offset) reads partial file — much faster than full pull
data = await afc.fread(device_path, 512 * 1024, 0)
except Exception:
# fread may not exist or may fail — fall back to full pull
await afc.pull(device_path, local_path)
print(json.dumps({'ok': True, 'path': local_path}))
return
with open(local_path, 'wb') as f:
f.write(data)
print(json.dumps({'ok': True, 'path': local_path}))
except Exception as e:
print(f'Error: {e}', file=sys.stderr)
sys.exit(1)
async def cmd_batch_thumbs(pairs_json: str):
"""
Pull thumbnails for multiple files in a single AFC session.
Input: JSON array of [device_path, local_path] pairs.
Output: JSON array of {devicePath, ok} results.
Strategy by format:
- HEIC/HEIF: read first 1MB (thumbnail track is near file start),
extract embedded thumbnail via sips. Falls back to full pull if needed.
- JPEG/PNG: read first 512KB (sufficient for sips resample).
"""
try:
pairs = json.loads(pairs_json)
lockdown = await get_lockdown()
afc = await get_afc(lockdown)
results = []
for device_path, local_path in pairs:
ext = device_path.rsplit('.', 1)[-1].lower() if '.' in device_path else ''
is_heic = ext in ('heic', 'heif')
try:
os.makedirs(os.path.dirname(os.path.abspath(local_path)), exist_ok=True)
if is_heic:
# HEIC: pull full file — sips needs the full image to convert to JPEG
# Cached permanently so only transferred once
await afc.pull(device_path, local_path)
else:
# JPEG/PNG: 512KB partial read is sufficient for sips resample
try:
data = await afc.fread(device_path, 512 * 1024, 0)
with open(local_path, 'wb') as f:
f.write(data)
except Exception:
await afc.pull(device_path, local_path)
results.append({'devicePath': device_path, 'ok': True})
except Exception as e:
results.append({'devicePath': device_path, 'ok': False, 'error': str(e)})
print(json.dumps(results))
except Exception as e:
print(f'Error: {e}', file=sys.stderr)
sys.exit(1)
async def cmd_delete(device_path: str):
try:
lockdown = await get_lockdown()
afc = await get_afc(lockdown)
await afc.rm(device_path)
print(json.dumps({'ok': True}))
except Exception as e:
print(f'Error: {e}', file=sys.stderr)
sys.exit(1)
if __name__ == '__main__':
if len(sys.argv) < 2:
print('Usage: photophetch-ios-helper.py <command> [args]', file=sys.stderr)
sys.exit(1)
command = sys.argv[1]
if command == 'list-devices':
asyncio.run(cmd_list_devices())
elif command == 'list-photos':
asyncio.run(cmd_list_photos())
elif command == 'pull' and len(sys.argv) == 4:
asyncio.run(cmd_pull(sys.argv[2], sys.argv[3]))
elif command == 'pull-thumb' and len(sys.argv) == 4:
asyncio.run(cmd_pull_thumb(sys.argv[2], sys.argv[3]))
elif command == 'batch-thumbs' and len(sys.argv) == 3:
asyncio.run(cmd_batch_thumbs(sys.argv[2]))
elif command == 'delete' and len(sys.argv) == 3:
asyncio.run(cmd_delete(sys.argv[2]))
else:
print(f'Unknown command: {command}', file=sys.stderr)
sys.exit(1)