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