Thumbnails: pull full file + sips resize, max 3 concurrent ADB pulls
MediaStore content query is unreliable on Samsung. Switch to: 1. adb pull full file to tmp 2. sips --resampleWidth 240 to generate thumbnail 3. Cache in ~/.photophetch/thumbcache/<serial>/ 4. Semaphore(3) limits concurrent pulls — cells scrolled past are cancelled by Compose LaunchedEffect lifecycle automatically. First load per photo takes 2-3s. Subsequent views instant from cache.
This commit is contained in:
@@ -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/<serial>/<hash>.jpg
|
||||
* Cache: ~/.photophetch/thumbcache/<serial>/<hash>.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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user