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.
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.
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
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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
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.
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.
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.
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)'.
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.
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.
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.
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.
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.
- 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
- 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
- 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
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.
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.
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.
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.
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.
- 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
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.
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.
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.
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.
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