From 1ae16f0ead984002c0cc91950d80d12b3f17fb05 Mon Sep 17 00:00:00 2001 From: Kyle Bolen Date: Tue, 28 Jul 2026 07:50:06 +0000 Subject: [PATCH] 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. --- .../photophetch/device/AndroidConnector.kt | 8 ++ .../photophetch/device/DeviceConnector.kt | 14 +++- .../photophetch/device/IosConnector.kt | 7 ++ .../com/bolenpad/photophetch/ui/AppState.kt | 2 +- .../bolenpad/photophetch/ui/BrowseScreen.kt | 8 +- .../bolenpad/photophetch/ui/DensePhotoGrid.kt | 24 +++--- .../photophetch/util/ThumbnailCache.kt | 84 +++++++------------ 7 files changed, 77 insertions(+), 70 deletions(-) diff --git a/src/main/kotlin/com/bolenpad/photophetch/device/AndroidConnector.kt b/src/main/kotlin/com/bolenpad/photophetch/device/AndroidConnector.kt index 2b66322..f99b43e 100644 --- a/src/main/kotlin/com/bolenpad/photophetch/device/AndroidConnector.kt +++ b/src/main/kotlin/com/bolenpad/photophetch/device/AndroidConnector.kt @@ -161,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) diff --git a/src/main/kotlin/com/bolenpad/photophetch/device/DeviceConnector.kt b/src/main/kotlin/com/bolenpad/photophetch/device/DeviceConnector.kt index 02d1dc0..1420eaf 100644 --- a/src/main/kotlin/com/bolenpad/photophetch/device/DeviceConnector.kt +++ b/src/main/kotlin/com/bolenpad/photophetch/device/DeviceConnector.kt @@ -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. diff --git a/src/main/kotlin/com/bolenpad/photophetch/device/IosConnector.kt b/src/main/kotlin/com/bolenpad/photophetch/device/IosConnector.kt index 99448a1..ff67b88 100644 --- a/src/main/kotlin/com/bolenpad/photophetch/device/IosConnector.kt +++ b/src/main/kotlin/com/bolenpad/photophetch/device/IosConnector.kt @@ -121,6 +121,13 @@ class IosConnector( return destFile } + override suspend fun pullToFile(device: DeviceInfo, devicePath: String, destinationPath: Path): Boolean { + val output = runHelper("pull", 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 output = runHelper("delete", photo.devicePath) if (output.contains("Error")) { diff --git a/src/main/kotlin/com/bolenpad/photophetch/ui/AppState.kt b/src/main/kotlin/com/bolenpad/photophetch/ui/AppState.kt index b94c905..e8385be 100644 --- a/src/main/kotlin/com/bolenpad/photophetch/ui/AppState.kt +++ b/src/main/kotlin/com/bolenpad/photophetch/ui/AppState.kt @@ -343,7 +343,7 @@ class AppState(private val configStore: ConfigStore) { // ------------------------------------------------------------------------- // 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.python3Path ?: "python3") } diff --git a/src/main/kotlin/com/bolenpad/photophetch/ui/BrowseScreen.kt b/src/main/kotlin/com/bolenpad/photophetch/ui/BrowseScreen.kt index 4e4fc61..1a9ccf3 100644 --- a/src/main/kotlin/com/bolenpad/photophetch/ui/BrowseScreen.kt +++ b/src/main/kotlin/com/bolenpad/photophetch/ui/BrowseScreen.kt @@ -95,8 +95,12 @@ fun BrowseScreen(state: AppState) { DensePhotoGrid( folderSections = folderSections, selectedPhotos = selectedPhotos, - device = device, - adbBin = config.adbPath ?: "adb", + cacheKey = device?.let { state.connectorFor(it).cacheKey(it) } ?: "unknown", + pullFn = { devicePath, localPath -> + device?.let { d -> + state.connectorFor(d).pullToFile(d, devicePath, localPath) + } ?: false + }, columnCount = columnCount, listState = listState, onTogglePhoto = { state.togglePhotoSelection(it) }, diff --git a/src/main/kotlin/com/bolenpad/photophetch/ui/DensePhotoGrid.kt b/src/main/kotlin/com/bolenpad/photophetch/ui/DensePhotoGrid.kt index 63bf13e..fca147e 100644 --- a/src/main/kotlin/com/bolenpad/photophetch/ui/DensePhotoGrid.kt +++ b/src/main/kotlin/com/bolenpad/photophetch/ui/DensePhotoGrid.kt @@ -20,13 +20,13 @@ 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.DeviceInfo 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 @@ -41,8 +41,8 @@ import java.time.format.DateTimeFormatter fun DensePhotoGrid( folderSections: List, selectedPhotos: Set, - device: DeviceInfo?, - adbBin: String, + cacheKey: String, + pullFn: suspend (devicePath: String, localDestPath: Path) -> Boolean, columnCount: Int, listState: LazyListState, onTogglePhoto: (PhonePhoto) -> Unit, @@ -51,7 +51,6 @@ fun DensePhotoGrid( modifier: Modifier = Modifier, ) { if (folderSections.isEmpty() || folderSections.all { it.allPhotos.isEmpty() }) { - val isIos = folderSections.isEmpty() // IosConnector returns no folders when blocked Box(modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Column( horizontalAlignment = Alignment.CenterHorizontally, @@ -139,8 +138,8 @@ fun DensePhotoGrid( DenseThumb( photo = labeled.photo, isSelected = labeled.photo in selectedPhotos, - device = device, - adbBin = adbBin, + cacheKey = cacheKey, + pullFn = pullFn, onToggle = { onTogglePhoto(labeled.photo) }, dateLabel = labeled.dateLabel, modifier = Modifier.weight(1f), @@ -161,8 +160,8 @@ fun DensePhotoGrid( private fun DenseThumb( photo: PhonePhoto, isSelected: Boolean, - device: DeviceInfo?, - adbBin: String, + cacheKey: String, + pullFn: suspend (devicePath: String, localDestPath: Path) -> Boolean, onToggle: () -> Unit, dateLabel: String?, modifier: Modifier = Modifier, @@ -171,9 +170,14 @@ private fun DenseThumb( var thumbFailed by remember(photo.devicePath) { mutableStateOf(false) } LaunchedEffect(photo.devicePath) { - if (device?.adbSerial != null && !thumbFailed && thumbnail == null) { + if (!thumbFailed && thumbnail == null) { val bytes = withContext(Dispatchers.IO) { - ThumbnailCache.get(adbBin, device.adbSerial, photo.devicePath, photo.filename) + ThumbnailCache.get( + cacheKey = cacheKey, + devicePath = photo.devicePath, + filename = photo.filename, + pullFn = pullFn, + ) } if (bytes != null) { runCatching { diff --git a/src/main/kotlin/com/bolenpad/photophetch/util/ThumbnailCache.kt b/src/main/kotlin/com/bolenpad/photophetch/util/ThumbnailCache.kt index 34bd17f..eb5e198 100644 --- a/src/main/kotlin/com/bolenpad/photophetch/util/ThumbnailCache.kt +++ b/src/main/kotlin/com/bolenpad/photophetch/util/ThumbnailCache.kt @@ -1,7 +1,5 @@ package com.bolenpad.photophetch.util -import kotlinx.coroutines.sync.Semaphore -import kotlinx.coroutines.sync.withPermit import org.slf4j.LoggerFactory import java.nio.file.Files import java.nio.file.Path @@ -10,101 +8,75 @@ import kotlin.io.path.createDirectories import kotlin.io.path.exists /** - * Pulls and caches JPEG thumbnails from an Android device. + * Pulls and caches JPEG thumbnails from a connected phone. * - * Approach: pull the full file via `adb pull`, resize to 240px with macOS - * `sips`, cache the thumbnail. Subsequent views are instant from cache. + * 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). * - * Concurrency: at most 3 simultaneous ADB pulls to avoid overwhelming the - * connection. Cells that are scrolled past before their pull completes are - * cancelled automatically by Compose's LaunchedEffect lifecycle. - * - * Cache: ~/.photophetch/thumbcache//.jpg — persists across - * sessions so re-opening the app is fast. + * Cache: ~/.photophetch/thumbcache//.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") - // Max simultaneous ADB pulls - private val semaphore = Semaphore(3) - /** * Returns raw JPEG bytes for the thumbnail, pulling from device if not cached. - * Suspend — respects coroutine cancellation (scrolling away cancels the pull). * 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( - adbBin: String, - serial: String, + 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")) return null + if (ext in setOf("mp4", "mov", "3gp", "avi", "mkv", "m4v", "webm", "mpeg", "mpg")) return null - val cacheDir = cacheRoot.resolve(serial) + val cacheDir = cacheRoot.resolve(cacheKey.replace("/", "_").replace(":", "_")) if (!cacheDir.exists()) cacheDir.createDirectories() if (!tmpDir.exists()) tmpDir.createDirectories() - val cacheKey = (serial + devicePath).hashCode().toString(16) - val cachePath = cacheDir.resolve("$cacheKey.jpg") + val hash = (cacheKey + devicePath).hashCode().toString(16) + val cachePath = cacheDir.resolve("$hash.jpg") - // Return cached version immediately if (cachePath.exists() && cachePath.toFile().length() > 0) { return try { Files.readAllBytes(cachePath) } catch (_: Exception) { null } } - // Acquire slot — waits if 3 pulls are already running - return semaphore.withPermit { - pullAndResize(adbBin, serial, devicePath, filename, ext, cachePath) - } - } - - private fun pullAndResize( - adbBin: String, - serial: String, - devicePath: String, - filename: String, - ext: String, - cachePath: Path, - ): ByteArray? { - val tmpFile = tmpDir.resolve("${cachePath.fileName.toString().removeSuffix(".jpg")}.$ext") return try { - // Pull full file - val pull = ProcessBuilder(adbBin, "-s", serial, "pull", devicePath, tmpFile.toString()) - .redirectErrorStream(true) - .start() - pull.inputStream.readBytes() // drain output - pull.waitFor() + val tmpFile = tmpDir.resolve("$hash.$ext") - if (!tmpFile.exists() || tmpFile.toFile().length() == 0L) return null + val success = pullFn(devicePath, tmpFile) + if (!success || !tmpFile.exists() || tmpFile.toFile().length() == 0L) return null - // Resize to 240px wide thumbnail via sips (macOS built-in) + // Resize to thumbnail via sips (macOS built-in) val sips = ProcessBuilder( - "sips", - "--resampleWidth", "240", - tmpFile.toString(), - "--out", cachePath.toString() - ) - .redirectErrorStream(true) - .start() + "sips", "--resampleWidth", "240", + 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 - } finally { - tmpFile.toFile().delete() } } - fun clearForDevice(serial: String) { - cacheRoot.resolve(serial).toFile().deleteRecursively() + fun clearForKey(cacheKey: String) { + val sanitized = cacheKey.replace("/", "_").replace(":", "_") + cacheRoot.resolve(sanitized).toFile().deleteRecursively() } }