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