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

View File

@@ -256,7 +256,7 @@ private fun CopyResultRow(result: CopyResult) {
when {
isFailed -> result.error ?: "Failed"
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,
color = when {