From 770aa58dd0170132a1d427a13991dc11fe0c5211 Mon Sep 17 00:00:00 2001 From: Kyle Bolen Date: Tue, 28 Jul 2026 04:40:42 +0000 Subject: [PATCH] Fix listing speed + add thumbnails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AndroidConnector: replace per-file ls -la with single directory ls -la call — one ADB round trip per folder instead of one per file. Much faster for large Camera folders. BrowseScreen: add thumbnail loading via ThumbnailCache. Pulls first 64KB of each JPEG via adb exec-out dd (enough for embedded EXIF thumb), caches to ~/.photophetch/thumbcache//. Loads asynchronously on Dispatchers.IO so UI stays responsive. Placeholder icon shown until thumbnail arrives. --- .../photophetch/device/AndroidConnector.kt | 65 ++++++-------- .../bolenpad/photophetch/ui/BrowseScreen.kt | 84 ++++++++++++++----- .../photophetch/util/ThumbnailCache.kt | 77 +++++++++++++++++ 3 files changed, 165 insertions(+), 61 deletions(-) create mode 100644 src/main/kotlin/com/bolenpad/photophetch/util/ThumbnailCache.kt diff --git a/src/main/kotlin/com/bolenpad/photophetch/device/AndroidConnector.kt b/src/main/kotlin/com/bolenpad/photophetch/device/AndroidConnector.kt index e340bdc..5f59d7a 100644 --- a/src/main/kotlin/com/bolenpad/photophetch/device/AndroidConnector.kt +++ b/src/main/kotlin/com/bolenpad/photophetch/device/AndroidConnector.kt @@ -90,31 +90,29 @@ class AndroidConnector( val check = runAdb(serial, "shell", "ls", dir, "2>/dev/null") if (check.contains("No such file") || check.isBlank()) continue - // Use `find` to get filenames, then `ls -la` for size/date. - // Avoids relying on `stat` which has inconsistent flag support across - // Android versions (Samsung/toybox vs GNU coreutils). - val findOutput = runAdb( - serial, "shell", - "find", dir, "-maxdepth", "1", "-type", "f" - ) + // Single ls -la call for the whole directory — one ADB round trip per folder + // instead of one per file. Format varies slightly by Android version but is + // consistently: permissions links user group SIZE DATE TIME FILENAME + val lsOutput = runAdb(serial, "shell", "ls", "-la", dir) - for (line in findOutput.lines()) { - val path = line.trim() - if (path.isBlank() || path.contains("No such file")) continue + for (line in lsOutput.lines()) { + val trimmed = line.trim() + // Skip total line, blank lines, directories + if (trimmed.isBlank() || trimmed.startsWith("total") || trimmed.startsWith("d")) continue - val filename = path.substringAfterLast("/") + val parts = trimmed.split(Regex("\\s+"), limit = 8) + if (parts.size < 8) continue + + val filename = parts[7].trim() val ext = filename.substringAfterLast(".", "").lowercase() if (ext !in mediaExtensions) continue - // Get size and modification time via ls -la on the specific file - val lsLine = runAdb(serial, "shell", "ls", "-la", path).trim() - // ls -la format: -rw-rw---- 1 root everybody 3821234 2026-07-15 14:22 /sdcard/... - val sizeBytes = parseLsSize(lsLine) ?: 0L - val dateTaken = parseLsDate(lsLine) ?: java.time.Instant.EPOCH + val sizeBytes = parts[4].toLongOrNull() ?: 0L + val dateTaken = parseDate(parts[5], parts[6]) photos.add( PhonePhoto( - devicePath = path, + devicePath = "$dir/$filename", filename = filename, sizeBytes = sizeBytes, dateTaken = dateTaken, @@ -176,32 +174,21 @@ class AndroidConnector( device.adbSerial ?: throw DeviceException("No ADB serial for device: ${device.displayName}") /** - * Parse file size from `ls -la` output line. - * Format: -rw-rw---- 1 root everybody 3821234 2026-07-15 14:22 filename - * Size is the 5th whitespace-separated token. + * Parse date + time from ls -la tokens. + * Common formats: "2026-07-15" "14:22" or "2026-07-15" "14:22:01" */ - private fun parseLsSize(lsLine: String): Long? { - val parts = lsLine.trim().split(Regex("\\s+")) - // Size is at index 4 (0-based): permissions, links, user, group, SIZE, date, time, name - return parts.getOrNull(4)?.toLongOrNull() - } - - /** - * Parse modification date from `ls -la` output line. - * Date token is at index 5 (yyyy-MM-dd) and time at index 6 (HH:mm). - */ - private fun parseLsDate(lsLine: String): java.time.Instant? { + private fun parseDate(datePart: String, timePart: String): java.time.Instant { return try { - val parts = lsLine.trim().split(Regex("\\s+")) - val datePart = parts.getOrNull(5) ?: return null - val timePart = parts.getOrNull(6) ?: "00:00" - val ldt = java.time.LocalDateTime.parse( - "$datePart $timePart", + val timeClean = timePart.substringBefore(".") // strip sub-seconds if present + val formatter = if (timeClean.length == 5) { java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm") - ) - ldt.atZone(java.time.ZoneId.systemDefault()).toInstant() + } else { + java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") + } + java.time.LocalDateTime.parse("$datePart $timeClean", formatter) + .atZone(java.time.ZoneId.systemDefault()).toInstant() } catch (_: Exception) { - null + java.time.Instant.EPOCH } } diff --git a/src/main/kotlin/com/bolenpad/photophetch/ui/BrowseScreen.kt b/src/main/kotlin/com/bolenpad/photophetch/ui/BrowseScreen.kt index 49ddde4..315911e 100644 --- a/src/main/kotlin/com/bolenpad/photophetch/ui/BrowseScreen.kt +++ b/src/main/kotlin/com/bolenpad/photophetch/ui/BrowseScreen.kt @@ -11,18 +11,25 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.DateRange +import androidx.compose.material.icons.filled.Image import androidx.compose.material.icons.filled.RadioButtonUnchecked import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.toComposeImageBitmap +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp 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 import java.time.LocalDate import java.time.ZoneId import java.time.format.DateTimeFormatter @@ -101,6 +108,8 @@ fun BrowseScreen(state: AppState) { items(pl.filtered, key = { it.devicePath }) { photo -> PhotoCell( photo = photo, + device = device, + adbBin = state.config.collectAsState().value.adbPath ?: "adb", isSelected = photo in selected, onToggle = { state.togglePhotoSelection(photo) }, ) @@ -164,12 +173,29 @@ private fun BrowseTopBar( @Composable private fun PhotoCell( photo: PhonePhoto, + device: DeviceInfo?, + adbBin: String, isSelected: Boolean, onToggle: () -> Unit, ) { val date = photo.dateTaken.atZone(ZoneId.systemDefault()).toLocalDate() val dateStr = date.format(DateTimeFormatter.ofPattern("MMM d")) + // Load thumbnail asynchronously + var thumbnail by remember(photo.devicePath) { mutableStateOf(null) } + LaunchedEffect(photo.devicePath) { + if (device?.adbSerial != null) { + val bytes = withContext(Dispatchers.IO) { + ThumbnailCache.get(adbBin, device.adbSerial, photo.devicePath, photo.filename) + } + if (bytes != null) { + runCatching { + thumbnail = Image.makeFromEncoded(bytes).toComposeImageBitmap() + } + } + } + } + Box( modifier = Modifier .aspectRatio(1f) @@ -181,18 +207,34 @@ private fun PhotoCell( .then( if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary) else Modifier - ) - .padding(4.dp), + ), ) { + // Thumbnail image or placeholder + if (thumbnail != null) { + androidx.compose.foundation.Image( + bitmap = thumbnail!!, + contentDescription = photo.filename, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + } else { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Icon( + Icons.Default.Image, + contentDescription = null, + tint = MaterialTheme.colorScheme.outline, + modifier = Modifier.size(32.dp), + ) + } + } + + // Overlay: badges top-left, date bottom-left, checkmark top-right Column( - modifier = Modifier.fillMaxSize(), + modifier = Modifier.fillMaxSize().padding(4.dp), verticalArrangement = Arrangement.SpaceBetween, ) { - // Type badge Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) { - if (photo.isHeic) { - Badge { Text("HEIC", style = MaterialTheme.typography.labelSmall) } - } + if (photo.isHeic) Badge { Text("HEIC", style = MaterialTheme.typography.labelSmall) } if (photo.mediaType == MediaType.VIDEO) { Badge(containerColor = MaterialTheme.colorScheme.tertiary) { Text("VID", style = MaterialTheme.typography.labelSmall) @@ -204,25 +246,23 @@ private fun PhotoCell( } } } - - // Filename and date at bottom - Column { - Text( - photo.filename, - style = MaterialTheme.typography.labelSmall, - maxLines = 1, - overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, - ) - Text(dateStr, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) - } + Text( + dateStr, + style = MaterialTheme.typography.labelSmall, + color = androidx.compose.ui.graphics.Color.White, + modifier = Modifier.background( + androidx.compose.ui.graphics.Color.Black.copy(alpha = 0.4f), + shape = MaterialTheme.shapes.extraSmall, + ).padding(horizontal = 3.dp, vertical = 1.dp), + ) } - // Selection checkmark Icon( if (isSelected) Icons.Default.CheckCircle else Icons.Default.RadioButtonUnchecked, contentDescription = if (isSelected) "Selected" else "Not selected", - tint = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline, - modifier = Modifier.align(Alignment.TopEnd).size(20.dp), + tint = if (isSelected) MaterialTheme.colorScheme.primary + else androidx.compose.ui.graphics.Color.White, + modifier = Modifier.align(Alignment.TopEnd).padding(4.dp).size(20.dp), ) } } diff --git a/src/main/kotlin/com/bolenpad/photophetch/util/ThumbnailCache.kt b/src/main/kotlin/com/bolenpad/photophetch/util/ThumbnailCache.kt new file mode 100644 index 0000000..cf408fa --- /dev/null +++ b/src/main/kotlin/com/bolenpad/photophetch/util/ThumbnailCache.kt @@ -0,0 +1,77 @@ +package com.bolenpad.photophetch.util + +import org.slf4j.LoggerFactory +import java.io.ByteArrayOutputStream +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import kotlin.io.path.createDirectories +import kotlin.io.path.exists + +/** + * Pulls and caches small JPEG thumbnails from an Android device via ADB. + * + * Strategy: pull the first 64KB of each JPEG — enough to contain the + * embedded EXIF thumbnail (typically 10-15KB). The partial file is cached + * locally so repeated renders don't re-hit the device. + * + * Cache location: ~/.photophetch/thumbcache//.jpg + */ +object ThumbnailCache { + private val log = LoggerFactory.getLogger(ThumbnailCache::class.java) + private val cacheRoot: Path = Paths.get(System.getProperty("user.home"), ".photophetch", "thumbcache") + + /** + * Returns raw JPEG bytes for the thumbnail, pulling from device if not cached. + * Returns null if the pull failed or the file is a video. + */ + fun get( + adbBin: String, + serial: String, + devicePath: String, + filename: String, + ): ByteArray? { + val ext = filename.substringAfterLast(".", "").lowercase() + if (ext in setOf("mp4", "mov", "3gp", "avi")) return null // no thumb for video + + val cacheDir = cacheRoot.resolve(serial) + if (!cacheDir.exists()) cacheDir.createDirectories() + + val cacheKey = devicePath.hashCode().toString(16) + val cachePath = cacheDir.resolve("$cacheKey.jpg") + + if (cachePath.exists()) { + return try { Files.readAllBytes(cachePath) } catch (_: Exception) { null } + } + + return try { + // Pull first 65536 bytes (enough for EXIF thumbnail in most JPEGs) + val process = ProcessBuilder( + adbBin, "-s", serial, + "exec-out", + "dd", "if=$devicePath", "bs=65536", "count=1", "2>/dev/null" + ) + .redirectErrorStream(false) + .start() + + val bytes = process.inputStream.readBytes() + process.waitFor() + + if (bytes.isNotEmpty()) { + Files.write(cachePath, bytes) + bytes + } else null + } catch (e: Exception) { + log.debug("Thumbnail pull failed for $filename: ${e.message}") + null + } + } + + /** Clear the entire cache for a device (call after import session). */ + fun clearForDevice(serial: String) { + val cacheDir = cacheRoot.resolve(serial) + if (cacheDir.exists()) { + cacheDir.toFile().deleteRecursively() + } + } +}