From 1d239fb60a74cc3582c96bc4e1c0bc33ae461eb6 Mon Sep 17 00:00:00 2001 From: Kyle Bolen Date: Thu, 30 Jul 2026 05:14:08 +0000 Subject: [PATCH] EXIF verification at import time + click-to-preview on verify screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copier: run exiftool -Make -s3 on each pulled file after copy. - exifVerified=true: Make contains expected camera make - exifVerified=false: Make absent or unexpected (not deleted from phone) - exifVerified=null: exiftool not installed CopyResult: add exifVerified + exifMake fields. deleteVerifiedFromDevice: skip files where exifVerified=false. VerifyScreen: - Green row: EXIF verified camera original - Yellow row: EXIF unverified — shows Make value, warns not deleted - Red row: copy failed - Click any row: PreviewDialog shows full image + EXIF status + path ConnectScreen: show warning banner if exiftool not installed, with install command (brew install exiftool). --- .../bolenpad/photophetch/model/ImportJob.kt | 8 ++ .../bolenpad/photophetch/transfer/Copier.kt | 30 +++- .../com/bolenpad/photophetch/ui/AppState.kt | 32 +++-- .../bolenpad/photophetch/ui/ConnectScreen.kt | 34 +++++ .../bolenpad/photophetch/ui/VerifyScreen.kt | 136 +++++++++++++++--- 5 files changed, 202 insertions(+), 38 deletions(-) diff --git a/src/main/kotlin/com/bolenpad/photophetch/model/ImportJob.kt b/src/main/kotlin/com/bolenpad/photophetch/model/ImportJob.kt index c6a69b0..dccef04 100644 --- a/src/main/kotlin/com/bolenpad/photophetch/model/ImportJob.kt +++ b/src/main/kotlin/com/bolenpad/photophetch/model/ImportJob.kt @@ -43,6 +43,14 @@ data class CopyResult( val destSha256: String, val verified: Boolean, val convertedFromHeic: Boolean = false, + /** + * True if exiftool confirmed Make=Apple (or expected camera make) on the pulled file. + * False means EXIF was missing or Make was unexpected — file may not be a camera original. + * Null means exiftool was not available or not run. + */ + val exifVerified: Boolean? = null, + /** The Make value exiftool found, e.g. "Apple", "samsung", or null if absent */ + val exifMake: String? = null, val error: String? = null, ) diff --git a/src/main/kotlin/com/bolenpad/photophetch/transfer/Copier.kt b/src/main/kotlin/com/bolenpad/photophetch/transfer/Copier.kt index ed3674e..ef65771 100644 --- a/src/main/kotlin/com/bolenpad/photophetch/transfer/Copier.kt +++ b/src/main/kotlin/com/bolenpad/photophetch/transfer/Copier.kt @@ -135,18 +135,15 @@ class Copier( val remoteHash = connector.remoteChecksum(device, photo) val verified: Boolean - val verificationNote: String? when { wasConverted -> { // HEIC converted — can't compare hashes, check file is non-empty verified = destSize > 0 - verificationNote = if (verified) "Converted HEIC→JPEG" else null } remoteHash != null -> { // Full hash comparison verified = remoteHash.equals(destHash, ignoreCase = true) - verificationNote = null } else -> { // No remote hash — verify size matches what ADB reported @@ -154,7 +151,6 @@ class Copier( val sizeMatch = photo.sizeBytes == 0L || // unknown size is OK destSize == photo.sizeBytes verified = destSize > 0 && sizeMatch - verificationNote = if (verified) "Size verified (${destSize / 1024} KB)" else null } } @@ -165,6 +161,11 @@ class Copier( destSha256 = destHash, verified = verified, convertedFromHeic = wasConverted, + exifVerified = readExifMake(finalFile)?.let { make -> + make.contains("apple", ignoreCase = true) || + make.contains(photo.exifMake ?: "apple", ignoreCase = true) + }, + exifMake = readExifMake(finalFile), error = when { !verified && remoteHash != null -> "Hash mismatch — file may be corrupt" !verified -> "Copy failed — file missing or size mismatch" @@ -239,4 +240,25 @@ class Copier( } return digest.digest().joinToString("") { "%02x".format(it) } } + + /** + * Read the EXIF Make tag from a local file using exiftool. + * Returns the Make value (e.g. "Apple"), empty string if Make tag absent, + * or null if exiftool is not installed or fails. + */ + private fun readExifMake(file: Path): String? { + return try { + val process = ProcessBuilder("exiftool", "-Make", "-s3", file.absolutePathString()) + .redirectErrorStream(true) + .start() + val output = process.inputStream.bufferedReader().readText().trim() + process.waitFor() + // -s3 gives bare value with no label. Empty output = tag absent. + output.ifEmpty { "" } // empty string = no Make tag (not null = exiftool ran fine) + } catch (e: Exception) { + // exiftool not installed or not on PATH + log.debug("exiftool not available: ${e.message}") + null + } + } } diff --git a/src/main/kotlin/com/bolenpad/photophetch/ui/AppState.kt b/src/main/kotlin/com/bolenpad/photophetch/ui/AppState.kt index 0bdec60..ae22ccb 100644 --- a/src/main/kotlin/com/bolenpad/photophetch/ui/AppState.kt +++ b/src/main/kotlin/com/bolenpad/photophetch/ui/AppState.kt @@ -430,24 +430,26 @@ class AppState(private val configStore: ConfigStore) { val result = _importResult.value ?: return scope.launch(Dispatchers.IO) { val connector = connectorFor(device) - result.results.filter { it.verified }.forEach { r -> - try { - connector.deletePhoto(device, r.photo) - // Also delete companion MOV if this was a Live Photo still - if (r.photo.isLivePhotoStill && r.photo.companionDevicePath != null) { - val companion = r.photo.copy( - devicePath = r.photo.companionDevicePath, - filename = r.photo.companionDevicePath.substringAfterLast("/"), - ) - try { connector.deletePhoto(device, companion) } - catch (e: DeviceException) { - log.warn("Could not delete companion for ${r.photo.filename}: ${e.message}") + result.results + .filter { it.verified && it.exifVerified != false } // skip exifVerified=false + .forEach { r -> + try { + connector.deletePhoto(device, r.photo) + // Also delete companion MOV if this was a Live Photo still + if (r.photo.isLivePhotoStill && r.photo.companionDevicePath != null) { + val companion = r.photo.copy( + devicePath = r.photo.companionDevicePath, + filename = r.photo.companionDevicePath.substringAfterLast("/"), + ) + try { connector.deletePhoto(device, companion) } + catch (e: DeviceException) { + log.warn("Could not delete companion for ${r.photo.filename}: ${e.message}") + } } + } catch (e: DeviceException) { + log.error("Delete failed for ${r.photo.filename}: ${e.message}") } - } catch (e: DeviceException) { - log.error("Delete failed for ${r.photo.filename}: ${e.message}") } - } } } diff --git a/src/main/kotlin/com/bolenpad/photophetch/ui/ConnectScreen.kt b/src/main/kotlin/com/bolenpad/photophetch/ui/ConnectScreen.kt index 62f9643..658d7e1 100644 --- a/src/main/kotlin/com/bolenpad/photophetch/ui/ConnectScreen.kt +++ b/src/main/kotlin/com/bolenpad/photophetch/ui/ConnectScreen.kt @@ -46,6 +46,18 @@ fun ConnectScreen(state: AppState) { StagingNotConfiguredBanner(onConfigureClick = { state.navigateTo(Screen.SETTINGS) }) } + // exiftool warning — checked once per session + val exiftoolMissing = remember { + try { + val p = ProcessBuilder("exiftool", "-ver").redirectErrorStream(true).start() + p.inputStream.readBytes() + p.waitFor() != 0 + } catch (_: Exception) { true } + } + if (exiftoolMissing) { + ExiftoolMissingBanner() + } + // Device scan area when (val scan = scanState) { is DeviceScanState.Idle -> { @@ -200,3 +212,25 @@ private fun StagingNotConfiguredBanner(onConfigureClick: () -> Unit) { } } } + +@Composable +private fun ExiftoolMissingBanner() { + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.tertiaryContainer), + modifier = Modifier.width(400.dp), + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text( + "exiftool not installed", + style = MaterialTheme.typography.bodySmall, + fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onTertiaryContainer, + ) + Text( + "EXIF verification will be skipped during import. Install with: brew install exiftool", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onTertiaryContainer, + ) + } + } +} diff --git a/src/main/kotlin/com/bolenpad/photophetch/ui/VerifyScreen.kt b/src/main/kotlin/com/bolenpad/photophetch/ui/VerifyScreen.kt index b020ba5..3f0e733 100644 --- a/src/main/kotlin/com/bolenpad/photophetch/ui/VerifyScreen.kt +++ b/src/main/kotlin/com/bolenpad/photophetch/ui/VerifyScreen.kt @@ -4,10 +4,12 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.clickable import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Error import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -15,9 +17,17 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.graphics.Brush 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 androidx.compose.ui.window.Dialog import com.bolenpad.photophetch.model.CopyResult +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.jetbrains.skia.Image as SkiaImage +import java.nio.file.Files /** * Shown during active import (IMPORTING screen). @@ -62,9 +72,18 @@ fun VerifyScreen(state: AppState) { val importConfig by state.importConfig.collectAsState() var deleteConfirmShown by remember { mutableStateOf(false) } var deleteDone by remember { mutableStateOf(false) } + var previewResult by remember { mutableStateOf(null) } val r = result ?: return + // Preview dialog + previewResult?.let { pr -> + PreviewDialog( + result = pr, + onDismiss = { previewResult = null }, + ) + } + Column(modifier = Modifier.fillMaxSize().padding(24.dp)) { // Summary header @@ -125,7 +144,7 @@ fun VerifyScreen(state: AppState) { contentPadding = PaddingValues(vertical = 4.dp), ) { items(r.results, key = { it.photo.devicePath }) { copyResult -> - CopyResultRow(copyResult) + CopyResultRow(copyResult, onPreview = { previewResult = it }) } } @@ -223,30 +242,31 @@ private fun ResultSummaryHeader(success: Int, failure: Int, destination: String) } @Composable -private fun CopyResultRow(result: CopyResult) { +private fun CopyResultRow(result: CopyResult, onPreview: (CopyResult) -> Unit) { val hasRemoteHash = result.sourceSha256 != "(not available)" - val isVerifiedWithHash = result.verified && result.error == null && hasRemoteHash - val isVerifiedNoHash = result.verified && result.error == null && !hasRemoteHash val isFailed = result.error != null || !result.verified + val exifUnverified = result.exifVerified == false + val exifUnavailable = result.exifVerified == null + + // Color: red=failed, yellow=unverified EXIF, green=fully verified + val iconTint = when { + isFailed -> MaterialTheme.colorScheme.error + exifUnverified -> MaterialTheme.colorScheme.tertiary // yellow-ish + else -> MaterialTheme.colorScheme.primary + } + val icon = when { + isFailed -> Icons.Default.Error + exifUnverified -> Icons.Default.Warning + else -> Icons.Default.CheckCircle + } Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp) + .clickable { onPreview(result) }, // click to preview horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { - Icon( - when { - isFailed -> Icons.Default.Error - else -> Icons.Default.CheckCircle - }, - contentDescription = null, - tint = when { - isFailed -> MaterialTheme.colorScheme.error - isVerifiedWithHash -> MaterialTheme.colorScheme.primary - else -> MaterialTheme.colorScheme.secondary // verified, no hash - }, - modifier = Modifier.size(18.dp), - ) + Icon(icon, contentDescription = null, tint = iconTint, modifier = Modifier.size(18.dp)) Column(modifier = Modifier.weight(1f)) { val displayName = if (result.convertedFromHeic) "${result.photo.filename} → ${result.destinationPath.fileName}" @@ -255,16 +275,22 @@ private fun CopyResultRow(result: CopyResult) { Text( when { isFailed -> result.error ?: "Failed" - isVerifiedWithHash -> "SHA-256 verified · ${result.photo.sizeBytes / 1024} KB" + exifUnverified -> "⚠ EXIF Make: '${result.exifMake?.ifEmpty { "(absent)" } ?: "(absent)"}' — not deleted from phone" + exifUnavailable -> "Copied · ${result.photo.sizeBytes / 1024} KB · local SHA-256: ${result.destSha256.take(12)}…" + hasRemoteHash -> "SHA-256 verified · ${result.photo.sizeBytes / 1024} KB" else -> "Copied · ${result.photo.sizeBytes / 1024} KB · local SHA-256: ${result.destSha256.take(12)}…" }, style = MaterialTheme.typography.labelSmall, color = when { isFailed -> MaterialTheme.colorScheme.error + exifUnverified -> MaterialTheme.colorScheme.tertiary else -> MaterialTheme.colorScheme.onSurfaceVariant }, ) } + // Tap hint + Text("👁", style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant) } } @@ -295,3 +321,75 @@ private fun DeleteConfirmCard(count: Int, onConfirm: () -> Unit, onCancel: () -> } } } + +@Composable +private fun PreviewDialog(result: CopyResult, onDismiss: () -> Unit) { + var bitmap by remember(result.destinationPath) { mutableStateOf(null) } + + // Load from destination file (already on disk after import) + LaunchedEffect(result.destinationPath) { + val bytes = withContext(Dispatchers.IO) { + try { Files.readAllBytes(result.destinationPath) } catch (_: Exception) { null } + } + if (bytes != null) { + runCatching { + // Use sips to get a reasonable preview size if file is large + bitmap = SkiaImage.makeFromEncoded(bytes).toComposeImageBitmap() + } + } + } + + Dialog(onDismissRequest = onDismiss) { + Card(modifier = Modifier.fillMaxWidth(0.9f)) { + Column(modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp)) { + + // Filename + EXIF status + Text(result.photo.filename, fontWeight = FontWeight.SemiBold) + Text( + when (result.exifVerified) { + true -> "✓ EXIF Make: ${result.exifMake}" + false -> "⚠ EXIF Make: '${result.exifMake?.ifEmpty { "(absent)" } ?: "(absent)"}' — not a confirmed camera original" + null -> "EXIF not checked (exiftool not installed)" + }, + style = MaterialTheme.typography.bodySmall, + color = when (result.exifVerified) { + true -> MaterialTheme.colorScheme.primary + false -> MaterialTheme.colorScheme.tertiary + null -> MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + + HorizontalDivider() + + // Preview image + Box( + modifier = Modifier.fillMaxWidth().aspectRatio(4f / 3f), + contentAlignment = Alignment.Center, + ) { + if (bitmap != null) { + androidx.compose.foundation.Image( + bitmap = bitmap!!, + contentDescription = result.photo.filename, + contentScale = ContentScale.Fit, + modifier = Modifier.fillMaxSize(), + ) + } else { + CircularProgressIndicator() + } + } + + // File info + Text( + "${result.photo.sizeBytes / 1024} KB · ${result.destinationPath}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Button(onClick = onDismiss, modifier = Modifier.fillMaxWidth()) { + Text("Close") + } + } + } + } +}