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:
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@@ -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
|
||||||
122
README.md
Normal file
122
README.md
Normal file
@@ -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/<YYYY-MM-DD>_<DeviceName>/`
|
||||||
|
- 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.
|
||||||
48
build.gradle.kts
Normal file
48
build.gradle.kts
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
gradle/libs.versions.toml
Normal file
20
gradle/libs.versions.toml
Normal file
@@ -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" }
|
||||||
18
settings.gradle.kts
Normal file
18
settings.gradle.kts
Normal file
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/main/kotlin/com/bolenpad/photophetch/Main.kt
Normal file
23
src/main/kotlin/com/bolenpad/photophetch/Main.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
244
src/main/kotlin/com/bolenpad/photophetch/device/IosConnector.kt
Normal file
244
src/main/kotlin/com/bolenpad/photophetch/device/IosConnector.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
26
src/main/kotlin/com/bolenpad/photophetch/model/AppConfig.kt
Normal file
26
src/main/kotlin/com/bolenpad/photophetch/model/AppConfig.kt
Normal 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,
|
||||||
|
)
|
||||||
24
src/main/kotlin/com/bolenpad/photophetch/model/DeviceInfo.kt
Normal file
24
src/main/kotlin/com/bolenpad/photophetch/model/DeviceInfo.kt
Normal 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,
|
||||||
|
}
|
||||||
48
src/main/kotlin/com/bolenpad/photophetch/model/ImportJob.kt
Normal file
48
src/main/kotlin/com/bolenpad/photophetch/model/ImportJob.kt
Normal 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 }
|
||||||
|
}
|
||||||
36
src/main/kotlin/com/bolenpad/photophetch/model/PhonePhoto.kt
Normal file
36
src/main/kotlin/com/bolenpad/photophetch/model/PhonePhoto.kt
Normal 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,
|
||||||
|
}
|
||||||
204
src/main/kotlin/com/bolenpad/photophetch/transfer/Copier.kt
Normal file
204
src/main/kotlin/com/bolenpad/photophetch/transfer/Copier.kt
Normal 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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
32
src/main/kotlin/com/bolenpad/photophetch/ui/App.kt
Normal file
32
src/main/kotlin/com/bolenpad/photophetch/ui/App.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
315
src/main/kotlin/com/bolenpad/photophetch/ui/AppState.kt
Normal file
315
src/main/kotlin/com/bolenpad/photophetch/ui/AppState.kt
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
278
src/main/kotlin/com/bolenpad/photophetch/ui/BrowseScreen.kt
Normal file
278
src/main/kotlin/com/bolenpad/photophetch/ui/BrowseScreen.kt
Normal 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") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
202
src/main/kotlin/com/bolenpad/photophetch/ui/ConnectScreen.kt
Normal file
202
src/main/kotlin/com/bolenpad/photophetch/ui/ConnectScreen.kt
Normal 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") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
154
src/main/kotlin/com/bolenpad/photophetch/ui/ImportScreen.kt
Normal file
154
src/main/kotlin/com/bolenpad/photophetch/ui/ImportScreen.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
219
src/main/kotlin/com/bolenpad/photophetch/ui/VerifyScreen.kt
Normal file
219
src/main/kotlin/com/bolenpad/photophetch/ui/VerifyScreen.kt
Normal 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") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
54
src/main/kotlin/com/bolenpad/photophetch/util/ConfigStore.kt
Normal file
54
src/main/kotlin/com/bolenpad/photophetch/util/ConfigStore.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
103
src/main/kotlin/com/bolenpad/photophetch/util/ExifFilter.kt
Normal file
103
src/main/kotlin/com/bolenpad/photophetch/util/ExifFilter.kt
Normal 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()
|
||||||
|
}
|
||||||
108
src/main/kotlin/com/bolenpad/photophetch/util/StagingPath.kt
Normal file
108
src/main/kotlin/com/bolenpad/photophetch/util/StagingPath.kt
Normal 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_\\-]"), "")
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user