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:
@@ -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<ImageBitmap?>(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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/<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 {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user