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 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user