Thumbnails work for both Android and iOS

ThumbnailCache: refactored to accept a device-agnostic pullFn lambda
instead of hardcoded adb parameters.

DeviceConnector: add pullToFile() and cacheKey() to interface.
AndroidConnector: implement pullToFile via adb pull, cacheKey = serial.
IosConnector: implement pullToFile via AFC helper pull, cacheKey = UDID.

DensePhotoGrid/DenseThumb: now receive cacheKey + pullFn instead of
device/adbBin, built by BrowseScreen from the active connector.
This commit is contained in:
Kyle Bolen
2026-07-28 07:50:06 +00:00
parent 50d6f66116
commit 1ae16f0ead
7 changed files with 77 additions and 70 deletions

View File

@@ -161,6 +161,14 @@ class AndroidConnector(
return destFile
}
override suspend fun pullToFile(device: DeviceInfo, devicePath: String, destinationPath: Path): Boolean {
val serial = requireSerial(device)
val output = runAdb(serial, "pull", devicePath, destinationPath.absolutePathString())
return output.contains("pulled") || output.contains("1 file")
}
override fun cacheKey(device: DeviceInfo): String = device.adbSerial ?: device.displayName
override suspend fun deletePhoto(device: DeviceInfo, photo: PhonePhoto) {
val serial = requireSerial(device)
val output = runAdb(serial, "shell", "rm", photo.devicePath)

View File

@@ -26,10 +26,22 @@ interface DeviceConnector {
/**
* Pull a single photo from the device to [destinationDir].
* Returns the local path of the pulled file.
* @throws DeviceException on adb/gphoto2 failure
* @throws DeviceException on adb/AFC failure
*/
suspend fun pullPhoto(device: DeviceInfo, photo: PhonePhoto, destinationDir: Path): Path
/**
* Pull a file from the device to an exact [destinationPath].
* Used for thumbnail fetching. Returns true on success.
*/
suspend fun pullToFile(device: DeviceInfo, devicePath: String, destinationPath: Path): Boolean
/**
* A stable cache key for this device (e.g. ADB serial or UDID).
* Used by ThumbnailCache to namespace cached thumbnails.
*/
fun cacheKey(device: DeviceInfo): String
/**
* Delete a photo from the device.
* Only called after the user explicitly confirms deletion.

View File

@@ -121,6 +121,13 @@ class IosConnector(
return destFile
}
override suspend fun pullToFile(device: DeviceInfo, devicePath: String, destinationPath: Path): Boolean {
val output = runHelper("pull", devicePath, destinationPath.absolutePathString())
return !output.contains("Error") && destinationPath.toFile().exists()
}
override fun cacheKey(device: DeviceInfo): String = device.gphotoPort ?: device.displayName
override suspend fun deletePhoto(device: DeviceInfo, photo: PhonePhoto) {
val output = runHelper("delete", photo.devicePath)
if (output.contains("Error")) {

View File

@@ -343,7 +343,7 @@ class AppState(private val configStore: ConfigStore) {
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private fun connectorFor(device: DeviceInfo): DeviceConnector = when (device.type) {
internal fun connectorFor(device: DeviceInfo): DeviceConnector = when (device.type) {
DeviceType.ANDROID -> AndroidConnector(_config.value.adbPath ?: "adb")
DeviceType.IOS -> IosConnector(_config.value.python3Path ?: "python3")
}

View File

@@ -95,8 +95,12 @@ fun BrowseScreen(state: AppState) {
DensePhotoGrid(
folderSections = folderSections,
selectedPhotos = selectedPhotos,
device = device,
adbBin = config.adbPath ?: "adb",
cacheKey = device?.let { state.connectorFor(it).cacheKey(it) } ?: "unknown",
pullFn = { devicePath, localPath ->
device?.let { d ->
state.connectorFor(d).pullToFile(d, devicePath, localPath)
} ?: false
},
columnCount = columnCount,
listState = listState,
onTogglePhoto = { state.togglePhotoSelection(it) },

View File

@@ -20,13 +20,13 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.bolenpad.photophetch.model.DeviceInfo
import com.bolenpad.photophetch.model.MediaType
import com.bolenpad.photophetch.model.PhonePhoto
import com.bolenpad.photophetch.util.ThumbnailCache
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jetbrains.skia.Image as SkiaImage
import java.nio.file.Path
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@@ -41,8 +41,8 @@ import java.time.format.DateTimeFormatter
fun DensePhotoGrid(
folderSections: List<FolderSection>,
selectedPhotos: Set<PhonePhoto>,
device: DeviceInfo?,
adbBin: String,
cacheKey: String,
pullFn: suspend (devicePath: String, localDestPath: Path) -> Boolean,
columnCount: Int,
listState: LazyListState,
onTogglePhoto: (PhonePhoto) -> Unit,
@@ -51,7 +51,6 @@ fun DensePhotoGrid(
modifier: Modifier = Modifier,
) {
if (folderSections.isEmpty() || folderSections.all { it.allPhotos.isEmpty() }) {
val isIos = folderSections.isEmpty() // IosConnector returns no folders when blocked
Box(modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
@@ -139,8 +138,8 @@ fun DensePhotoGrid(
DenseThumb(
photo = labeled.photo,
isSelected = labeled.photo in selectedPhotos,
device = device,
adbBin = adbBin,
cacheKey = cacheKey,
pullFn = pullFn,
onToggle = { onTogglePhoto(labeled.photo) },
dateLabel = labeled.dateLabel,
modifier = Modifier.weight(1f),
@@ -161,8 +160,8 @@ fun DensePhotoGrid(
private fun DenseThumb(
photo: PhonePhoto,
isSelected: Boolean,
device: DeviceInfo?,
adbBin: String,
cacheKey: String,
pullFn: suspend (devicePath: String, localDestPath: Path) -> Boolean,
onToggle: () -> Unit,
dateLabel: String?,
modifier: Modifier = Modifier,
@@ -171,9 +170,14 @@ private fun DenseThumb(
var thumbFailed by remember(photo.devicePath) { mutableStateOf(false) }
LaunchedEffect(photo.devicePath) {
if (device?.adbSerial != null && !thumbFailed && thumbnail == null) {
if (!thumbFailed && thumbnail == null) {
val bytes = withContext(Dispatchers.IO) {
ThumbnailCache.get(adbBin, device.adbSerial, photo.devicePath, photo.filename)
ThumbnailCache.get(
cacheKey = cacheKey,
devicePath = photo.devicePath,
filename = photo.filename,
pullFn = pullFn,
)
}
if (bytes != null) {
runCatching {

View File

@@ -1,7 +1,5 @@
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
@@ -10,101 +8,75 @@ import kotlin.io.path.createDirectories
import kotlin.io.path.exists
/**
* Pulls and caches JPEG thumbnails from an Android device.
* Pulls and caches JPEG thumbnails from a connected phone.
*
* Approach: pull the full file via `adb pull`, resize to 240px with macOS
* `sips`, cache the thumbnail. Subsequent views are instant from cache.
* Device-agnostic: caller provides a [pullFn] lambda that pulls a remote file
* to a local destination path. Works for both Android (adb pull) and
* iOS (pymobiledevice3 AFC pull).
*
* 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: ~/.photophetch/thumbcache/<serial>/<hash>.jpg — persists across
* sessions so re-opening the app is fast.
* Cache: ~/.photophetch/thumbcache/<cacheKey>/<hash>.jpg
*/
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, pulling from device if not cached.
* Suspend — respects coroutine cancellation (scrolling away cancels the pull).
* Returns null for videos or on any error.
*
* @param cacheKey Unique key for the cache directory (e.g. adb serial or device UDID)
* @param devicePath Remote path on the device
* @param filename Local filename (used to determine extension and media type)
* @param pullFn Lambda that pulls [devicePath] to [localDestPath]. Returns true on success.
*/
suspend fun get(
adbBin: String,
serial: String,
cacheKey: String,
devicePath: String,
filename: String,
pullFn: suspend (devicePath: String, localDestPath: Path) -> Boolean,
): ByteArray? {
val ext = filename.substringAfterLast(".", "").lowercase()
if (ext in setOf("mp4", "mov", "3gp", "avi")) return null
if (ext in setOf("mp4", "mov", "3gp", "avi", "mkv", "m4v", "webm", "mpeg", "mpg")) return null
val cacheDir = cacheRoot.resolve(serial)
val cacheDir = cacheRoot.resolve(cacheKey.replace("/", "_").replace(":", "_"))
if (!cacheDir.exists()) cacheDir.createDirectories()
if (!tmpDir.exists()) tmpDir.createDirectories()
val cacheKey = (serial + devicePath).hashCode().toString(16)
val cachePath = cacheDir.resolve("$cacheKey.jpg")
val hash = (cacheKey + devicePath).hashCode().toString(16)
val cachePath = cacheDir.resolve("$hash.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 {
// Pull full file
val pull = ProcessBuilder(adbBin, "-s", serial, "pull", devicePath, tmpFile.toString())
.redirectErrorStream(true)
.start()
pull.inputStream.readBytes() // drain output
pull.waitFor()
val tmpFile = tmpDir.resolve("$hash.$ext")
if (!tmpFile.exists() || tmpFile.toFile().length() == 0L) return null
val success = pullFn(devicePath, tmpFile)
if (!success || !tmpFile.exists() || tmpFile.toFile().length() == 0L) return null
// Resize to 240px wide thumbnail via sips (macOS built-in)
// Resize to thumbnail via sips (macOS built-in)
val sips = ProcessBuilder(
"sips",
"--resampleWidth", "240",
tmpFile.toString(),
"--out", cachePath.toString()
)
.redirectErrorStream(true)
.start()
"sips", "--resampleWidth", "240",
tmpFile.toString(), "--out", cachePath.toString()
).redirectErrorStream(true).start()
sips.inputStream.readBytes()
sips.waitFor()
tmpFile.toFile().delete()
if (cachePath.exists() && cachePath.toFile().length() > 0) {
Files.readAllBytes(cachePath)
} else null
} catch (e: Exception) {
log.debug("Thumbnail generation failed for $filename: ${e.message}")
null
} finally {
tmpFile.toFile().delete()
}
}
fun clearForDevice(serial: String) {
cacheRoot.resolve(serial).toFile().deleteRecursively()
fun clearForKey(cacheKey: String) {
val sanitized = cacheKey.replace("/", "_").replace(":", "_")
cacheRoot.resolve(sanitized).toFile().deleteRecursively()
}
}