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