Fix folder structure and improve no-remote-hash verification

Copier: preserveFolderStructure now uses photo.sourceFolder directly
(always correct) instead of parsing devicePath, and applies regardless
of how many source folders are in the job.

Verification without remote hash: compare file size against ADB-reported
size (from ls -la) in addition to checking non-empty. Record local
SHA-256 so there's always a checksum on file even without a remote one.

VerifyScreen: show 'local SHA-256: abc123...' for copied-without-remote-
hash files instead of '(no remote checksum)'.
This commit is contained in:
Kyle Bolen
2026-07-28 05:44:10 +00:00
parent 3c59638759
commit 816d818625
2 changed files with 28 additions and 19 deletions

View File

@@ -63,14 +63,15 @@ class Copier(
photosToImport.forEachIndexed { idx, photo -> photosToImport.forEachIndexed { idx, photo ->
onProgress(idx, photosToImport.size, photo.filename) onProgress(idx, photosToImport.size, photo.filename)
// Determine destination directory — flat or preserve subfolder // Determine destination directory
val destDir = if (job.preserveFolderStructure) { val destDir = if (job.preserveFolderStructure) {
// devicePath is like /sdcard/DCIM/Camera/IMG_001.jpg // Always use sourceFolder regardless of how many folders are in the job
// Extract the subfolder name relative to DCIM root val subFolder = photo.sourceFolder.ifBlank {
val subFolder = photo.devicePath photo.devicePath
.substringAfter("/DCIM/", "") .substringAfter("/DCIM/", "")
.substringBeforeLast("/", "") .substringBeforeLast("/", "")
.ifBlank { "Camera" } .ifBlank { "Camera" }
}
stagingDir.resolve(subFolder).also { it.toFile().mkdirs() } stagingDir.resolve(subFolder).also { it.toFile().mkdirs() }
} else { } else {
stagingDir stagingDir
@@ -113,26 +114,34 @@ class Copier(
pulledFile to false pulledFile to false
} }
// Step 3: Hash the destination file // Step 3: Hash the destination file (always — gives us a local checksum on record)
val destHash = sha256(finalFile.inputStream()) val destHash = sha256(finalFile.inputStream())
val destSize = finalFile.toFile().length()
// Step 4: Get remote hash for cross-check (Android only; iOS returns null) // Step 4: Get remote hash for cross-check (Android only; iOS returns null)
val remoteHash = connector.remoteChecksum(device, photo) val remoteHash = connector.remoteChecksum(device, photo)
// Verification: if we have a remote hash, compare; otherwise mark verified val verified: Boolean
// (the copy itself is the verification for iOS since we have no remote hash) val verificationNote: String?
val verified = when {
when {
wasConverted -> { wasConverted -> {
// HEIC was converted — we can't compare against original hash. // HEIC converted — can't compare hashes, check file is non-empty
// We accept the file if it exists and is non-zero. verified = destSize > 0
finalFile.exists() && finalFile.toFile().length() > 0 verificationNote = if (verified) "Converted HEIC→JPEG" else null
} }
remoteHash != null -> { remoteHash != null -> {
remoteHash.equals(destHash, ignoreCase = true) // Full hash comparison
verified = remoteHash.equals(destHash, ignoreCase = true)
verificationNote = null
} }
else -> { else -> {
// No remote hash — verify that file is non-empty and readable // No remote hash — verify size matches what ADB reported
finalFile.exists() && finalFile.toFile().length() > 0 // (sizeBytes from ls -la, so we know what the device said)
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
} }
} }
@@ -145,7 +154,7 @@ class Copier(
convertedFromHeic = wasConverted, convertedFromHeic = wasConverted,
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 empty" !verified -> "Copy failed — file missing or size mismatch"
else -> null else -> null
}, },
) )

View File

@@ -256,7 +256,7 @@ private fun CopyResultRow(result: CopyResult) {
when { when {
isFailed -> result.error ?: "Failed" isFailed -> result.error ?: "Failed"
isVerifiedWithHash -> "SHA-256 verified · ${result.photo.sizeBytes / 1024} KB" isVerifiedWithHash -> "SHA-256 verified · ${result.photo.sizeBytes / 1024} KB"
else -> "Copied · ${result.photo.sizeBytes / 1024} KB (no remote checksum)" 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 {