Fix listing speed + add thumbnails

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/<serial>/. Loads asynchronously
on Dispatchers.IO so UI stays responsive. Placeholder icon shown until
thumbnail arrives.
This commit is contained in:
Kyle Bolen
2026-07-28 04:40:42 +00:00
parent ec114aa7ce
commit 770aa58dd0
3 changed files with 165 additions and 61 deletions

View File

@@ -90,31 +90,29 @@ class AndroidConnector(
val check = runAdb(serial, "shell", "ls", dir, "2>/dev/null") val check = runAdb(serial, "shell", "ls", dir, "2>/dev/null")
if (check.contains("No such file") || check.isBlank()) continue if (check.contains("No such file") || check.isBlank()) continue
// Use `find` to get filenames, then `ls -la` for size/date. // Single ls -la call for the whole directory — one ADB round trip per folder
// Avoids relying on `stat` which has inconsistent flag support across // instead of one per file. Format varies slightly by Android version but is
// Android versions (Samsung/toybox vs GNU coreutils). // consistently: permissions links user group SIZE DATE TIME FILENAME
val findOutput = runAdb( val lsOutput = runAdb(serial, "shell", "ls", "-la", dir)
serial, "shell",
"find", dir, "-maxdepth", "1", "-type", "f"
)
for (line in findOutput.lines()) { for (line in lsOutput.lines()) {
val path = line.trim() val trimmed = line.trim()
if (path.isBlank() || path.contains("No such file")) continue // 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() val ext = filename.substringAfterLast(".", "").lowercase()
if (ext !in mediaExtensions) continue if (ext !in mediaExtensions) continue
// Get size and modification time via ls -la on the specific file val sizeBytes = parts[4].toLongOrNull() ?: 0L
val lsLine = runAdb(serial, "shell", "ls", "-la", path).trim() val dateTaken = parseDate(parts[5], parts[6])
// 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
photos.add( photos.add(
PhonePhoto( PhonePhoto(
devicePath = path, devicePath = "$dir/$filename",
filename = filename, filename = filename,
sizeBytes = sizeBytes, sizeBytes = sizeBytes,
dateTaken = dateTaken, dateTaken = dateTaken,
@@ -176,32 +174,21 @@ class AndroidConnector(
device.adbSerial ?: throw DeviceException("No ADB serial for device: ${device.displayName}") device.adbSerial ?: throw DeviceException("No ADB serial for device: ${device.displayName}")
/** /**
* Parse file size from `ls -la` output line. * Parse date + time from ls -la tokens.
* Format: -rw-rw---- 1 root everybody 3821234 2026-07-15 14:22 filename * Common formats: "2026-07-15" "14:22" or "2026-07-15" "14:22:01"
* Size is the 5th whitespace-separated token.
*/ */
private fun parseLsSize(lsLine: String): Long? { private fun parseDate(datePart: String, timePart: String): java.time.Instant {
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? {
return try { return try {
val parts = lsLine.trim().split(Regex("\\s+")) val timeClean = timePart.substringBefore(".") // strip sub-seconds if present
val datePart = parts.getOrNull(5) ?: return null val formatter = if (timeClean.length == 5) {
val timePart = parts.getOrNull(6) ?: "00:00"
val ldt = java.time.LocalDateTime.parse(
"$datePart $timePart",
java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm") java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
) } else {
ldt.atZone(java.time.ZoneId.systemDefault()).toInstant() 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) { } catch (_: Exception) {
null java.time.Instant.EPOCH
} }
} }

View File

@@ -11,18 +11,25 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.DateRange 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.RadioButtonUnchecked
import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.bolenpad.photophetch.model.DeviceInfo 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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jetbrains.skia.Image
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
@@ -101,6 +108,8 @@ fun BrowseScreen(state: AppState) {
items(pl.filtered, key = { it.devicePath }) { photo -> items(pl.filtered, key = { it.devicePath }) { photo ->
PhotoCell( PhotoCell(
photo = photo, photo = photo,
device = device,
adbBin = state.config.collectAsState().value.adbPath ?: "adb",
isSelected = photo in selected, isSelected = photo in selected,
onToggle = { state.togglePhotoSelection(photo) }, onToggle = { state.togglePhotoSelection(photo) },
) )
@@ -164,12 +173,29 @@ private fun BrowseTopBar(
@Composable @Composable
private fun PhotoCell( private fun PhotoCell(
photo: PhonePhoto, photo: PhonePhoto,
device: DeviceInfo?,
adbBin: String,
isSelected: Boolean, isSelected: Boolean,
onToggle: () -> Unit, onToggle: () -> Unit,
) { ) {
val date = photo.dateTaken.atZone(ZoneId.systemDefault()).toLocalDate() val date = photo.dateTaken.atZone(ZoneId.systemDefault()).toLocalDate()
val dateStr = date.format(DateTimeFormatter.ofPattern("MMM d")) val dateStr = date.format(DateTimeFormatter.ofPattern("MMM d"))
// Load thumbnail asynchronously
var thumbnail by remember(photo.devicePath) { mutableStateOf<ImageBitmap?>(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( Box(
modifier = Modifier modifier = Modifier
.aspectRatio(1f) .aspectRatio(1f)
@@ -181,18 +207,34 @@ private fun PhotoCell(
.then( .then(
if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary) if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary)
else Modifier 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( Column(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize().padding(4.dp),
verticalArrangement = Arrangement.SpaceBetween, verticalArrangement = Arrangement.SpaceBetween,
) { ) {
// Type badge
Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) {
if (photo.isHeic) { if (photo.isHeic) Badge { Text("HEIC", style = MaterialTheme.typography.labelSmall) }
Badge { Text("HEIC", style = MaterialTheme.typography.labelSmall) }
}
if (photo.mediaType == MediaType.VIDEO) { if (photo.mediaType == MediaType.VIDEO) {
Badge(containerColor = MaterialTheme.colorScheme.tertiary) { Badge(containerColor = MaterialTheme.colorScheme.tertiary) {
Text("VID", style = MaterialTheme.typography.labelSmall) Text("VID", style = MaterialTheme.typography.labelSmall)
@@ -204,25 +246,23 @@ private fun PhotoCell(
} }
} }
} }
Text(
// Filename and date at bottom dateStr,
Column { style = MaterialTheme.typography.labelSmall,
Text( color = androidx.compose.ui.graphics.Color.White,
photo.filename, modifier = Modifier.background(
style = MaterialTheme.typography.labelSmall, androidx.compose.ui.graphics.Color.Black.copy(alpha = 0.4f),
maxLines = 1, shape = MaterialTheme.shapes.extraSmall,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, ).padding(horizontal = 3.dp, vertical = 1.dp),
) )
Text(dateStr, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
} }
// Selection checkmark
Icon( Icon(
if (isSelected) Icons.Default.CheckCircle else Icons.Default.RadioButtonUnchecked, if (isSelected) Icons.Default.CheckCircle else Icons.Default.RadioButtonUnchecked,
contentDescription = if (isSelected) "Selected" else "Not selected", contentDescription = if (isSelected) "Selected" else "Not selected",
tint = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline, tint = if (isSelected) MaterialTheme.colorScheme.primary
modifier = Modifier.align(Alignment.TopEnd).size(20.dp), else androidx.compose.ui.graphics.Color.White,
modifier = Modifier.align(Alignment.TopEnd).padding(4.dp).size(20.dp),
) )
} }
} }

View File

@@ -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/<serial>/<filename_hash>.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()
}
}
}