Commit Graph

94 Commits

Author SHA1 Message Date
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
Kyle Bolen
c4608e9c0e Fix compile errors from initial build
- Main.kt: move ConfigStore/AppState outside application {} scope (remember
  is not valid outside a composable)
- IosConnector: split runGphoto2/runGphoto2InDir to avoid named+positional
  vararg mixing (Kotlin does not allow this)
- ConnectScreen: fix SmartPhone -> Smartphone icon name
- ConnectScreen/BrowseScreen/ImportScreen/SettingsScreen: update deprecated
  Icons.Default.ArrowBack to Icons.AutoMirrored.Filled.ArrowBack
- AndroidConnector: remove unused variable assignment in remoteChecksum
- ExifFilter: replace return@filter labels with private helper function to
  eliminate ambiguous label warnings
2026-07-28 03:09:28 +00:00
Kyle Bolen
145b7bc955 Initial commit: PhotoPhetch v1.0.0
Kotlin/Compose Multiplatform desktop app for importing photos from
phones into PhotoPhile staging directory.

Features:
- Android support via ADB subprocess
- iPhone support via gphoto2 subprocess
- Smart filtering: excludes screenshots, received images, Live Photo MOVs
- HEIC -> JPEG conversion via macOS sips (built-in)
- SHA-256 verification of every copied file
- Safe delete with explicit confirmation
- Staging path convention: _staging/{date}_{device}/
- Config persistence at ~/.photophetch/config.json
2026-07-28 03:04:05 +00:00