Fix thumbnails: use MediaStore thumbnail DB instead of partial file pull

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.
This commit is contained in:
Kyle Bolen
2026-07-28 04:49:20 +00:00
parent 770aa58dd0
commit 301291616d
2 changed files with 68 additions and 32 deletions

View File

@@ -181,17 +181,20 @@ private fun PhotoCell(
val date = photo.dateTaken.atZone(ZoneId.systemDefault()).toLocalDate() val date = photo.dateTaken.atZone(ZoneId.systemDefault()).toLocalDate()
val dateStr = date.format(DateTimeFormatter.ofPattern("MMM d")) 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<ImageBitmap?>(null) } var thumbnail by remember(photo.devicePath) { mutableStateOf<ImageBitmap?>(null) }
var failed by remember(photo.devicePath) { mutableStateOf(false) }
LaunchedEffect(photo.devicePath) { LaunchedEffect(photo.devicePath) {
if (device?.adbSerial != null) { if (device?.adbSerial != null && !failed) {
val bytes = withContext(Dispatchers.IO) { val bytes = withContext(Dispatchers.IO) {
ThumbnailCache.get(adbBin, device.adbSerial, photo.devicePath, photo.filename) ThumbnailCache.get(adbBin, device.adbSerial, photo.devicePath, photo.filename)
} }
if (bytes != null) { if (bytes != null) {
runCatching { runCatching {
thumbnail = Image.makeFromEncoded(bytes).toComposeImageBitmap() thumbnail = Image.makeFromEncoded(bytes).toComposeImageBitmap()
} }.onFailure { failed = true }
} else {
failed = true
} }
} }
} }

View File

@@ -1,7 +1,6 @@
package com.bolenpad.photophetch.util package com.bolenpad.photophetch.util
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import java.io.ByteArrayOutputStream
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.Path import java.nio.file.Path
import java.nio.file.Paths import java.nio.file.Paths
@@ -9,21 +8,26 @@ import kotlin.io.path.createDirectories
import kotlin.io.path.exists 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 * Android pre-generates thumbnails at ~512px for every photo in the gallery.
* embedded EXIF thumbnail (typically 10-15KB). The partial file is cached * These live in /sdcard/Android/data/com.android.providers.media/... or are
* locally so repeated renders don't re-hit the device. * accessible via content://media/external/images/thumbnails.
* *
* Cache location: ~/.photophetch/thumbcache/<serial>/<filename_hash>.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/<serial>/<hash>.jpg
*/ */
object ThumbnailCache { object ThumbnailCache {
private val log = LoggerFactory.getLogger(ThumbnailCache::class.java) private val log = LoggerFactory.getLogger(ThumbnailCache::class.java)
private val cacheRoot: Path = Paths.get(System.getProperty("user.home"), ".photophetch", "thumbcache") 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 raw JPEG bytes for the thumbnail.
* Returns null if the pull failed or the file is a video. * Returns null if unavailable (video, or MediaStore lookup failed).
*/ */
fun get( fun get(
adbBin: String, adbBin: String,
@@ -32,46 +36,75 @@ object ThumbnailCache {
filename: String, filename: String,
): ByteArray? { ): ByteArray? {
val ext = filename.substringAfterLast(".", "").lowercase() 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) val cacheDir = cacheRoot.resolve(serial)
if (!cacheDir.exists()) cacheDir.createDirectories() if (!cacheDir.exists()) cacheDir.createDirectories()
val cacheKey = devicePath.hashCode().toString(16) val cacheKey = (serial + devicePath).hashCode().toString(16)
val cachePath = cacheDir.resolve("$cacheKey.jpg") 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 { Files.readAllBytes(cachePath) } catch (_: Exception) { null }
} }
return try { return try {
// Pull first 65536 bytes (enough for EXIF thumbnail in most JPEGs) // Step 1: Find the media _id for this file via MediaStore
val process = ProcessBuilder( val mediaId = queryMediaId(adbBin, serial, devicePath) ?: return null
adbBin, "-s", serial,
"exec-out",
"dd", "if=$devicePath", "bs=65536", "count=1", "2>/dev/null"
)
.redirectErrorStream(false)
.start()
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() process.waitFor()
if (bytes.isNotEmpty()) { if (cachePath.exists() && cachePath.toFile().length() > 0) {
Files.write(cachePath, bytes) Files.readAllBytes(cachePath)
bytes
} else null } else null
} catch (e: Exception) { } catch (e: Exception) {
log.debug("Thumbnail pull failed for $filename: ${e.message}") log.debug("Thumbnail lookup failed for $filename: ${e.message}")
null null
} }
} }
/** Clear the entire cache for a device (call after import session). */ private fun queryMediaId(adbBin: String, serial: String, devicePath: String): String? {
fun clearForDevice(serial: String) { // Query MediaStore for the _id of the file at this path
val cacheDir = cacheRoot.resolve(serial) val output = runAdb(
if (cacheDir.exists()) { adbBin, serial,
cacheDir.toFile().deleteRecursively() "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()
} }
} }