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:
@@ -43,6 +43,14 @@ data class CopyResult(
|
|||||||
val destSha256: String,
|
val destSha256: String,
|
||||||
val verified: Boolean,
|
val verified: Boolean,
|
||||||
val convertedFromHeic: Boolean = false,
|
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,
|
val error: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -135,18 +135,15 @@ class Copier(
|
|||||||
val remoteHash = connector.remoteChecksum(device, photo)
|
val remoteHash = connector.remoteChecksum(device, photo)
|
||||||
|
|
||||||
val verified: Boolean
|
val verified: Boolean
|
||||||
val verificationNote: String?
|
|
||||||
|
|
||||||
when {
|
when {
|
||||||
wasConverted -> {
|
wasConverted -> {
|
||||||
// HEIC converted — can't compare hashes, check file is non-empty
|
// HEIC converted — can't compare hashes, check file is non-empty
|
||||||
verified = destSize > 0
|
verified = destSize > 0
|
||||||
verificationNote = if (verified) "Converted HEIC→JPEG" else null
|
|
||||||
}
|
}
|
||||||
remoteHash != null -> {
|
remoteHash != null -> {
|
||||||
// Full hash comparison
|
// Full hash comparison
|
||||||
verified = remoteHash.equals(destHash, ignoreCase = true)
|
verified = remoteHash.equals(destHash, ignoreCase = true)
|
||||||
verificationNote = null
|
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
// No remote hash — verify size matches what ADB reported
|
// No remote hash — verify size matches what ADB reported
|
||||||
@@ -154,7 +151,6 @@ class Copier(
|
|||||||
val sizeMatch = photo.sizeBytes == 0L || // unknown size is OK
|
val sizeMatch = photo.sizeBytes == 0L || // unknown size is OK
|
||||||
destSize == photo.sizeBytes
|
destSize == photo.sizeBytes
|
||||||
verified = destSize > 0 && sizeMatch
|
verified = destSize > 0 && sizeMatch
|
||||||
verificationNote = if (verified) "Size verified (${destSize / 1024} KB)" else null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,6 +161,11 @@ class Copier(
|
|||||||
destSha256 = destHash,
|
destSha256 = destHash,
|
||||||
verified = verified,
|
verified = verified,
|
||||||
convertedFromHeic = wasConverted,
|
convertedFromHeic = wasConverted,
|
||||||
|
exifVerified = readExifMake(finalFile)?.let { make ->
|
||||||
|
make.contains("apple", ignoreCase = true) ||
|
||||||
|
make.contains(photo.exifMake ?: "apple", ignoreCase = true)
|
||||||
|
},
|
||||||
|
exifMake = readExifMake(finalFile),
|
||||||
error = when {
|
error = when {
|
||||||
!verified && remoteHash != null -> "Hash mismatch — file may be corrupt"
|
!verified && remoteHash != null -> "Hash mismatch — file may be corrupt"
|
||||||
!verified -> "Copy failed — file missing or size mismatch"
|
!verified -> "Copy failed — file missing or size mismatch"
|
||||||
@@ -239,4 +240,25 @@ class Copier(
|
|||||||
}
|
}
|
||||||
return digest.digest().joinToString("") { "%02x".format(it) }
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -430,24 +430,26 @@ class AppState(private val configStore: ConfigStore) {
|
|||||||
val result = _importResult.value ?: return
|
val result = _importResult.value ?: return
|
||||||
scope.launch(Dispatchers.IO) {
|
scope.launch(Dispatchers.IO) {
|
||||||
val connector = connectorFor(device)
|
val connector = connectorFor(device)
|
||||||
result.results.filter { it.verified }.forEach { r ->
|
result.results
|
||||||
try {
|
.filter { it.verified && it.exifVerified != false } // skip exifVerified=false
|
||||||
connector.deletePhoto(device, r.photo)
|
.forEach { r ->
|
||||||
// Also delete companion MOV if this was a Live Photo still
|
try {
|
||||||
if (r.photo.isLivePhotoStill && r.photo.companionDevicePath != null) {
|
connector.deletePhoto(device, r.photo)
|
||||||
val companion = r.photo.copy(
|
// Also delete companion MOV if this was a Live Photo still
|
||||||
devicePath = r.photo.companionDevicePath,
|
if (r.photo.isLivePhotoStill && r.photo.companionDevicePath != null) {
|
||||||
filename = r.photo.companionDevicePath.substringAfterLast("/"),
|
val companion = r.photo.copy(
|
||||||
)
|
devicePath = r.photo.companionDevicePath,
|
||||||
try { connector.deletePhoto(device, companion) }
|
filename = r.photo.companionDevicePath.substringAfterLast("/"),
|
||||||
catch (e: DeviceException) {
|
)
|
||||||
log.warn("Could not delete companion for ${r.photo.filename}: ${e.message}")
|
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}")
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,18 @@ fun ConnectScreen(state: AppState) {
|
|||||||
StagingNotConfiguredBanner(onConfigureClick = { state.navigateTo(Screen.SETTINGS) })
|
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
|
// Device scan area
|
||||||
when (val scan = scanState) {
|
when (val scan = scanState) {
|
||||||
is DeviceScanState.Idle -> {
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import androidx.compose.foundation.layout.*
|
|||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.CheckCircle
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
import androidx.compose.material.icons.filled.Error
|
import androidx.compose.material.icons.filled.Error
|
||||||
import androidx.compose.material.icons.filled.Home
|
import androidx.compose.material.icons.filled.Home
|
||||||
|
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
|
||||||
@@ -15,9 +17,17 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.draw.drawWithContent
|
import androidx.compose.ui.draw.drawWithContent
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
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 androidx.compose.ui.window.Dialog
|
||||||
import com.bolenpad.photophetch.model.CopyResult
|
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).
|
* Shown during active import (IMPORTING screen).
|
||||||
@@ -62,9 +72,18 @@ fun VerifyScreen(state: AppState) {
|
|||||||
val importConfig by state.importConfig.collectAsState()
|
val importConfig by state.importConfig.collectAsState()
|
||||||
var deleteConfirmShown by remember { mutableStateOf(false) }
|
var deleteConfirmShown by remember { mutableStateOf(false) }
|
||||||
var deleteDone by remember { mutableStateOf(false) }
|
var deleteDone by remember { mutableStateOf(false) }
|
||||||
|
var previewResult by remember { mutableStateOf<CopyResult?>(null) }
|
||||||
|
|
||||||
val r = result ?: return
|
val r = result ?: return
|
||||||
|
|
||||||
|
// Preview dialog
|
||||||
|
previewResult?.let { pr ->
|
||||||
|
PreviewDialog(
|
||||||
|
result = pr,
|
||||||
|
onDismiss = { previewResult = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize().padding(24.dp)) {
|
Column(modifier = Modifier.fillMaxSize().padding(24.dp)) {
|
||||||
|
|
||||||
// Summary header
|
// Summary header
|
||||||
@@ -125,7 +144,7 @@ fun VerifyScreen(state: AppState) {
|
|||||||
contentPadding = PaddingValues(vertical = 4.dp),
|
contentPadding = PaddingValues(vertical = 4.dp),
|
||||||
) {
|
) {
|
||||||
items(r.results, key = { it.photo.devicePath }) { copyResult ->
|
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
|
@Composable
|
||||||
private fun CopyResultRow(result: CopyResult) {
|
private fun CopyResultRow(result: CopyResult, onPreview: (CopyResult) -> Unit) {
|
||||||
val hasRemoteHash = result.sourceSha256 != "(not available)"
|
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 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(
|
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),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(icon, contentDescription = null, tint = iconTint, modifier = Modifier.size(18.dp))
|
||||||
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),
|
|
||||||
)
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
val displayName = if (result.convertedFromHeic)
|
val displayName = if (result.convertedFromHeic)
|
||||||
"${result.photo.filename} → ${result.destinationPath.fileName}"
|
"${result.photo.filename} → ${result.destinationPath.fileName}"
|
||||||
@@ -255,16 +275,22 @@ private fun CopyResultRow(result: CopyResult) {
|
|||||||
Text(
|
Text(
|
||||||
when {
|
when {
|
||||||
isFailed -> result.error ?: "Failed"
|
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)}…"
|
else -> "Copied · ${result.photo.sizeBytes / 1024} KB · local SHA-256: ${result.destSha256.take(12)}…"
|
||||||
},
|
},
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = when {
|
color = when {
|
||||||
isFailed -> MaterialTheme.colorScheme.error
|
isFailed -> MaterialTheme.colorScheme.error
|
||||||
|
exifUnverified -> MaterialTheme.colorScheme.tertiary
|
||||||
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user