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:
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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) },
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user