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