From 301291616dcfca0631d420b389bce9778ea78654 Mon Sep 17 00:00:00 2001 From: Kyle Bolen Date: Tue, 28 Jul 2026 04:49:20 +0000 Subject: [PATCH] Fix thumbnails: use MediaStore thumbnail DB instead of partial file pull MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../bolenpad/photophetch/ui/BrowseScreen.kt | 9 +- .../photophetch/util/ThumbnailCache.kt | 91 +++++++++++++------ 2 files changed, 68 insertions(+), 32 deletions(-) diff --git a/src/main/kotlin/com/bolenpad/photophetch/ui/BrowseScreen.kt b/src/main/kotlin/com/bolenpad/photophetch/ui/BrowseScreen.kt index 315911e..34135ef 100644 --- a/src/main/kotlin/com/bolenpad/photophetch/ui/BrowseScreen.kt +++ b/src/main/kotlin/com/bolenpad/photophetch/ui/BrowseScreen.kt @@ -181,17 +181,20 @@ private fun PhotoCell( val date = photo.dateTaken.atZone(ZoneId.systemDefault()).toLocalDate() val dateStr = date.format(DateTimeFormatter.ofPattern("MMM d")) - // Load thumbnail asynchronously + // Load thumbnail asynchronously — limited concurrency to avoid overwhelming ADB var thumbnail by remember(photo.devicePath) { mutableStateOf(null) } + var failed by remember(photo.devicePath) { mutableStateOf(false) } LaunchedEffect(photo.devicePath) { - if (device?.adbSerial != null) { + if (device?.adbSerial != null && !failed) { val bytes = withContext(Dispatchers.IO) { ThumbnailCache.get(adbBin, device.adbSerial, photo.devicePath, photo.filename) } if (bytes != null) { runCatching { thumbnail = Image.makeFromEncoded(bytes).toComposeImageBitmap() - } + }.onFailure { failed = true } + } else { + failed = true } } } diff --git a/src/main/kotlin/com/bolenpad/photophetch/util/ThumbnailCache.kt b/src/main/kotlin/com/bolenpad/photophetch/util/ThumbnailCache.kt index cf408fa..d7145c6 100644 --- a/src/main/kotlin/com/bolenpad/photophetch/util/ThumbnailCache.kt +++ b/src/main/kotlin/com/bolenpad/photophetch/util/ThumbnailCache.kt @@ -1,7 +1,6 @@ package com.bolenpad.photophetch.util import org.slf4j.LoggerFactory -import java.io.ByteArrayOutputStream import java.nio.file.Files import java.nio.file.Path import java.nio.file.Paths @@ -9,21 +8,26 @@ import kotlin.io.path.createDirectories import kotlin.io.path.exists /** - * Pulls and caches small JPEG thumbnails from an Android device via ADB. + * Retrieves JPEG thumbnails from an Android device using the MediaStore + * thumbnail database via `adb shell content query` + thumbnail URI pull. * - * Strategy: pull the first 64KB of each JPEG — enough to contain the - * embedded EXIF thumbnail (typically 10-15KB). The partial file is cached - * locally so repeated renders don't re-hit the 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. * - * Cache location: ~/.photophetch/thumbcache//.jpg + * 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). + * + * Cache location: ~/.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") /** - * Returns raw JPEG bytes for the thumbnail, pulling from device if not cached. - * Returns null if the pull failed or the file is a video. + * Returns raw JPEG bytes for the thumbnail. + * Returns null if unavailable (video, or MediaStore lookup failed). */ fun get( adbBin: String, @@ -32,46 +36,75 @@ object ThumbnailCache { filename: String, ): ByteArray? { val ext = filename.substringAfterLast(".", "").lowercase() - if (ext in setOf("mp4", "mov", "3gp", "avi")) return null // no thumb for video + if (ext in setOf("mp4", "mov", "3gp", "avi")) return null val cacheDir = cacheRoot.resolve(serial) if (!cacheDir.exists()) cacheDir.createDirectories() - val cacheKey = devicePath.hashCode().toString(16) + val cacheKey = (serial + devicePath).hashCode().toString(16) val cachePath = cacheDir.resolve("$cacheKey.jpg") - if (cachePath.exists()) { + if (cachePath.exists() && cachePath.toFile().length() > 0) { return try { Files.readAllBytes(cachePath) } catch (_: Exception) { null } } return try { - // Pull first 65536 bytes (enough for EXIF thumbnail in most JPEGs) - val process = ProcessBuilder( - adbBin, "-s", serial, - "exec-out", - "dd", "if=$devicePath", "bs=65536", "count=1", "2>/dev/null" - ) - .redirectErrorStream(false) - .start() + // Step 1: Find the media _id for this file via MediaStore + val mediaId = queryMediaId(adbBin, serial, devicePath) ?: return null - val bytes = process.inputStream.readBytes() + // 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()) + .redirectErrorStream(true) + .start() + process.inputStream.readBytes() process.waitFor() - if (bytes.isNotEmpty()) { - Files.write(cachePath, bytes) - bytes + if (cachePath.exists() && cachePath.toFile().length() > 0) { + Files.readAllBytes(cachePath) } else null } catch (e: Exception) { - log.debug("Thumbnail pull failed for $filename: ${e.message}") + log.debug("Thumbnail lookup failed for $filename: ${e.message}") null } } - /** Clear the entire cache for a device (call after import session). */ + 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) { - val cacheDir = cacheRoot.resolve(serial) - if (cacheDir.exists()) { - cacheDir.toFile().deleteRecursively() - } + cacheRoot.resolve(serial).toFile().deleteRecursively() } }