Commit Graph

67 Commits

Author SHA1 Message Date
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