EXIF verification at import time + click-to-preview on verify screen

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).
This commit is contained in:
Kyle Bolen
2026-07-30 05:14:08 +00:00
parent 347f182c30
commit 1d239fb60a
5 changed files with 202 additions and 38 deletions

View File

@@ -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,
)

View File

@@ -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
}
}
}

View File

@@ -430,7 +430,9 @@ 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 ->
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

View File

@@ -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,
)
}
}
}

View File

@@ -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<CopyResult?>(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<ImageBitmap?>(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")
}
}
}
}
}