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")
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
}
}

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.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<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(
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),
),
) {
Column(
// 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().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,
dateStr,
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
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),
)
Text(dateStr, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
// 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),
)
}
}

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()
}
}
}