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")
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
|
||||||
) {
|
) {
|
||||||
Column(
|
// Thumbnail image or placeholder
|
||||||
|
if (thumbnail != null) {
|
||||||
|
androidx.compose.foundation.Image(
|
||||||
|
bitmap = thumbnail!!,
|
||||||
|
contentDescription = photo.filename,
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
modifier = Modifier.fillMaxSize(),
|
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,
|
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(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filename and date at bottom
|
|
||||||
Column {
|
|
||||||
Text(
|
Text(
|
||||||
photo.filename,
|
dateStr,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
maxLines = 1,
|
color = androidx.compose.ui.graphics.Color.White,
|
||||||
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
|
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(
|
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),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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