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 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) { override suspend fun deletePhoto(device: DeviceInfo, photo: PhonePhoto) {
val serial = requireSerial(device) val serial = requireSerial(device)
val output = runAdb(serial, "shell", "rm", photo.devicePath) 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]. * Pull a single photo from the device to [destinationDir].
* Returns the local path of the pulled file. * 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 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. * Delete a photo from the device.
* Only called after the user explicitly confirms deletion. * Only called after the user explicitly confirms deletion.

View File

@@ -121,6 +121,13 @@ class IosConnector(
return destFile 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) { override suspend fun deletePhoto(device: DeviceInfo, photo: PhonePhoto) {
val output = runHelper("delete", photo.devicePath) val output = runHelper("delete", photo.devicePath)
if (output.contains("Error")) { if (output.contains("Error")) {

View File

@@ -343,7 +343,7 @@ class AppState(private val configStore: ConfigStore) {
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Helpers // 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.ANDROID -> AndroidConnector(_config.value.adbPath ?: "adb")
DeviceType.IOS -> IosConnector(_config.value.python3Path ?: "python3") DeviceType.IOS -> IosConnector(_config.value.python3Path ?: "python3")
} }

View File

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

View File

@@ -1,7 +1,5 @@
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
@@ -10,101 +8,75 @@ import kotlin.io.path.createDirectories
import kotlin.io.path.exists 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 * Device-agnostic: caller provides a [pullFn] lambda that pulls a remote file
* `sips`, cache the thumbnail. Subsequent views are instant from cache. * 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 * Cache: ~/.photophetch/thumbcache/<cacheKey>/<hash>.jpg
* 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.
*/ */
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") 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. * 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. * 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( suspend fun get(
adbBin: String, cacheKey: String,
serial: String,
devicePath: String, devicePath: String,
filename: String, filename: String,
pullFn: suspend (devicePath: String, localDestPath: Path) -> Boolean,
): ByteArray? { ): ByteArray? {
val ext = filename.substringAfterLast(".", "").lowercase() 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 (!cacheDir.exists()) cacheDir.createDirectories()
if (!tmpDir.exists()) tmpDir.createDirectories() if (!tmpDir.exists()) tmpDir.createDirectories()
val cacheKey = (serial + devicePath).hashCode().toString(16) val hash = (cacheKey + devicePath).hashCode().toString(16)
val cachePath = cacheDir.resolve("$cacheKey.jpg") val cachePath = cacheDir.resolve("$hash.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 {
// Pull full file val tmpFile = tmpDir.resolve("$hash.$ext")
val pull = ProcessBuilder(adbBin, "-s", serial, "pull", devicePath, tmpFile.toString())
.redirectErrorStream(true)
.start()
pull.inputStream.readBytes() // drain output
pull.waitFor()
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( val sips = ProcessBuilder(
"sips", "sips", "--resampleWidth", "240",
"--resampleWidth", "240", tmpFile.toString(), "--out", cachePath.toString()
tmpFile.toString(), ).redirectErrorStream(true).start()
"--out", cachePath.toString()
)
.redirectErrorStream(true)
.start()
sips.inputStream.readBytes() sips.inputStream.readBytes()
sips.waitFor() sips.waitFor()
tmpFile.toFile().delete()
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 generation failed for $filename: ${e.message}") log.debug("Thumbnail generation failed for $filename: ${e.message}")
null null
} finally {
tmpFile.toFile().delete()
} }
} }
fun clearForDevice(serial: String) { fun clearForKey(cacheKey: String) {
cacheRoot.resolve(serial).toFile().deleteRecursively() val sanitized = cacheKey.replace("/", "_").replace(":", "_")
cacheRoot.resolve(sanitized).toFile().deleteRecursively()
} }
} }