diff --git a/src/main/kotlin/com/bolenpad/photophetch/util/ThumbnailCache.kt b/src/main/kotlin/com/bolenpad/photophetch/util/ThumbnailCache.kt index d7145c6..34bd17f 100644 --- a/src/main/kotlin/com/bolenpad/photophetch/util/ThumbnailCache.kt +++ b/src/main/kotlin/com/bolenpad/photophetch/util/ThumbnailCache.kt @@ -1,5 +1,7 @@ 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 @@ -8,28 +10,32 @@ import kotlin.io.path.createDirectories import kotlin.io.path.exists /** - * Retrieves JPEG thumbnails from an Android device using the MediaStore - * thumbnail database via `adb shell content query` + thumbnail URI pull. + * Pulls and caches JPEG thumbnails from an Android device. * - * Android pre-generates thumbnails at ~512px for every photo in the gallery. - * These live in /sdcard/Android/data/com.android.providers.media/... or are - * accessible via content://media/external/images/thumbnails. + * Approach: pull the full file via `adb pull`, resize to 240px with macOS + * `sips`, cache the thumbnail. Subsequent views are instant from cache. * - * We use `adb shell content query` to find the thumbnail path for a given - * media file, then `adb pull` to fetch just the thumbnail (~20-50KB vs 3-5MB - * for the full file). + * 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 location: ~/.photophetch/thumbcache//.jpg + * Cache: ~/.photophetch/thumbcache//.jpg — persists across + * sessions so re-opening the app is fast. */ 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. - * Returns null if unavailable (video, or MediaStore lookup failed). + * 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. */ - fun get( + suspend fun get( adbBin: String, serial: String, devicePath: String, @@ -40,70 +46,64 @@ object ThumbnailCache { val cacheDir = cacheRoot.resolve(serial) if (!cacheDir.exists()) cacheDir.createDirectories() + if (!tmpDir.exists()) tmpDir.createDirectories() val cacheKey = (serial + devicePath).hashCode().toString(16) val cachePath = cacheDir.resolve("$cacheKey.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 { - // Step 1: Find the media _id for this file via MediaStore - val mediaId = queryMediaId(adbBin, serial, devicePath) ?: return null - - // Step 2: Get the thumbnail path for this media_id - val thumbPath = queryThumbnailPath(adbBin, serial, mediaId) ?: return null - - // Step 3: Pull just the thumbnail file (~20-50KB) - val process = ProcessBuilder(adbBin, "-s", serial, "pull", thumbPath, cachePath.toString()) + // Pull full file + val pull = ProcessBuilder(adbBin, "-s", serial, "pull", devicePath, tmpFile.toString()) .redirectErrorStream(true) .start() - process.inputStream.readBytes() - process.waitFor() + pull.inputStream.readBytes() // drain output + pull.waitFor() + + if (!tmpFile.exists() || tmpFile.toFile().length() == 0L) return null + + // Resize to 240px wide thumbnail via sips (macOS built-in) + val sips = ProcessBuilder( + "sips", + "--resampleWidth", "240", + tmpFile.toString(), + "--out", cachePath.toString() + ) + .redirectErrorStream(true) + .start() + sips.inputStream.readBytes() + sips.waitFor() if (cachePath.exists() && cachePath.toFile().length() > 0) { Files.readAllBytes(cachePath) } else null } catch (e: Exception) { - log.debug("Thumbnail lookup failed for $filename: ${e.message}") + log.debug("Thumbnail generation failed for $filename: ${e.message}") null + } finally { + tmpFile.toFile().delete() } } - private fun queryMediaId(adbBin: String, serial: String, devicePath: String): String? { - // Query MediaStore for the _id of the file at this path - val output = runAdb( - adbBin, serial, - "shell", "content", "query", - "--uri", "content://media/external/images/media", - "--projection", "_id", - "--where", "_data='$devicePath'" - ) - // Output: "Row: 0 _id=12345" - return Regex("_id=(\\d+)").find(output)?.groupValues?.getOrNull(1) - } - - private fun queryThumbnailPath(adbBin: String, serial: String, mediaId: String): String? { - val output = runAdb( - adbBin, serial, - "shell", "content", "query", - "--uri", "content://media/external/images/thumbnails", - "--projection", "_data", - "--where", "image_id=$mediaId" - ) - // Output: "Row: 0 _data=/sdcard/.thumbnails/12345.jpg" - return Regex("_data=([^\\s]+)").find(output)?.groupValues?.getOrNull(1) - } - - private fun runAdb(adbBin: String, serial: String, vararg args: String): String { - val cmd = listOf(adbBin, "-s", serial) + args.toList() - val process = ProcessBuilder(cmd).redirectErrorStream(true).start() - val output = process.inputStream.bufferedReader().readText() - process.waitFor() - return output - } - fun clearForDevice(serial: String) { cacheRoot.resolve(serial).toFile().deleteRecursively() }