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:
Kyle Bolen
2026-07-28 04:57:13 +00:00
parent 301291616d
commit 43086a91f1

View File

@@ -1,5 +1,7 @@
package com.bolenpad.photophetch.util package com.bolenpad.photophetch.util
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.Path import java.nio.file.Path
@@ -8,28 +10,32 @@ import kotlin.io.path.createDirectories
import kotlin.io.path.exists import kotlin.io.path.exists
/** /**
* Retrieves JPEG thumbnails from an Android device using the MediaStore * Pulls and caches JPEG thumbnails from an Android device.
* thumbnail database via `adb shell content query` + thumbnail URI pull.
* *
* Android pre-generates thumbnails at ~512px for every photo in the gallery. * Approach: pull the full file via `adb pull`, resize to 240px with macOS
* These live in /sdcard/Android/data/com.android.providers.media/... or are * `sips`, cache the thumbnail. Subsequent views are instant from cache.
* accessible via content://media/external/images/thumbnails.
* *
* We use `adb shell content query` to find the thumbnail path for a given * Concurrency: at most 3 simultaneous ADB pulls to avoid overwhelming the
* media file, then `adb pull` to fetch just the thumbnail (~20-50KB vs 3-5MB * connection. Cells that are scrolled past before their pull completes are
* for the full file). * 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 { 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")
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 raw JPEG bytes for the thumbnail, pulling from device if not cached.
* Returns null if unavailable (video, or MediaStore lookup failed). * Suspend — respects coroutine cancellation (scrolling away cancels the pull).
* Returns null for videos or on any error.
*/ */
fun get( suspend fun get(
adbBin: String, adbBin: String,
serial: String, serial: String,
devicePath: String, devicePath: String,
@@ -40,70 +46,64 @@ object ThumbnailCache {
val cacheDir = cacheRoot.resolve(serial) val cacheDir = cacheRoot.resolve(serial)
if (!cacheDir.exists()) cacheDir.createDirectories() if (!cacheDir.exists()) cacheDir.createDirectories()
if (!tmpDir.exists()) tmpDir.createDirectories()
val cacheKey = (serial + devicePath).hashCode().toString(16) val cacheKey = (serial + devicePath).hashCode().toString(16)
val cachePath = cacheDir.resolve("$cacheKey.jpg") val cachePath = cacheDir.resolve("$cacheKey.jpg")
// Return cached version immediately
if (cachePath.exists() && cachePath.toFile().length() > 0) { if (cachePath.exists() && cachePath.toFile().length() > 0) {
return try { Files.readAllBytes(cachePath) } catch (_: Exception) { null } 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 { return try {
// Step 1: Find the media _id for this file via MediaStore // Pull full file
val mediaId = queryMediaId(adbBin, serial, devicePath) ?: return null val pull = ProcessBuilder(adbBin, "-s", serial, "pull", devicePath, tmpFile.toString())
// 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) .redirectErrorStream(true)
.start() .start()
process.inputStream.readBytes() pull.inputStream.readBytes() // drain output
process.waitFor() 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) { if (cachePath.exists() && cachePath.toFile().length() > 0) {
Files.readAllBytes(cachePath) Files.readAllBytes(cachePath)
} else null } else null
} catch (e: Exception) { } catch (e: Exception) {
log.debug("Thumbnail lookup failed for $filename: ${e.message}") log.debug("Thumbnail generation failed for $filename: ${e.message}")
null 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) { fun clearForDevice(serial: String) {
cacheRoot.resolve(serial).toFile().deleteRecursively() cacheRoot.resolve(serial).toFile().deleteRecursively()
} }