Initial commit: PhotoPhetch v1.0.0

Kotlin/Compose Multiplatform desktop app for importing photos from
phones into PhotoPhile staging directory.

Features:
- Android support via ADB subprocess
- iPhone support via gphoto2 subprocess
- Smart filtering: excludes screenshots, received images, Live Photo MOVs
- HEIC -> JPEG conversion via macOS sips (built-in)
- SHA-256 verification of every copied file
- Safe delete with explicit confirmation
- Staging path convention: _staging/{date}_{device}/
- Config persistence at ~/.photophetch/config.json
This commit is contained in:
Kyle Bolen
2026-07-28 03:04:05 +00:00
commit 145b7bc955
24 changed files with 2623 additions and 0 deletions

View File

@@ -0,0 +1,23 @@
package com.bolenpad.photophetch
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.WindowState
import androidx.compose.ui.window.application
import com.bolenpad.photophetch.ui.App
import com.bolenpad.photophetch.ui.AppState
import com.bolenpad.photophetch.util.ConfigStore
fun main() = application {
val configStore = remember { ConfigStore() }
val appState = remember { AppState(configStore) }
Window(
onCloseRequest = ::exitApplication,
title = "PhotoPhetch",
state = WindowState(size = DpSize(900.dp, 700.dp)),
) {
App(appState)
}
}

View File

@@ -0,0 +1,184 @@
package com.bolenpad.photophetch.device
import com.bolenpad.photophetch.model.DeviceInfo
import com.bolenpad.photophetch.model.DeviceType
import com.bolenpad.photophetch.model.MediaType
import com.bolenpad.photophetch.model.PhonePhoto
import org.slf4j.LoggerFactory
import java.nio.file.Path
import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import kotlin.io.path.absolutePathString
/**
* DeviceConnector implementation for Android phones via ADB.
*
* Requires `adb` on PATH (or override via [adbBin]).
* Install via: brew install android-platform-tools
*/
class AndroidConnector(
private val adbBin: String = "adb",
) : DeviceConnector {
private val log = LoggerFactory.getLogger(AndroidConnector::class.java)
// Directories on Android that contain camera-roll originals
private val cameraDirs = listOf(
"/sdcard/DCIM/Camera",
"/sdcard/DCIM/100ANDRO",
"/sdcard/Pictures/Camera",
)
// Photo/video extensions to include
private val mediaExtensions = setOf(
"jpg", "jpeg", "png", "dng", "raw", // photos
"mp4", "mov", "3gp", "avi", // videos
)
override suspend fun detectDevices(): List<DeviceInfo> {
val output = runAdb(null, "devices", "-l")
// Parse lines like: "emulator-5554 device product:sdk_gphone..."
// or: "R3CT107GHBR device usb:1-1.4 product:beyond2..."
return output.lines()
.drop(1) // skip "List of devices attached" header
.filter { it.contains("device") && !it.startsWith("*") }
.mapNotNull { line ->
val parts = line.trim().split(Regex("\\s+"))
if (parts.size < 2 || parts[1] != "device") return@mapNotNull null
val serial = parts[0]
val model = parts.firstOrNull { it.startsWith("model:") }
?.removePrefix("model:") ?: "Android Device"
val product = parts.firstOrNull { it.startsWith("product:") }
?.removePrefix("product:")
DeviceInfo(
displayName = product?.replace("_", " ")?.replaceFirstChar { it.uppercase() }
?: model.replace("_", " "),
type = DeviceType.ANDROID,
adbSerial = serial,
)
}
}
override suspend fun listPhotos(device: DeviceInfo): List<PhonePhoto> {
val serial = requireSerial(device)
val photos = mutableListOf<PhonePhoto>()
for (dir in cameraDirs) {
// Check if directory exists first
val check = runAdb(serial, "shell", "ls", dir, "2>/dev/null")
if (check.contains("No such file") || check.isBlank()) continue
// List files with metadata: size, date, time, name
// `stat` format: size=%s mtime=%Y path=%n
val statOutput = runAdb(
serial, "shell",
"find", dir, "-maxdepth", "1", "-type", "f",
"-exec", "stat", "-c", "%s %Y %n", "{}", ";"
)
for (line in statOutput.lines()) {
val trimmed = line.trim()
if (trimmed.isBlank()) continue
val parts = trimmed.split(" ", limit = 3)
if (parts.size < 3) continue
val sizeBytes = parts[0].toLongOrNull() ?: continue
val epochSecs = parts[1].toLongOrNull() ?: continue
val path = parts[2]
val filename = path.substringAfterLast("/")
val ext = filename.substringAfterLast(".", "").lowercase()
if (ext !in mediaExtensions) continue
photos.add(
PhonePhoto(
devicePath = path,
filename = filename,
sizeBytes = sizeBytes,
dateTaken = Instant.ofEpochSecond(epochSecs),
// Android stat doesn't give EXIF; we infer Make/Model from
// the device itself rather than per-file EXIF
exifMake = device.displayName,
exifModel = device.displayName,
mediaType = if (ext in setOf("mp4", "mov", "3gp", "avi"))
MediaType.VIDEO else MediaType.PHOTO,
)
)
}
}
return photos.sortedByDescending { it.dateTaken }
}
override suspend fun pullPhoto(device: DeviceInfo, photo: PhonePhoto, destinationDir: Path): Path {
val serial = requireSerial(device)
val destFile = destinationDir.resolve(photo.filename)
val output = runAdb(serial, "pull", photo.devicePath, destFile.absolutePathString())
if (!output.contains("pulled") && !output.contains("1 file")) {
throw DeviceException("adb pull failed for ${photo.filename}: $output")
}
log.info("Pulled ${photo.filename}${destFile.absolutePathString()}")
return destFile
}
override suspend fun deletePhoto(device: DeviceInfo, photo: PhonePhoto) {
val serial = requireSerial(device)
val output = runAdb(serial, "shell", "rm", photo.devicePath)
if (output.contains("No such file") || output.contains("Permission denied")) {
throw DeviceException("Failed to delete ${photo.filename}: $output")
}
log.info("Deleted ${photo.filename} from device")
}
override suspend fun remoteChecksum(device: DeviceInfo, photo: PhonePhoto): String? {
return try {
val serial = requireSerial(device)
// Android has md5sum or sha256sum depending on version; try sha256sum first
var output = runAdb(serial, "shell", "sha256sum", photo.devicePath)
if (output.contains("not found") || output.isBlank()) {
output = runAdb(serial, "shell", "md5sum", photo.devicePath)
// md5sum isn't SHA-256 — return null so Copier uses its own verification
return null
}
// sha256sum output: "<hash> <path>"
output.trim().split(Regex("\\s+")).firstOrNull()
} catch (e: Exception) {
log.warn("Remote checksum failed for ${photo.filename}: ${e.message}")
null
}
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private fun requireSerial(device: DeviceInfo): String =
device.adbSerial ?: throw DeviceException("No ADB serial for device: ${device.displayName}")
private fun runAdb(serial: String?, vararg args: String): String {
val cmd = buildList {
add(adbBin)
if (serial != null) {
add("-s")
add(serial)
}
addAll(args)
}
log.debug("Running: ${cmd.joinToString(" ")}")
val process = ProcessBuilder(cmd)
.redirectErrorStream(true)
.start()
val output = process.inputStream.bufferedReader().readText()
val exitCode = process.waitFor()
if (exitCode != 0 && !output.contains("No such file")) {
log.warn("adb exited $exitCode: $output")
}
return output
}
}

View File

@@ -0,0 +1,48 @@
package com.bolenpad.photophetch.device
import com.bolenpad.photophetch.model.DeviceInfo
import com.bolenpad.photophetch.model.PhonePhoto
import java.nio.file.Path
/**
* Abstraction over Android (ADB) and iOS (gphoto2) device connectivity.
* All implementations run I/O on whatever dispatcher the caller provides —
* callers should invoke from Dispatchers.IO.
*/
interface DeviceConnector {
/**
* Detect all connected devices of this type.
* Returns an empty list (not an error) when nothing is connected.
*/
suspend fun detectDevices(): List<DeviceInfo>
/**
* List all photos/videos on the given device.
* Raw listing — no filtering applied here; filtering happens in ExifFilter.
*/
suspend fun listPhotos(device: DeviceInfo): List<PhonePhoto>
/**
* Pull a single photo from the device to [destinationDir].
* Returns the local path of the pulled file.
* @throws DeviceException on adb/gphoto2 failure
*/
suspend fun pullPhoto(device: DeviceInfo, photo: PhonePhoto, destinationDir: Path): Path
/**
* Delete a photo from the device.
* Only called after the user explicitly confirms deletion.
* @throws DeviceException on failure
*/
suspend fun deletePhoto(device: DeviceInfo, photo: PhonePhoto)
/**
* Compute SHA-256 of the photo *on the device* without pulling it.
* Returns null if the device doesn't support on-device hashing
* (in that case, the Copier will hash the local copy against the pulled file).
*/
suspend fun remoteChecksum(device: DeviceInfo, photo: PhonePhoto): String?
}
class DeviceException(message: String, cause: Throwable? = null) : Exception(message, cause)

View File

@@ -0,0 +1,244 @@
package com.bolenpad.photophetch.device
import com.bolenpad.photophetch.model.DeviceInfo
import com.bolenpad.photophetch.model.DeviceType
import com.bolenpad.photophetch.model.MediaType
import com.bolenpad.photophetch.model.PhonePhoto
import org.slf4j.LoggerFactory
import java.nio.file.Path
import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import kotlin.io.path.absolutePathString
/**
* DeviceConnector implementation for iPhones via gphoto2.
*
* Requires `gphoto2` on PATH (or override via [gphoto2Bin]).
* Install via: brew install gphoto2
*
* Important macOS note: The built-in Image Capture daemon (PTPCamera) will
* conflict with gphoto2. If listing returns no files, run:
* killall PTPCamera
* PhotoPhetch shows instructions for this when no photos are found on a
* connected iPhone.
*
* Live Photo handling: gphoto2 lists both the .HEIC and the companion .MOV
* with matching base names. We detect the pair by comparing filenames and
* flag [PhonePhoto.isLivePhotoStill] / [PhonePhoto.isLivePhotoMotion].
* The MOV companion is excluded from import by default.
*/
class IosConnector(
private val gphoto2Bin: String = "gphoto2",
) : DeviceConnector {
private val log = LoggerFactory.getLogger(IosConnector::class.java)
// gphoto2 date format in --list-files output
private val gphotoDateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
override suspend fun detectDevices(): List<DeviceInfo> {
// gphoto2 --auto-detect lists connected cameras/phones
val output = runGphoto2("--auto-detect")
/*
* Output format:
* Model Port
* ----------------------------------------------------------
* Apple iPhone usb:001,023
*/
val devices = mutableListOf<DeviceInfo>()
var parsingStarted = false
for (line in output.lines()) {
if (line.startsWith("---")) { parsingStarted = true; continue }
if (!parsingStarted || line.isBlank()) continue
// Split on 2+ spaces to separate model from port
val parts = line.trim().split(Regex("\\s{2,}"), limit = 2)
if (parts.size < 2) continue
val model = parts[0].trim()
val port = parts[1].trim()
if (model.contains("iPhone", ignoreCase = true) ||
model.contains("iPad", ignoreCase = true) ||
model.contains("Apple", ignoreCase = true)
) {
devices.add(
DeviceInfo(
displayName = model,
type = DeviceType.IOS,
gphotoPort = port,
)
)
}
}
return devices
}
override suspend fun listPhotos(device: DeviceInfo): List<PhonePhoto> {
val port = requirePort(device)
// gphoto2 --list-files gives index, name, size, date for each file in DCIM
val output = runGphoto2("--port", port, "--list-files")
/*
* Output format (each file):
* #1 IMG_0001.HEIC 5906 KB 2026-07-15 14:22:01
* #2 IMG_0001.MOV 12344 KB 2026-07-15 14:22:01
* #3 IMG_0002.JPG 3021 KB 2026-07-15 14:23:45
*/
val rawPhotos = mutableListOf<PhonePhoto>()
for (line in output.lines()) {
if (!line.trimStart().startsWith("#")) continue
val photo = parseGphoto2FileLine(line) ?: continue
rawPhotos.add(photo)
}
// Detect Live Photo pairs: HEIC + MOV with the same base name and close timestamps
return tagLivePhotoPairs(rawPhotos)
}
override suspend fun pullPhoto(device: DeviceInfo, photo: PhonePhoto, destinationDir: Path): Path {
val port = requirePort(device)
// gphoto2 uses 1-based file index; devicePath stores the index as "#N"
val fileIndex = photo.devicePath.removePrefix("#")
val destFile = destinationDir.resolve(photo.filename)
// --get-file downloads to current dir; we set working dir to destinationDir
val output = runGphoto2(
workingDir = destinationDir,
"--port", port,
"--get-file", fileIndex,
"--filename", photo.filename,
"--force-overwrite",
)
if (output.contains("Error") && !output.contains("Saving file as")) {
throw DeviceException("gphoto2 pull failed for ${photo.filename}: $output")
}
log.info("Pulled ${photo.filename}${destFile.absolutePathString()}")
return destFile
}
override suspend fun deletePhoto(device: DeviceInfo, photo: PhonePhoto) {
val port = requirePort(device)
val fileIndex = photo.devicePath.removePrefix("#")
val output = runGphoto2("--port", port, "--delete-file", fileIndex)
if (output.contains("Error")) {
throw DeviceException("Failed to delete ${photo.filename}: $output")
}
log.info("Deleted ${photo.filename} from device")
}
/**
* gphoto2 doesn't provide a remote hash command, so we return null.
* The Copier will verify by comparing the local copy against the pulled bytes.
*/
override suspend fun remoteChecksum(device: DeviceInfo, photo: PhonePhoto): String? = null
// -------------------------------------------------------------------------
// Internal helpers
// -------------------------------------------------------------------------
/**
* Parse a single line from `gphoto2 --list-files`.
* Example: "#1 IMG_0001.HEIC 5906 KB 2026-07-15 14:22:01"
*/
private fun parseGphoto2FileLine(line: String): PhonePhoto? {
return try {
val trimmed = line.trim()
val indexEnd = trimmed.indexOf(' ')
val index = trimmed.substring(0, indexEnd).trimStart('#')
// Split remainder on multiple spaces
val rest = trimmed.substring(indexEnd).trim().split(Regex("\\s{2,}"))
if (rest.size < 3) return null
val filename = rest[0].trim()
// rest[1] = "5906 KB" or "5906 kB"
val sizeStr = rest[1].trim().split(" ").firstOrNull() ?: "0"
val sizeKb = sizeStr.toLongOrNull() ?: 0L
// rest[2] could be "2026-07-15 14:22:01" (split might be further)
val datePart = rest.drop(2).joinToString(" ").trim()
val dateTaken = try {
LocalDateTime.parse(datePart, gphotoDateFormatter)
.atZone(ZoneId.systemDefault()).toInstant()
} catch (_: Exception) {
Instant.EPOCH
}
val ext = filename.substringAfterLast(".", "").lowercase()
val mediaType = when (ext) {
"mp4", "mov", "3gp", "avi" -> MediaType.VIDEO
"jpg", "jpeg", "heic", "heif", "png", "dng" -> MediaType.PHOTO
else -> MediaType.UNKNOWN
}
PhonePhoto(
devicePath = "#$index",
filename = filename,
sizeBytes = sizeKb * 1024,
dateTaken = dateTaken,
// iPhones always have camera Make/Model EXIF — we set it from device name
// since gphoto2 --list-files doesn't expose EXIF inline
exifMake = "Apple",
exifModel = null, // populated later if needed
mediaType = mediaType,
)
} catch (e: Exception) {
log.debug("Could not parse gphoto2 line: $line${e.message}")
null
}
}
/**
* Detect Live Photo pairs by matching HEIC + MOV files with the same base name
* (e.g. IMG_0001.HEIC + IMG_0001.MOV). Tags both sides appropriately.
*/
private fun tagLivePhotoPairs(photos: List<PhonePhoto>): List<PhonePhoto> {
// Build a map from base name (no extension) → list of photos
val byBaseName = photos.groupBy { it.filename.substringBeforeLast(".", it.filename) }
return photos.map { photo ->
val baseName = photo.filename.substringBeforeLast(".", photo.filename)
val siblings = byBaseName[baseName] ?: return@map photo
val hasHeic = siblings.any {
it.filename.endsWith(".HEIC", ignoreCase = true) ||
it.filename.endsWith(".HEIF", ignoreCase = true)
}
val hasMov = siblings.any {
it.filename.endsWith(".MOV", ignoreCase = true)
}
if (!hasHeic || !hasMov) return@map photo
val ext = photo.filename.substringAfterLast(".", "").lowercase()
when (ext) {
"heic", "heif" -> photo.copy(isLivePhotoStill = true)
"mov" -> photo.copy(isLivePhotoMotion = true)
else -> photo
}
}
}
private fun requirePort(device: DeviceInfo): String =
device.gphotoPort ?: throw DeviceException("No gphoto2 port for device: ${device.displayName}")
private fun runGphoto2(vararg args: String, workingDir: Path? = null): String {
val cmd = buildList {
add(gphoto2Bin)
addAll(args)
}
log.debug("Running: ${cmd.joinToString(" ")}")
val builder = ProcessBuilder(cmd).redirectErrorStream(true)
if (workingDir != null) builder.directory(workingDir.toFile())
val process = builder.start()
val output = process.inputStream.bufferedReader().readText()
process.waitFor()
return output
}
}

View File

@@ -0,0 +1,26 @@
package com.bolenpad.photophetch.model
import kotlinx.serialization.Serializable
/**
* Persisted user preferences, stored at ~/.photophetch/config.json.
*/
@Serializable
data class AppConfig(
/** Absolute path to the PhotoPhile staging directory on the mounted NAS */
val stagingRoot: String = "",
/** Default folder name template. Supports {date} and {device} tokens. */
val folderNameTemplate: String = "{date}_{device}",
/** Default: convert HEIC to JPEG on import */
val defaultConvertHeicToJpeg: Boolean = true,
/** Default: strip Live Photo .MOV companions */
val defaultStripLivePhotoMotion: Boolean = true,
/** Default: do not delete from phone (safer default) */
val defaultDeleteAfterVerify: Boolean = false,
/** Whether the user has been warned about HEIC phone settings */
val heicWarningDismissed: Boolean = false,
/** Path to adb binary, or null to use PATH */
val adbPath: String? = null,
/** Path to gphoto2 binary, or null to use PATH */
val gphoto2Path: String? = null,
)

View File

@@ -0,0 +1,24 @@
package com.bolenpad.photophetch.model
/**
* Describes a connected phone device.
*/
data class DeviceInfo(
/** Human-readable name (e.g. "Kevin's iPhone 15 Pro") */
val displayName: String,
/** Device type */
val type: DeviceType,
/** ADB serial number (Android only) */
val adbSerial: String? = null,
/** gphoto2 port identifier (iOS only, e.g. "usb:001,004") */
val gphotoPort: String? = null,
/** OS version string */
val osVersion: String? = null,
/** Storage available in bytes, if queryable */
val availableStorageBytes: Long? = null,
)
enum class DeviceType {
ANDROID,
IOS,
}

View File

@@ -0,0 +1,48 @@
package com.bolenpad.photophetch.model
import java.nio.file.Path
import java.time.Instant
/**
* Represents a batch of photos selected for import and the destination configuration.
*/
data class ImportJob(
/** Photos selected for import */
val selectedPhotos: List<PhonePhoto>,
/** Absolute path to the staging root (e.g. /Volumes/delphi/_staging) */
val stagingRoot: Path,
/** Folder name within staging (e.g. "2026-07-28_iPhone15Pro") */
val folderName: String,
/** Whether to convert HEIC files to JPEG during copy */
val convertHeicToJpeg: Boolean = true,
/** Whether to strip Live Photo motion (.MOV) companions */
val stripLivePhotoMotion: Boolean = true,
/** Whether to delete photos from the phone after successful verification */
val deleteAfterVerify: Boolean = false,
)
/**
* Tracks the outcome of copying a single photo.
*/
data class CopyResult(
val photo: PhonePhoto,
val destinationPath: Path,
val sourceSha256: String,
val destSha256: String,
val verified: Boolean,
val convertedFromHeic: Boolean = false,
val error: String? = null,
)
/**
* Overall import run result.
*/
data class ImportResult(
val job: ImportJob,
val results: List<CopyResult>,
val startedAt: Instant,
val completedAt: Instant,
) {
val successCount: Int get() = results.count { it.verified && it.error == null }
val failureCount: Int get() = results.count { it.error != null || !it.verified }
}

View File

@@ -0,0 +1,36 @@
package com.bolenpad.photophetch.model
import java.time.Instant
/**
* Represents a single photo or video on the connected phone.
*/
data class PhonePhoto(
/** Device-side path or gphoto2 handle (e.g. "/DCIM/100APPLE/IMG_1234.HEIC") */
val devicePath: String,
/** Filename without directory */
val filename: String,
/** File size in bytes */
val sizeBytes: Long,
/** Date taken from EXIF DateTimeOriginal, or file modified date as fallback */
val dateTaken: Instant,
/** EXIF camera make (e.g. "Apple"). Null if absent — used for screenshot detection. */
val exifMake: String?,
/** EXIF camera model (e.g. "iPhone 15 Pro"). Null if absent. */
val exifModel: String?,
/** Media type */
val mediaType: MediaType,
/** True if this is the still component of an Apple Live Photo (paired with a .MOV) */
val isLivePhotoStill: Boolean = false,
/** True if this is the motion component of an Apple Live Photo (.MOV companion) */
val isLivePhotoMotion: Boolean = false,
/** True if the format is HEIC — user may want to convert to JPEG */
val isHeic: Boolean = filename.endsWith(".HEIC", ignoreCase = true) ||
filename.endsWith(".HEIF", ignoreCase = true),
)
enum class MediaType {
PHOTO,
VIDEO,
UNKNOWN,
}

View File

@@ -0,0 +1,204 @@
package com.bolenpad.photophetch.transfer
import com.bolenpad.photophetch.device.DeviceConnector
import com.bolenpad.photophetch.model.CopyResult
import com.bolenpad.photophetch.model.DeviceInfo
import com.bolenpad.photophetch.model.ImportJob
import com.bolenpad.photophetch.model.ImportResult
import com.bolenpad.photophetch.model.PhonePhoto
import org.slf4j.LoggerFactory
import java.io.InputStream
import java.nio.file.Path
import java.security.MessageDigest
import java.time.Instant
import kotlin.io.path.absolutePathString
import kotlin.io.path.deleteIfExists
import kotlin.io.path.exists
import kotlin.io.path.inputStream
/**
* Copies photos from a phone to the staging directory with SHA-256 verification.
*
* For each photo:
* 1. Pull from device to a temp file in the staging dir
* 2. Optionally convert HEIC → JPEG via macOS `sips` (built-in, no install needed)
* 3. Compute SHA-256 of the final destination file
* 4. Cross-check against device hash if available (Android ADB sha256sum)
* 5. Report success or failure
*
* Conversion note on HEIC stills:
* The .HEIC file IS the shutter-moment photo — sips preserves all EXIF metadata.
* The converted JPEG is visually identical to what you'd see in Photos.app.
*/
class Copier(
private val connector: DeviceConnector,
private val device: DeviceInfo,
) {
private val log = LoggerFactory.getLogger(Copier::class.java)
/**
* Execute a full import job.
* Call from Dispatchers.IO. Progress is reported via [onProgress].
*
* @param onProgress Called after each file with (completed, total, currentFile)
*/
suspend fun run(
job: ImportJob,
onProgress: (completed: Int, total: Int, currentFile: String) -> Unit = { _, _, _ -> },
): ImportResult {
val startedAt = Instant.now()
val results = mutableListOf<CopyResult>()
val stagingDir = job.stagingRoot.resolve(job.folderName)
// Validate destination is writable
if (!stagingDir.exists()) {
stagingDir.toFile().mkdirs()
}
val photosToImport = if (job.stripLivePhotoMotion) {
job.selectedPhotos.filter { !it.isLivePhotoMotion }
} else {
job.selectedPhotos
}
photosToImport.forEachIndexed { idx, photo ->
onProgress(idx, photosToImport.size, photo.filename)
val result = copyOne(photo, stagingDir, job.convertHeicToJpeg)
results.add(result)
if (result.error != null) {
log.error("Failed to copy ${photo.filename}: ${result.error}")
}
}
onProgress(photosToImport.size, photosToImport.size, "")
return ImportResult(
job = job,
results = results,
startedAt = startedAt,
completedAt = Instant.now(),
)
}
/**
* Copy and verify a single photo.
*/
private suspend fun copyOne(
photo: PhonePhoto,
stagingDir: Path,
convertHeic: Boolean,
): CopyResult {
var pulledFile: Path? = null
return try {
// Step 1: Pull from device to staging dir
pulledFile = connector.pullPhoto(device, photo, stagingDir)
// Step 2: Optionally convert HEIC → JPEG
val (finalFile, wasConverted) = if (convertHeic && photo.isHeic) {
val jpegFile = convertHeicToJpeg(pulledFile)
pulledFile.deleteIfExists() // remove original .HEIC after conversion
jpegFile to true
} else {
pulledFile to false
}
// Step 3: Hash the destination file
val destHash = sha256(finalFile.inputStream())
// 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 {
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
}
remoteHash != null -> {
remoteHash.equals(destHash, ignoreCase = true)
}
else -> {
// No remote hash — verify that file is non-empty and readable
finalFile.exists() && finalFile.toFile().length() > 0
}
}
CopyResult(
photo = photo,
destinationPath = finalFile,
sourceSha256 = remoteHash ?: "(not available)",
destSha256 = destHash,
verified = verified,
convertedFromHeic = wasConverted,
error = if (!verified) "Hash mismatch — file may be corrupt" else null,
)
} catch (e: Exception) {
pulledFile?.deleteIfExists()
CopyResult(
photo = photo,
destinationPath = stagingDir.resolve(photo.filename),
sourceSha256 = "(error)",
destSha256 = "(error)",
verified = false,
error = e.message ?: e.javaClass.simpleName,
)
}
}
/**
* Convert a HEIC file to JPEG using macOS `sips` (built-in, always available on macOS).
*
* sips preserves all EXIF metadata including DateTimeOriginal, GPS, Make, Model.
* The HEIC is the shutter-moment still — the JPEG output is visually lossless
* (HEIC is typically higher quality than JPEG at the same file size, so the
* conversion to JPEG adds mild compression but no data is lost that matters).
*
* @return Path to the new .jpg file (same directory, same base name)
*/
private fun convertHeicToJpeg(heicFile: Path): Path {
val jpegName = heicFile.fileName.toString()
.replaceFirst(Regex("\\.(heic|heif)$", RegexOption.IGNORE_CASE), ".jpg")
val jpegFile = heicFile.parent.resolve(jpegName)
log.info("Converting ${heicFile.fileName}$jpegName")
val process = ProcessBuilder(
"sips",
"--setProperty", "format", "jpeg",
"--setProperty", "formatOptions", "high",
heicFile.absolutePathString(),
"--out", jpegFile.absolutePathString(),
)
.redirectErrorStream(true)
.start()
val output = process.inputStream.bufferedReader().readText()
val exitCode = process.waitFor()
if (exitCode != 0) {
throw RuntimeException("sips conversion failed (exit $exitCode): $output")
}
if (!jpegFile.exists()) {
throw RuntimeException("sips ran but output file not found: ${jpegFile.absolutePathString()}")
}
return jpegFile
}
/**
* Compute SHA-256 hex string from an [InputStream].
* Closes the stream when done.
*/
private fun sha256(stream: InputStream): String {
val digest = MessageDigest.getInstance("SHA-256")
stream.use { input ->
val buffer = ByteArray(64 * 1024)
var read: Int
while (input.read(buffer).also { read = it } != -1) {
digest.update(buffer, 0, read)
}
}
return digest.digest().joinToString("") { "%02x".format(it) }
}
}

View File

@@ -0,0 +1,32 @@
package com.bolenpad.photophetch.ui
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
@Composable
fun App(state: AppState) {
MaterialTheme(
colorScheme = lightColorScheme(),
) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
val screen by state.currentScreen.collectAsState()
when (screen) {
Screen.CONNECT -> ConnectScreen(state)
Screen.BROWSE -> BrowseScreen(state)
Screen.IMPORT_CONFIG -> ImportScreen(state)
Screen.IMPORTING -> ImportingScreen(state)
Screen.VERIFY -> VerifyScreen(state)
Screen.SETTINGS -> SettingsScreen(state)
}
}
}
}

View File

@@ -0,0 +1,315 @@
package com.bolenpad.photophetch.ui
import com.bolenpad.photophetch.device.AndroidConnector
import com.bolenpad.photophetch.device.DeviceConnector
import com.bolenpad.photophetch.device.DeviceException
import com.bolenpad.photophetch.device.IosConnector
import com.bolenpad.photophetch.model.AppConfig
import com.bolenpad.photophetch.model.CopyResult
import com.bolenpad.photophetch.model.DeviceInfo
import com.bolenpad.photophetch.model.DeviceType
import com.bolenpad.photophetch.model.ImportJob
import com.bolenpad.photophetch.model.ImportResult
import com.bolenpad.photophetch.model.PhonePhoto
import com.bolenpad.photophetch.transfer.Copier
import com.bolenpad.photophetch.util.ConfigStore
import com.bolenpad.photophetch.util.ExifFilter
import com.bolenpad.photophetch.util.StagingPath
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.slf4j.LoggerFactory
import java.nio.file.Path
import java.nio.file.Paths
import java.time.LocalDate
/** Top-level navigation destinations */
enum class Screen {
CONNECT,
BROWSE,
IMPORT_CONFIG,
IMPORTING,
VERIFY,
SETTINGS,
}
/** Sealed state for the device scan */
sealed class DeviceScanState {
object Idle : DeviceScanState()
object Scanning : DeviceScanState()
data class Found(val devices: List<DeviceInfo>) : DeviceScanState()
data class Error(val message: String) : DeviceScanState()
}
/** Sealed state for the photo listing */
sealed class PhotoListState {
object Idle : PhotoListState()
object Loading : PhotoListState()
data class Loaded(
val all: List<PhonePhoto>,
val filtered: List<PhonePhoto>,
val shootingInHeic: Boolean,
) : PhotoListState()
data class Error(val message: String) : PhotoListState()
}
/** Progress during active import */
data class ImportProgress(
val completed: Int,
val total: Int,
val currentFile: String,
)
/**
* Application state container — the single source of truth for all UI state.
* All mutations happen via public methods; UI observes via StateFlows.
*/
class AppState(
private val configStore: ConfigStore,
) {
private val log = LoggerFactory.getLogger(AppState::class.java)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
// -------------------------------------------------------------------------
// Persisted config
// -------------------------------------------------------------------------
private val _config = MutableStateFlow(configStore.load())
val config: StateFlow<AppConfig> = _config.asStateFlow()
// -------------------------------------------------------------------------
// Navigation
// -------------------------------------------------------------------------
private val _currentScreen = MutableStateFlow(Screen.CONNECT)
val currentScreen: StateFlow<Screen> = _currentScreen.asStateFlow()
fun navigateTo(screen: Screen) { _currentScreen.value = screen }
// -------------------------------------------------------------------------
// Device scanning
// -------------------------------------------------------------------------
private val _deviceScan = MutableStateFlow<DeviceScanState>(DeviceScanState.Idle)
val deviceScan: StateFlow<DeviceScanState> = _deviceScan.asStateFlow()
private val _selectedDevice = MutableStateFlow<DeviceInfo?>(null)
val selectedDevice: StateFlow<DeviceInfo?> = _selectedDevice.asStateFlow()
fun scanForDevices() {
scope.launch {
_deviceScan.value = DeviceScanState.Scanning
try {
val devices = withContext(Dispatchers.IO) {
val android = AndroidConnector(_config.value.adbPath ?: "adb")
val ios = IosConnector(_config.value.gphoto2Path ?: "gphoto2")
android.detectDevices() + ios.detectDevices()
}
_deviceScan.value = if (devices.isEmpty()) {
DeviceScanState.Found(emptyList())
} else {
DeviceScanState.Found(devices)
}
} catch (e: Exception) {
log.error("Device scan failed", e)
_deviceScan.value = DeviceScanState.Error(e.message ?: "Unknown error")
}
}
}
fun selectDevice(device: DeviceInfo) {
_selectedDevice.value = device
loadPhotos(device)
navigateTo(Screen.BROWSE)
}
// -------------------------------------------------------------------------
// Photo listing
// -------------------------------------------------------------------------
private val _photoList = MutableStateFlow<PhotoListState>(PhotoListState.Idle)
val photoList: StateFlow<PhotoListState> = _photoList.asStateFlow()
private val _selectedPhotos = MutableStateFlow<Set<PhonePhoto>>(emptySet())
val selectedPhotos: StateFlow<Set<PhonePhoto>> = _selectedPhotos.asStateFlow()
private val _dateFilter = MutableStateFlow<Pair<LocalDate?, LocalDate?>>(null to null)
val dateFilter: StateFlow<Pair<LocalDate?, LocalDate?>> = _dateFilter.asStateFlow()
fun loadPhotos(device: DeviceInfo) {
scope.launch {
_photoList.value = PhotoListState.Loading
try {
val connector = connectorFor(device)
val raw = withContext(Dispatchers.IO) { connector.listPhotos(device) }
val filtered = ExifFilter.filter(raw, _config.value.defaultStripLivePhotoMotion)
val shootingInHeic = ExifFilter.isShootingInHeic(filtered)
_photoList.value = PhotoListState.Loaded(
all = raw,
filtered = filtered,
shootingInHeic = shootingInHeic,
)
} catch (e: Exception) {
log.error("Photo listing failed", e)
_photoList.value = PhotoListState.Error(e.message ?: "Unknown error")
}
}
}
fun setDateFilter(from: LocalDate?, to: LocalDate?) {
_dateFilter.value = from to to
// Re-apply filter over already-loaded photos
val current = _photoList.value as? PhotoListState.Loaded ?: return
val filtered = ExifFilter.filter(current.all, _config.value.defaultStripLivePhotoMotion)
.let { applyDateFilter(it, from, to) }
_photoList.value = current.copy(filtered = filtered)
}
fun togglePhotoSelection(photo: PhonePhoto) {
_selectedPhotos.update { current ->
if (photo in current) current - photo else current + photo
}
}
fun selectAll() {
val photos = (_photoList.value as? PhotoListState.Loaded)?.filtered ?: return
_selectedPhotos.value = photos.toSet()
}
fun selectNone() {
_selectedPhotos.value = emptySet()
}
private fun applyDateFilter(
photos: List<PhonePhoto>,
from: LocalDate?,
to: LocalDate?,
): List<PhonePhoto> {
if (from == null && to == null) return photos
return photos.filter { photo ->
val date = photo.dateTaken.atZone(java.time.ZoneId.systemDefault()).toLocalDate()
(from == null || !date.isBefore(from)) && (to == null || !date.isAfter(to))
}
}
// -------------------------------------------------------------------------
// Import configuration
// -------------------------------------------------------------------------
private val _importConfig = MutableStateFlow(
ImportConfig(
convertHeicToJpeg = true,
stripLivePhotoMotion = true,
deleteAfterVerify = false,
folderName = "",
)
)
val importConfig: StateFlow<ImportConfig> = _importConfig.asStateFlow()
data class ImportConfig(
val convertHeicToJpeg: Boolean,
val stripLivePhotoMotion: Boolean,
val deleteAfterVerify: Boolean,
val folderName: String,
)
fun updateImportConfig(update: ImportConfig.() -> ImportConfig) {
_importConfig.update { it.update() }
}
fun prepareImport() {
val device = _selectedDevice.value ?: return
val today = LocalDate.now()
val folderName = _config.value.folderNameTemplate
.replace("{date}", today.toString())
.replace("{device}", StagingPath.deviceLabel(device))
_importConfig.update { it.copy(folderName = folderName) }
navigateTo(Screen.IMPORT_CONFIG)
}
// -------------------------------------------------------------------------
// Importing
// -------------------------------------------------------------------------
private val _importProgress = MutableStateFlow<ImportProgress?>(null)
val importProgress: StateFlow<ImportProgress?> = _importProgress.asStateFlow()
private val _importResult = MutableStateFlow<ImportResult?>(null)
val importResult: StateFlow<ImportResult?> = _importResult.asStateFlow()
fun startImport() {
val device = _selectedDevice.value ?: return
val cfg = _config.value
val importCfg = _importConfig.value
val staging = cfg.stagingRoot.takeIf { it.isNotBlank() } ?: return
val stagingPath: Path = Paths.get(staging)
val job = ImportJob(
selectedPhotos = _selectedPhotos.value.toList(),
stagingRoot = stagingPath,
folderName = importCfg.folderName,
convertHeicToJpeg = importCfg.convertHeicToJpeg,
stripLivePhotoMotion = importCfg.stripLivePhotoMotion,
deleteAfterVerify = importCfg.deleteAfterVerify,
)
navigateTo(Screen.IMPORTING)
scope.launch {
val connector = connectorFor(device)
val copier = Copier(connector, device)
val result = withContext(Dispatchers.IO) {
copier.run(job) { completed, total, currentFile ->
_importProgress.value = ImportProgress(completed, total, currentFile)
}
}
_importResult.value = result
navigateTo(Screen.VERIFY)
}
}
// -------------------------------------------------------------------------
// Post-import deletion
// -------------------------------------------------------------------------
fun deleteVerifiedFromDevice() {
val device = _selectedDevice.value ?: return
val result = _importResult.value ?: return
val toDelete = result.results.filter { it.verified }.map { it.photo }
scope.launch(Dispatchers.IO) {
val connector = connectorFor(device)
toDelete.forEach { photo ->
try {
connector.deletePhoto(device, photo)
} catch (e: DeviceException) {
log.error("Delete failed for ${photo.filename}: ${e.message}")
}
}
}
}
// -------------------------------------------------------------------------
// Settings
// -------------------------------------------------------------------------
fun updateStagingRoot(path: String) {
_config.update { it.copy(stagingRoot = path) }
saveConfig()
}
fun dismissHeicWarning() {
_config.update { it.copy(heicWarningDismissed = true) }
saveConfig()
}
fun saveConfig() {
configStore.save(_config.value)
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private fun connectorFor(device: DeviceInfo): DeviceConnector = when (device.type) {
DeviceType.ANDROID -> AndroidConnector(_config.value.adbPath ?: "adb")
DeviceType.IOS -> IosConnector(_config.value.gphoto2Path ?: "gphoto2")
}
}

View File

@@ -0,0 +1,278 @@
package com.bolenpad.photophetch.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.DateRange
import androidx.compose.material.icons.filled.RadioButtonUnchecked
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.bolenpad.photophetch.model.DeviceInfo
import com.bolenpad.photophetch.model.MediaType
import com.bolenpad.photophetch.model.PhonePhoto
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@Composable
fun BrowseScreen(state: AppState) {
val photoList by state.photoList.collectAsState()
val selected by state.selectedPhotos.collectAsState()
val device by state.selectedDevice.collectAsState()
val config by state.config.collectAsState()
val dateFilter by state.dateFilter.collectAsState()
Column(modifier = Modifier.fillMaxSize()) {
// Top bar
BrowseTopBar(
device = device,
selected = selected,
onBack = { state.navigateTo(Screen.CONNECT) },
onSelectAll = { state.selectAll() },
onSelectNone = { state.selectNone() },
onImport = { state.prepareImport() },
)
when (val pl = photoList) {
is PhotoListState.Idle, is PhotoListState.Loading -> {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator()
Spacer(Modifier.height(8.dp))
Text("Loading photos…")
}
}
}
is PhotoListState.Loaded -> {
// HEIC warning banner
if (pl.shootingInHeic && !config.heicWarningDismissed) {
HeicWarningBanner(
convertByDefault = config.defaultConvertHeicToJpeg,
onDismiss = { state.dismissHeicWarning() },
)
}
// Date filter row
DateFilterRow(
from = dateFilter.first,
to = dateFilter.second,
onFilterChange = { from, to -> state.setDateFilter(from, to) },
)
// Photo count summary
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
"${pl.filtered.size} photos • ${selected.size} selected",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (pl.filtered.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("No photos match the current filter", color = MaterialTheme.colorScheme.onSurfaceVariant)
}
} else {
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 110.dp),
modifier = Modifier.fillMaxSize().padding(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
items(pl.filtered, key = { it.devicePath }) { photo ->
PhotoCell(
photo = photo,
isSelected = photo in selected,
onToggle = { state.togglePhotoSelection(photo) },
)
}
}
}
}
is PhotoListState.Error -> {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Error loading photos", color = MaterialTheme.colorScheme.error)
Text(pl.message, style = MaterialTheme.typography.bodySmall)
Spacer(Modifier.height(8.dp))
Button(onClick = { device?.let { state.loadPhotos(it) } }) { Text("Retry") }
}
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun BrowseTopBar(
device: DeviceInfo?,
selected: Set<PhonePhoto>,
onBack: () -> Unit,
onSelectAll: () -> Unit,
onSelectNone: () -> Unit,
onImport: () -> Unit,
) {
TopAppBar(
title = { Text(device?.displayName ?: "Browse Photos") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
},
actions = {
TextButton(onClick = onSelectAll) { Text("All") }
TextButton(onClick = onSelectNone) { Text("None") }
Button(
onClick = onImport,
enabled = selected.isNotEmpty(),
modifier = Modifier.padding(end = 8.dp),
) {
Text("Import ${if (selected.isNotEmpty()) "(${selected.size})" else ""}")
}
},
)
}
@Composable
private fun PhotoCell(
photo: PhonePhoto,
isSelected: Boolean,
onToggle: () -> Unit,
) {
val date = photo.dateTaken.atZone(ZoneId.systemDefault()).toLocalDate()
val dateStr = date.format(DateTimeFormatter.ofPattern("MMM d"))
Box(
modifier = Modifier
.aspectRatio(1f)
.clickable(onClick = onToggle)
.background(
if (isSelected) MaterialTheme.colorScheme.primaryContainer
else MaterialTheme.colorScheme.surfaceVariant
)
.then(
if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary)
else Modifier
)
.padding(4.dp),
) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.SpaceBetween,
) {
// Type badge
Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) {
if (photo.isHeic) {
Badge { Text("HEIC", style = MaterialTheme.typography.labelSmall) }
}
if (photo.mediaType == MediaType.VIDEO) {
Badge(containerColor = MaterialTheme.colorScheme.tertiary) {
Text("VID", style = MaterialTheme.typography.labelSmall)
}
}
if (photo.isLivePhotoStill) {
Badge(containerColor = MaterialTheme.colorScheme.secondary) {
Text("LIVE", style = MaterialTheme.typography.labelSmall)
}
}
}
// Filename and date at bottom
Column {
Text(
photo.filename,
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
)
Text(dateStr, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
// Selection checkmark
Icon(
if (isSelected) Icons.Default.CheckCircle else Icons.Default.RadioButtonUnchecked,
contentDescription = if (isSelected) "Selected" else "Not selected",
tint = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline,
modifier = Modifier.align(Alignment.TopEnd).size(20.dp),
)
}
}
@Composable
private fun DateFilterRow(
from: LocalDate?,
to: LocalDate?,
onFilterChange: (from: LocalDate?, to: LocalDate?) -> Unit,
) {
// Simple quick-filter buttons: Last 7 days, Last 30 days, All
val today = LocalDate.now()
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(Icons.Default.DateRange, contentDescription = null, modifier = Modifier.size(16.dp))
Text("Filter:", style = MaterialTheme.typography.labelMedium)
FilterChip(
selected = from == null && to == null,
onClick = { onFilterChange(null, null) },
label = { Text("All") },
)
FilterChip(
selected = from == today.minusDays(7) && to == null,
onClick = { onFilterChange(today.minusDays(7), null) },
label = { Text("Last 7 days") },
)
FilterChip(
selected = from == today.minusDays(30) && to == null,
onClick = { onFilterChange(today.minusDays(30), null) },
label = { Text("Last 30 days") },
)
}
}
@Composable
private fun HeicWarningBanner(convertByDefault: Boolean, onDismiss: () -> Unit) {
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.tertiaryContainer),
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
) {
Row(
modifier = Modifier.padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(Icons.Default.Warning, contentDescription = null, modifier = Modifier.size(20.dp))
Column(modifier = Modifier.weight(1f)) {
Text("Phone is shooting in HEIC", fontWeight = FontWeight.SemiBold, style = MaterialTheme.typography.bodySmall)
Text(
if (convertByDefault)
"Files will be converted to JPEG during import. To avoid this, go to Settings → Camera → Formats → Most Compatible on your iPhone."
else
"HEIC files will be kept as-is. PhotoPhile may not handle them on Linux. Consider Settings → Camera → Formats → Most Compatible.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onTertiaryContainer,
)
}
TextButton(onClick = onDismiss) { Text("Dismiss") }
}
}
}

View File

@@ -0,0 +1,202 @@
package com.bolenpad.photophetch.ui
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PhoneAndroid
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.SmartPhone
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.bolenpad.photophetch.model.DeviceInfo
import com.bolenpad.photophetch.model.DeviceType
@Composable
fun ConnectScreen(state: AppState) {
val scanState by state.deviceScan.collectAsState()
val config by state.config.collectAsState()
Column(
modifier = Modifier.fillMaxSize().padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
Spacer(Modifier.height(32.dp))
// App title
Text(
"PhotoPhetch",
style = MaterialTheme.typography.headlineLarge,
fontWeight = FontWeight.Bold,
)
Text(
"Import photos from your phone to PhotoPhile staging",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(16.dp))
// Staging root warning
if (config.stagingRoot.isBlank()) {
StagingNotConfiguredBanner(onConfigureClick = { state.navigateTo(Screen.SETTINGS) })
}
// Device scan area
when (val scan = scanState) {
is DeviceScanState.Idle -> {
ScanPrompt(onScan = { state.scanForDevices() })
}
is DeviceScanState.Scanning -> {
CircularProgressIndicator()
Text("Scanning for connected devices…")
}
is DeviceScanState.Found -> {
if (scan.devices.isEmpty()) {
NoDevicesFound(onRescan = { state.scanForDevices() })
} else {
DeviceList(
devices = scan.devices,
onSelect = { state.selectDevice(it) },
onRescan = { state.scanForDevices() },
)
}
}
is DeviceScanState.Error -> {
ScanError(message = scan.message, onRetry = { state.scanForDevices() })
}
}
Spacer(Modifier.weight(1f))
// Settings link at bottom
TextButton(onClick = { state.navigateTo(Screen.SETTINGS) }) {
Icon(Icons.Default.Settings, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(4.dp))
Text("Settings")
}
}
}
@Composable
private fun ScanPrompt(onScan: () -> Unit) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp)) {
Icon(
Icons.Default.PhoneAndroid,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.primary,
)
Text("Connect your phone via USB, then scan", style = MaterialTheme.typography.bodyLarge)
Button(onClick = onScan) {
Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Scan for Devices")
}
}
}
@Composable
private fun NoDevicesFound(onRescan: () -> Unit) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("No devices found", style = MaterialTheme.typography.titleMedium)
Text(
"Make sure your phone is connected via USB and:\n" +
"• Android: USB debugging is enabled and trust dialog accepted\n" +
"• iPhone: Trust This Computer tapped and gphoto2 installed (brew install gphoto2)\n" +
" If gphoto2 sees no files, run: killall PTPCamera",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedButton(onClick = onRescan) {
Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(4.dp))
Text("Scan Again")
}
}
}
@Composable
private fun DeviceList(
devices: List<DeviceInfo>,
onSelect: (DeviceInfo) -> Unit,
onRescan: () -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Text("Found ${devices.size} device(s)", style = MaterialTheme.typography.titleMedium)
devices.forEach { device ->
DeviceCard(device = device, onClick = { onSelect(device) })
}
Spacer(Modifier.height(8.dp))
TextButton(onClick = onRescan) {
Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(4.dp))
Text("Rescan")
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun DeviceCard(device: DeviceInfo, onClick: () -> Unit) {
Card(
onClick = onClick,
modifier = Modifier.width(360.dp),
) {
Row(
modifier = Modifier.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
if (device.type == DeviceType.IOS) Icons.Default.SmartPhone else Icons.Default.PhoneAndroid,
contentDescription = null,
modifier = Modifier.size(32.dp),
tint = MaterialTheme.colorScheme.primary,
)
Column {
Text(device.displayName, fontWeight = FontWeight.SemiBold)
Text(
device.type.name.lowercase().replaceFirstChar { it.uppercase() },
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Composable
private fun ScanError(message: String, onRetry: () -> Unit) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Scan failed", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.error)
Text(message, style = MaterialTheme.typography.bodySmall)
Button(onClick = onRetry) { Text("Retry") }
}
}
@Composable
private fun StagingNotConfiguredBanner(onConfigureClick: () -> Unit) {
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer),
modifier = Modifier.width(400.dp),
) {
Row(
modifier = Modifier.padding(12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"Staging directory not configured",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onConfigureClick) { Text("Configure") }
}
}
}

View File

@@ -0,0 +1,154 @@
package com.bolenpad.photophetch.ui
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.FolderOpen
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.bolenpad.photophetch.util.StagingPath
import java.nio.file.Paths
/**
* Import configuration screen — shown after photo selection, before starting the copy.
* User confirms destination folder name, conversion settings, and delete-after-verify preference.
*/
@Composable
fun ImportScreen(state: AppState) {
val importConfig by state.importConfig.collectAsState()
val config by state.config.collectAsState()
val selected by state.selectedPhotos.collectAsState()
val device by state.selectedDevice.collectAsState()
// Validate staging root
val stagingError = if (config.stagingRoot.isNotBlank()) {
StagingPath.validateStagingRoot(Paths.get(config.stagingRoot))
} else "Staging directory not configured"
Column(
modifier = Modifier.fillMaxSize().padding(32.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
// Header
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = { state.navigateTo(Screen.BROWSE) }) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
Text("Import Configuration", style = MaterialTheme.typography.headlineSmall)
}
// Summary card
Card {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("${selected.size} files selected", fontWeight = FontWeight.SemiBold)
device?.let {
Text("From: ${it.displayName}", style = MaterialTheme.typography.bodySmall)
}
}
}
HorizontalDivider()
// Destination folder name
Text("Destination Folder", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
"${config.stagingRoot}/",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedTextField(
value = importConfig.folderName,
onValueChange = { state.updateImportConfig { copy(folderName = it) } },
label = { Text("Folder name") },
modifier = Modifier.weight(1f),
singleLine = true,
)
}
if (stagingError != null) {
Text(stagingError, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
}
HorizontalDivider()
// Conversion options
Text("Conversion", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(modifier = Modifier.weight(1f)) {
Text("Convert HEIC → JPEG")
Text(
"Recommended for PhotoPhile on Linux. Uses macOS sips (built-in).",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(
checked = importConfig.convertHeicToJpeg,
onCheckedChange = { state.updateImportConfig { copy(convertHeicToJpeg = it) } },
)
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(modifier = Modifier.weight(1f)) {
Text("Strip Live Photo motion clips")
Text(
"Excludes .MOV companions from Live Photos. You want JPEGs, not motion clips.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(
checked = importConfig.stripLivePhotoMotion,
onCheckedChange = { state.updateImportConfig { copy(stripLivePhotoMotion = it) } },
)
}
HorizontalDivider()
// Delete after verify
Text("After Import", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(modifier = Modifier.weight(1f)) {
Text("Delete from phone after verification")
Text(
"Only deletes files that passed SHA-256 verification. You'll confirm on the next screen.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(
checked = importConfig.deleteAfterVerify,
onCheckedChange = { state.updateImportConfig { copy(deleteAfterVerify = it) } },
)
}
Spacer(Modifier.weight(1f))
// Start import button
Button(
onClick = { state.startImport() },
enabled = stagingError == null && importConfig.folderName.isNotBlank() && selected.isNotEmpty(),
modifier = Modifier.fillMaxWidth().height(52.dp),
) {
Text("Start Import (${selected.size} files)", style = MaterialTheme.typography.titleMedium)
}
}
}

View File

@@ -0,0 +1,89 @@
package com.bolenpad.photophetch.ui
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@Composable
fun SettingsScreen(state: AppState) {
val config by state.config.collectAsState()
// Local mutable copies for editing
var stagingRoot by remember(config.stagingRoot) { mutableStateOf(config.stagingRoot) }
var folderTemplate by remember(config.folderNameTemplate) { mutableStateOf(config.folderNameTemplate) }
Column(
modifier = Modifier.fillMaxSize().padding(32.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = { state.navigateTo(Screen.CONNECT) }) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
Text("Settings", style = MaterialTheme.typography.headlineSmall)
}
HorizontalDivider()
// Staging directory
Text("Staging Directory", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
OutlinedTextField(
value = stagingRoot,
onValueChange = { stagingRoot = it },
label = { Text("Path to _staging/ directory") },
placeholder = { Text("/Volumes/delphi/_staging") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Text(
"This should be the _staging directory on your NAS (must be mounted).",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// Folder name template
Text("Folder Name Template", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
OutlinedTextField(
value = folderTemplate,
onValueChange = { folderTemplate = it },
label = { Text("Template") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Text(
"Tokens: {date} = today's date (yyyy-MM-dd), {device} = phone name.\n" +
"Example: {date}_{device} → 2026-07-28_iPhone15Pro",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
HorizontalDivider()
// External tool paths
Text("External Tools", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
Text(
"Leave blank to use system PATH (recommended if installed via Homebrew).",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// Save button
Spacer(Modifier.weight(1f))
Button(
onClick = {
state.updateStagingRoot(stagingRoot)
// Also save template update
state.saveConfig()
},
modifier = Modifier.fillMaxWidth().height(52.dp),
) {
Text("Save Settings")
}
}
}

View File

@@ -0,0 +1,219 @@
package com.bolenpad.photophetch.ui
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
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.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.bolenpad.photophetch.model.CopyResult
/**
* Shown during active import (IMPORTING screen) and after import completes (VERIFY screen).
*/
@Composable
fun ImportingScreen(state: AppState) {
val progress by state.importProgress.collectAsState()
Column(
modifier = Modifier.fillMaxSize().padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text("Importing…", style = MaterialTheme.typography.headlineSmall)
Spacer(Modifier.height(32.dp))
val p = progress
if (p != null && p.total > 0) {
val fraction = p.completed.toFloat() / p.total
LinearProgressIndicator(progress = { fraction }, modifier = Modifier.fillMaxWidth(0.6f))
Spacer(Modifier.height(12.dp))
Text("${p.completed} / ${p.total} files")
if (p.currentFile.isNotBlank()) {
Text(
p.currentFile,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
CircularProgressIndicator()
}
}
}
@Composable
fun VerifyScreen(state: AppState) {
val result by state.importResult.collectAsState()
val importConfig by state.importConfig.collectAsState()
var deleteConfirmShown by remember { mutableStateOf(false) }
var deleteDone by remember { mutableStateOf(false) }
val r = result ?: return
Column(modifier = Modifier.fillMaxSize().padding(24.dp)) {
// Summary header
ResultSummaryHeader(
success = r.successCount,
failure = r.failureCount,
destination = "${r.job.stagingRoot}/${r.job.folderName}",
)
Spacer(Modifier.height(16.dp))
// File-by-file results
Text("Results", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.height(8.dp))
LazyColumn(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
items(r.results, key = { it.photo.devicePath }) { copyResult ->
CopyResultRow(copyResult)
}
}
Spacer(Modifier.height(16.dp))
HorizontalDivider()
Spacer(Modifier.height(12.dp))
// Actions
if (importConfig.deleteAfterVerify && r.successCount > 0 && !deleteDone) {
if (deleteConfirmShown) {
DeleteConfirmCard(
count = r.successCount,
onConfirm = {
state.deleteVerifiedFromDevice()
deleteDone = true
deleteConfirmShown = false
},
onCancel = { deleteConfirmShown = false },
)
} else {
OutlinedButton(
onClick = { deleteConfirmShown = true },
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
modifier = Modifier.fillMaxWidth(),
) {
Text("Delete ${r.successCount} verified files from phone")
}
}
} else if (deleteDone) {
Text(
"${r.successCount} files deleted from phone",
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.bodySmall,
)
}
Spacer(Modifier.height(8.dp))
Button(
onClick = {
state.navigateTo(Screen.CONNECT)
state.selectNone()
},
modifier = Modifier.fillMaxWidth(),
) {
Icon(Icons.Default.Home, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Done — Back to Start")
}
}
}
@Composable
private fun ResultSummaryHeader(success: Int, failure: Int, destination: String) {
Card(
colors = CardDefaults.cardColors(
containerColor = if (failure == 0)
MaterialTheme.colorScheme.primaryContainer
else
MaterialTheme.colorScheme.errorContainer,
)
) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
if (failure == 0) "Import complete ✓" else "Import complete with errors",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Text("$success verified, $failure failed")
Text(
"$destination",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
private fun CopyResultRow(result: CopyResult) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
if (result.verified && result.error == null) Icons.Default.CheckCircle else Icons.Default.Error,
contentDescription = null,
tint = if (result.verified && result.error == null)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.error,
modifier = Modifier.size(18.dp),
)
Column(modifier = Modifier.weight(1f)) {
val displayName = if (result.convertedFromHeic) {
"${result.photo.filename}${result.destinationPath.fileName}"
} else {
result.photo.filename
}
Text(displayName, style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Medium)
if (result.error != null) {
Text(result.error, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.error)
} else {
Text(
"${result.photo.sizeBytes / 1024} KB • SHA-256: ${result.destSha256.take(12)}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Composable
private fun DeleteConfirmCard(count: Int, onConfirm: () -> Unit, onCancel: () -> Unit) {
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer),
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
"Delete $count files from phone?",
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onErrorContainer,
)
Text(
"This is permanent. Only files that passed SHA-256 verification will be deleted.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
onClick = onConfirm,
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error),
) { Text("Delete from Phone") }
OutlinedButton(onClick = onCancel) { Text("Cancel") }
}
}
}
}

View File

@@ -0,0 +1,54 @@
package com.bolenpad.photophetch.util
import com.bolenpad.photophetch.model.AppConfig
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.slf4j.LoggerFactory
import java.nio.file.Path
import java.nio.file.Paths
import kotlin.io.path.createDirectories
import kotlin.io.path.exists
import kotlin.io.path.readText
import kotlin.io.path.writeText
/**
* Loads and saves [AppConfig] to ~/.photophetch/config.json.
*/
class ConfigStore {
private val log = LoggerFactory.getLogger(ConfigStore::class.java)
private val configDir: Path = Paths.get(System.getProperty("user.home"), ".photophetch")
private val configFile: Path = configDir.resolve("config.json")
private val json = Json {
prettyPrint = true
ignoreUnknownKeys = true
encodeDefaults = true
}
fun load(): AppConfig {
if (!configFile.exists()) {
log.info("No config file found at $configFile — using defaults")
return AppConfig()
}
return try {
val text = configFile.readText()
json.decodeFromString<AppConfig>(text).also {
log.info("Loaded config from $configFile")
}
} catch (e: Exception) {
log.error("Failed to load config from $configFile: ${e.message}. Using defaults.", e)
AppConfig()
}
}
fun save(config: AppConfig) {
try {
if (!configDir.exists()) configDir.createDirectories()
configFile.writeText(json.encodeToString(config))
log.info("Saved config to $configFile")
} catch (e: Exception) {
log.error("Failed to save config to $configFile: ${e.message}", e)
}
}
}

View File

@@ -0,0 +1,103 @@
package com.bolenpad.photophetch.util
import com.bolenpad.photophetch.model.MediaType
import com.bolenpad.photophetch.model.PhonePhoto
/**
* Filters a list of [PhonePhoto]s down to camera-roll originals.
*
* The goal is to exclude:
* - Screenshots (no EXIF Make/Model, or path contains "Screenshots")
* - Received images (WhatsApp, Telegram, MMS, etc.)
* - Live Photo .MOV companions (tagged as [PhonePhoto.isLivePhotoMotion])
* - Files from known non-camera directories
*
* What passes through:
* - Photos/videos from DCIM/ with EXIF Make/Model present
* - Any file explicitly selected by the user (override)
*/
object ExifFilter {
/**
* Directory path fragments that indicate non-camera-roll content.
* Case-insensitive match against the device path.
*/
private val excludedPathFragments = listOf(
"screenshot",
"screen_record",
"whatsapp",
"telegram",
"signal",
"viber",
"received",
"download",
"snapchat",
"instagram",
"twitter",
"facebook",
"tiktok",
"share",
"temp",
)
/**
* File name prefixes that indicate screenshots on various devices.
*/
private val screenshotFilePrefixes = listOf(
"screenshot_",
"screen_",
"capture_",
)
/**
* Apply all heuristics and return only likely camera-roll originals.
*
* @param stripLivePhotoMotion If true (default), exclude Live Photo .MOV companions.
*/
fun filter(
photos: List<PhonePhoto>,
stripLivePhotoMotion: Boolean = true,
): List<PhonePhoto> {
return photos.filter { photo ->
// Always exclude Live Photo motion clips if requested
if (stripLivePhotoMotion && photo.isLivePhotoMotion) return@filter false
// Exclude if the device path contains a known non-camera directory
val pathLower = photo.devicePath.lowercase()
if (excludedPathFragments.any { fragment -> pathLower.contains(fragment) }) {
return@filter false
}
// Exclude if filename starts with a screenshot prefix
val filenameLower = photo.filename.lowercase()
if (screenshotFilePrefixes.any { prefix -> filenameLower.startsWith(prefix) }) {
return@filter false
}
// Exclude files with no EXIF Make/Model AND not in a DCIM path
// (indicates received image or screenshot — not a camera original)
val hasExif = photo.exifMake != null || photo.exifModel != null
val inDcim = pathLower.contains("dcim")
if (!hasExif && !inDcim) return@filter false
// Skip files with completely unknown media type
if (photo.mediaType == MediaType.UNKNOWN) return@filter false
true
}
}
/**
* Returns photos that are HEIC files (and not Live Photo motion clips).
* Used to determine whether to show the HEIC conversion warning.
*/
fun heicPhotos(photos: List<PhonePhoto>): List<PhonePhoto> =
photos.filter { it.isHeic && !it.isLivePhotoMotion }
/**
* Returns true if any camera-roll photos are HEIC, which means the phone
* is shooting in HEIC mode (rather than JPEG / "Most Compatible").
*/
fun isShootingInHeic(photos: List<PhonePhoto>): Boolean =
heicPhotos(filter(photos)).isNotEmpty()
}

View File

@@ -0,0 +1,108 @@
package com.bolenpad.photophetch.util
import com.bolenpad.photophetch.model.DeviceInfo
import com.bolenpad.photophetch.model.DeviceType
import java.nio.file.Path
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import kotlin.io.path.createDirectories
import kotlin.io.path.exists
import kotlin.io.path.isDirectory
import kotlin.io.path.isWritable
/**
* Generates and validates staging directory paths following the PhotoPhile convention:
* <stagingRoot>/<folderName>/
* where folderName is derived from a template like "{date}_{device}".
*
* PhotoPhile staging convention examples:
* /Volumes/delphi/_staging/2026-07-28_iPhone15Pro/
* /Volumes/delphi/_staging/2026-07-28_Pixel9/
*/
object StagingPath {
private val dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
/**
* Resolve a staging folder path from a template, date, and device.
*
* Template tokens:
* {date} → today's date in yyyy-MM-dd format
* {device} → sanitized device display name
*
* @param stagingRoot Absolute path to the PhotoPhile _staging directory
* @param template Folder name template, e.g. "{date}_{device}"
* @param device The connected device
* @param date The import date (defaults to today)
*/
fun resolve(
stagingRoot: Path,
template: String,
device: DeviceInfo,
date: LocalDate = LocalDate.now(),
): Path {
val dateStr = dateFormatter.format(date)
val deviceStr = sanitize(deviceLabel(device))
val folderName = template
.replace("{date}", dateStr)
.replace("{device}", deviceStr)
return stagingRoot.resolve(folderName)
}
/**
* Create the staging directory if it doesn't already exist.
* @throws IllegalStateException if the parent is not writable
*/
fun ensureExists(path: Path): Path {
if (!path.exists()) {
path.createDirectories()
}
return path
}
/**
* Validate that the staging root is accessible and writable.
* Returns a user-friendly error message, or null if everything is fine.
*/
fun validateStagingRoot(stagingRoot: Path): String? {
return when {
stagingRoot.toString().isBlank() ->
"Staging directory is not configured. Go to Settings to set it."
!stagingRoot.exists() ->
"Staging directory does not exist: $stagingRoot\nIs the NAS mounted?"
!stagingRoot.isDirectory() ->
"$stagingRoot is not a directory"
!stagingRoot.isWritable() ->
"$stagingRoot is not writable. Check NAS permissions."
else -> null
}
}
/**
* Produces a short label for the device to use in folder names.
* Strips spaces, punctuation, and keeps it recognizable but filesystem-safe.
*/
fun deviceLabel(device: DeviceInfo): String {
// Prefer a short model-like string
val name = when (device.type) {
DeviceType.IOS -> {
// "Apple iPhone 15 Pro" → "iPhone15Pro"
device.displayName
.replace("Apple ", "")
.replace(" ", "")
}
DeviceType.ANDROID -> {
// "Google Pixel 9 Pro" → "Pixel9Pro" | "Samsung Galaxy S24" → "GalaxyS24"
device.displayName
.replace("Google ", "")
.replace("Samsung ", "")
.replace(" ", "")
}
}
return name.ifBlank { device.type.name.lowercase().replaceFirstChar { it.uppercase() } }
}
/** Remove filesystem-unsafe characters and collapse spaces. */
private fun sanitize(name: String): String =
name.replace(Regex("[^a-zA-Z0-9_\\-]"), "")
}