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