commit 145b7bc9556738d7d8137709ed7d0e13ddd3a468 Author: Kyle Bolen Date: Tue Jul 28 03:04:05 2026 +0000 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e9701da --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Gradle +.gradle/ +build/ +out/ + +# IntelliJ IDEA +.idea/ +*.iml +*.iws +*.ipr + +# macOS +.DS_Store +**/.DS_Store + +# Logs +*.log + +# PhotoPhetch user config (personal paths — don't commit) +~/.photophetch/ + +# Kotlin compilation +*.class +*.kotlin_module diff --git a/README.md b/README.md new file mode 100644 index 0000000..1327f9e --- /dev/null +++ b/README.md @@ -0,0 +1,122 @@ +# PhotoPhetch + +A macOS desktop app for importing photos from phones into [PhotoPhile](https://git.bolenpad.com/kbolen/photophile)'s staging directory. + +Solves the "messy phone import" problem: only grabs camera-roll originals, filters out screenshots and received images, optionally converts HEIC → JPEG, and SHA-256 verifies every file before optionally deleting it from the phone. + +## Features + +- **Android** support via ADB (`adb pull`) +- **iPhone** support via gphoto2 (PTP over USB) +- **Smart filtering**: excludes screenshots, WhatsApp/Telegram received images, and Live Photo motion clips +- **HEIC → JPEG conversion** via macOS `sips` (built-in, no install needed) +- **SHA-256 verification** of every copied file +- **Safe delete**: explicit confirmation screen before removing files from the phone +- **Staging path convention**: writes to `_staging/_/` +- Persists settings to `~/.photophetch/config.json` + +## Requirements + +- macOS (primary target) +- Java 21+ +- [Homebrew](https://brew.sh) for dependencies + +### Install dependencies + +```bash +# For iPhone support +brew install gphoto2 + +# For Android support +brew install android-platform-tools +``` + +## Running + +### From source (development) + +```bash +cd photophetch +./gradlew run +``` + +### Build a fat JAR + +```bash +./gradlew packageUberJarForCurrentOS +# Output: build/compose/jars/photophetch-macos-*.jar +java -jar build/compose/jars/photophetch-macos-*.jar +``` + +### Build a macOS .app bundle + +```bash +./gradlew packageDmg +# Output: build/compose/binaries/main/dmg/PhotoPhetch-1.0.0.dmg +``` + +## First-time setup + +1. Launch the app +2. Go to **Settings** and set your staging root (e.g. `/Volumes/delphi/_staging`) +3. Make sure your NAS is mounted at that path +4. Connect your phone via USB +5. Click **Scan for Devices** on the Connect screen + +## iPhone notes + +When you plug in an iPhone, macOS's built-in `PTPCamera` daemon may grab the device before gphoto2 can. If PhotoPhetch finds the device but shows no photos: + +```bash +killall PTPCamera +``` + +Then rescan in the app. + +### HEIC settings + +If your iPhone is shooting in HEIC (the default), PhotoPhetch will warn you and convert to JPEG during import. To stop shooting HEIC: + +> Settings → Camera → Formats → **Most Compatible** + +## Android notes + +Enable USB debugging: + +> Settings → About Phone → tap Build Number 7 times → Developer Options → USB Debugging + +Accept the "Trust this computer?" prompt on the phone when connecting. + +## Project structure + +``` +src/main/kotlin/com/bolenpad/photophetch/ +├── Main.kt # Entry point +├── device/ +│ ├── DeviceConnector.kt # Interface +│ ├── AndroidConnector.kt # ADB implementation +│ └── IosConnector.kt # gphoto2 implementation +├── model/ +│ ├── PhonePhoto.kt # Photo metadata +│ ├── ImportJob.kt # Import batch + results +│ ├── DeviceInfo.kt # Connected device info +│ └── AppConfig.kt # Persisted preferences +├── transfer/ +│ └── Copier.kt # Copy + SHA-256 verify + HEIC conversion +├── ui/ +│ ├── App.kt # Root composable + navigation +│ ├── AppState.kt # StateFlow ViewModel +│ ├── ConnectScreen.kt # Device detection +│ ├── BrowseScreen.kt # Photo grid + filters +│ ├── ImportScreen.kt # Import configuration +│ ├── VerifyScreen.kt # Progress + results + delete confirmation +│ └── SettingsScreen.kt # Staging path + preferences +└── util/ + ├── ConfigStore.kt # Config persistence + ├── ExifFilter.kt # Screenshot/noise filtering + └── StagingPath.kt # Staging directory path resolution +``` + +## PhotoPhile integration + +PhotoPhetch writes directly to the mounted NAS path — no API calls. After import, PhotoPhile picks up the new folder in `_staging/` and runs it through its normal pipeline: configure → normalize → publish. diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..ac6268e --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,48 @@ +import org.jetbrains.compose.desktop.application.dsl.TargetFormat + +plugins { + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.compose.multiplatform) +} + +group = "com.bolenpad.photophetch" +version = "1.0.0" + +kotlin { + jvmToolchain(21) +} + +dependencies { + implementation(compose.desktop.currentOs) + implementation(compose.material3) + implementation(compose.materialIconsExtended) + implementation(libs.coroutines.core) + implementation(libs.coroutines.swing) + implementation(libs.serialization.json) + implementation(libs.slf4j.api) + implementation(libs.logback.classic) +} + +compose.desktop { + application { + mainClass = "com.bolenpad.photophetch.MainKt" + + nativeDistributions { + targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) + packageName = "PhotoPhetch" + packageVersion = "1.0.0" + description = "Import photos from phones into PhotoPhile" + vendor = "bolenpad" + macOS { + bundleID = "com.bolenpad.photophetch" + iconFile.set(project.file("src/main/resources/icon.icns")) + } + } + + // Fat JAR for quick distribution + buildTypes.release.proguard { + isEnabled = false + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..7b3895b --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,20 @@ +[versions] +kotlin = "1.9.23" +compose = "1.6.2" +coroutines = "1.8.0" +serialization = "1.6.3" +slf4j = "2.0.12" +logback = "1.5.3" + +[libraries] +kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } +coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } +coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "coroutines" } +serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" } +slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" } +logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback" } + +[plugins] +kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +compose-multiplatform = { id = "org.jetbrains.compose", version.ref = "compose" } diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..778a336 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,18 @@ +rootProject.name = "photophetch" + +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + google() + maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") + } +} + +dependencyResolutionManagement { + repositories { + mavenCentral() + google() + maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") + } +} diff --git a/src/main/kotlin/com/bolenpad/photophetch/Main.kt b/src/main/kotlin/com/bolenpad/photophetch/Main.kt new file mode 100644 index 0000000..cafbfb3 --- /dev/null +++ b/src/main/kotlin/com/bolenpad/photophetch/Main.kt @@ -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) + } +} diff --git a/src/main/kotlin/com/bolenpad/photophetch/device/AndroidConnector.kt b/src/main/kotlin/com/bolenpad/photophetch/device/AndroidConnector.kt new file mode 100644 index 0000000..2972c6e --- /dev/null +++ b/src/main/kotlin/com/bolenpad/photophetch/device/AndroidConnector.kt @@ -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 { + 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 { + val serial = requireSerial(device) + val photos = mutableListOf() + + 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: " " + 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 + } +} diff --git a/src/main/kotlin/com/bolenpad/photophetch/device/DeviceConnector.kt b/src/main/kotlin/com/bolenpad/photophetch/device/DeviceConnector.kt new file mode 100644 index 0000000..02d1dc0 --- /dev/null +++ b/src/main/kotlin/com/bolenpad/photophetch/device/DeviceConnector.kt @@ -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 + + /** + * List all photos/videos on the given device. + * Raw listing — no filtering applied here; filtering happens in ExifFilter. + */ + suspend fun listPhotos(device: DeviceInfo): List + + /** + * 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) diff --git a/src/main/kotlin/com/bolenpad/photophetch/device/IosConnector.kt b/src/main/kotlin/com/bolenpad/photophetch/device/IosConnector.kt new file mode 100644 index 0000000..6687862 --- /dev/null +++ b/src/main/kotlin/com/bolenpad/photophetch/device/IosConnector.kt @@ -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 { + // gphoto2 --auto-detect lists connected cameras/phones + val output = runGphoto2("--auto-detect") + /* + * Output format: + * Model Port + * ---------------------------------------------------------- + * Apple iPhone usb:001,023 + */ + val devices = mutableListOf() + 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 { + 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() + 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): List { + // 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 + } +} diff --git a/src/main/kotlin/com/bolenpad/photophetch/model/AppConfig.kt b/src/main/kotlin/com/bolenpad/photophetch/model/AppConfig.kt new file mode 100644 index 0000000..75ad293 --- /dev/null +++ b/src/main/kotlin/com/bolenpad/photophetch/model/AppConfig.kt @@ -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, +) diff --git a/src/main/kotlin/com/bolenpad/photophetch/model/DeviceInfo.kt b/src/main/kotlin/com/bolenpad/photophetch/model/DeviceInfo.kt new file mode 100644 index 0000000..089dd3e --- /dev/null +++ b/src/main/kotlin/com/bolenpad/photophetch/model/DeviceInfo.kt @@ -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, +} diff --git a/src/main/kotlin/com/bolenpad/photophetch/model/ImportJob.kt b/src/main/kotlin/com/bolenpad/photophetch/model/ImportJob.kt new file mode 100644 index 0000000..94b9313 --- /dev/null +++ b/src/main/kotlin/com/bolenpad/photophetch/model/ImportJob.kt @@ -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, + /** 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, + 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 } +} diff --git a/src/main/kotlin/com/bolenpad/photophetch/model/PhonePhoto.kt b/src/main/kotlin/com/bolenpad/photophetch/model/PhonePhoto.kt new file mode 100644 index 0000000..7c16004 --- /dev/null +++ b/src/main/kotlin/com/bolenpad/photophetch/model/PhonePhoto.kt @@ -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, +} diff --git a/src/main/kotlin/com/bolenpad/photophetch/transfer/Copier.kt b/src/main/kotlin/com/bolenpad/photophetch/transfer/Copier.kt new file mode 100644 index 0000000..56d3016 --- /dev/null +++ b/src/main/kotlin/com/bolenpad/photophetch/transfer/Copier.kt @@ -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() + 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) } + } +} diff --git a/src/main/kotlin/com/bolenpad/photophetch/ui/App.kt b/src/main/kotlin/com/bolenpad/photophetch/ui/App.kt new file mode 100644 index 0000000..b98eb66 --- /dev/null +++ b/src/main/kotlin/com/bolenpad/photophetch/ui/App.kt @@ -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) + } + } + } +} diff --git a/src/main/kotlin/com/bolenpad/photophetch/ui/AppState.kt b/src/main/kotlin/com/bolenpad/photophetch/ui/AppState.kt new file mode 100644 index 0000000..74ed322 --- /dev/null +++ b/src/main/kotlin/com/bolenpad/photophetch/ui/AppState.kt @@ -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) : 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, + val filtered: List, + 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 = _config.asStateFlow() + + // ------------------------------------------------------------------------- + // Navigation + // ------------------------------------------------------------------------- + private val _currentScreen = MutableStateFlow(Screen.CONNECT) + val currentScreen: StateFlow = _currentScreen.asStateFlow() + + fun navigateTo(screen: Screen) { _currentScreen.value = screen } + + // ------------------------------------------------------------------------- + // Device scanning + // ------------------------------------------------------------------------- + private val _deviceScan = MutableStateFlow(DeviceScanState.Idle) + val deviceScan: StateFlow = _deviceScan.asStateFlow() + + private val _selectedDevice = MutableStateFlow(null) + val selectedDevice: StateFlow = _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.Idle) + val photoList: StateFlow = _photoList.asStateFlow() + + private val _selectedPhotos = MutableStateFlow>(emptySet()) + val selectedPhotos: StateFlow> = _selectedPhotos.asStateFlow() + + private val _dateFilter = MutableStateFlow>(null to null) + val dateFilter: StateFlow> = _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, + from: LocalDate?, + to: LocalDate?, + ): List { + 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.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(null) + val importProgress: StateFlow = _importProgress.asStateFlow() + + private val _importResult = MutableStateFlow(null) + val importResult: StateFlow = _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") + } +} diff --git a/src/main/kotlin/com/bolenpad/photophetch/ui/BrowseScreen.kt b/src/main/kotlin/com/bolenpad/photophetch/ui/BrowseScreen.kt new file mode 100644 index 0000000..87cd1fb --- /dev/null +++ b/src/main/kotlin/com/bolenpad/photophetch/ui/BrowseScreen.kt @@ -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, + 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") } + } + } +} diff --git a/src/main/kotlin/com/bolenpad/photophetch/ui/ConnectScreen.kt b/src/main/kotlin/com/bolenpad/photophetch/ui/ConnectScreen.kt new file mode 100644 index 0000000..1d6413f --- /dev/null +++ b/src/main/kotlin/com/bolenpad/photophetch/ui/ConnectScreen.kt @@ -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, + 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") } + } + } +} diff --git a/src/main/kotlin/com/bolenpad/photophetch/ui/ImportScreen.kt b/src/main/kotlin/com/bolenpad/photophetch/ui/ImportScreen.kt new file mode 100644 index 0000000..c3ad9c7 --- /dev/null +++ b/src/main/kotlin/com/bolenpad/photophetch/ui/ImportScreen.kt @@ -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) + } + } +} diff --git a/src/main/kotlin/com/bolenpad/photophetch/ui/SettingsScreen.kt b/src/main/kotlin/com/bolenpad/photophetch/ui/SettingsScreen.kt new file mode 100644 index 0000000..925177a --- /dev/null +++ b/src/main/kotlin/com/bolenpad/photophetch/ui/SettingsScreen.kt @@ -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") + } + } +} diff --git a/src/main/kotlin/com/bolenpad/photophetch/ui/VerifyScreen.kt b/src/main/kotlin/com/bolenpad/photophetch/ui/VerifyScreen.kt new file mode 100644 index 0000000..da5b222 --- /dev/null +++ b/src/main/kotlin/com/bolenpad/photophetch/ui/VerifyScreen.kt @@ -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") } + } + } + } +} diff --git a/src/main/kotlin/com/bolenpad/photophetch/util/ConfigStore.kt b/src/main/kotlin/com/bolenpad/photophetch/util/ConfigStore.kt new file mode 100644 index 0000000..c07b84f --- /dev/null +++ b/src/main/kotlin/com/bolenpad/photophetch/util/ConfigStore.kt @@ -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(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) + } + } +} diff --git a/src/main/kotlin/com/bolenpad/photophetch/util/ExifFilter.kt b/src/main/kotlin/com/bolenpad/photophetch/util/ExifFilter.kt new file mode 100644 index 0000000..d61a760 --- /dev/null +++ b/src/main/kotlin/com/bolenpad/photophetch/util/ExifFilter.kt @@ -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, + stripLivePhotoMotion: Boolean = true, + ): List { + 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): List = + 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): Boolean = + heicPhotos(filter(photos)).isNotEmpty() +} diff --git a/src/main/kotlin/com/bolenpad/photophetch/util/StagingPath.kt b/src/main/kotlin/com/bolenpad/photophetch/util/StagingPath.kt new file mode 100644 index 0000000..9da58bc --- /dev/null +++ b/src/main/kotlin/com/bolenpad/photophetch/util/StagingPath.kt @@ -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: + * // + * 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_\\-]"), "") +}