# Živé vzpomínky z dovolené / Live Vacation Memories

## Vývojový plán mobilní aplikace pro Android

Tento dokument popisuje návrh a postup vytvoření Android aplikace **Živé vzpomínky z dovolené / Live Vacation Memories**.

Hlavní cíle první verze:

1. založit cestu,
2. uložit čas **Start / Finish**,
3. systémově najít fotografie pořízené fotoaparátem během cesty,
4. přečíst datum, čas a GPS z EXIF,
5. rozdělit fotografie podle dnů a míst,
6. umožnit sdílení originálních fotografií mezi účastníky nablízko,
7. zachovat EXIF a GPS metadata,
8. exportovat projekt pro desktopovou verzi.

---

# 1. Doporučený technologický základ

## Jazyk a uživatelské rozhraní

- Kotlin
- Jetpack Compose
- Material 3
- Android Studio
- minimální Android: API 26
- cílová verze: aktuální stabilní Android SDK

## Doporučené knihovny

```kotlin
// UI
implementation(platform("androidx.compose:compose-bom:..."))
implementation("androidx.compose.material3:material3")
implementation("androidx.navigation:navigation-compose:...")

// Databáze
implementation("androidx.room:room-runtime:...")
implementation("androidx.room:room-ktx:...")
ksp("androidx.room:room-compiler:...")

// Úlohy na pozadí
implementation("androidx.work:work-runtime-ktx:...")

// EXIF
implementation("androidx.exifinterface:exifinterface:...")

// Nearby Connections
implementation("com.google.android.gms:play-services-nearby:...")

// Náhledy fotografií
implementation("io.coil-kt:coil-compose:...")

// JSON
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:...")
```

---

# 2. Doporučená architektura

```text
app
├── data
│   ├── database
│   ├── media
│   ├── nearby
│   ├── export
│   └── repository
├── domain
│   ├── model
│   └── usecase
├── ui
│   ├── trips
│   ├── photos
│   ├── nearby
│   ├── map
│   └── settings
└── workers
```

Tok aplikace:

```text
Compose obrazovka
      ↓
ViewModel
      ↓
Use case
      ↓
Repository
      ↓
Room / MediaStore / Nearby Connections
```

Samotné fotografie se neukládají do Room databáze. Do databáze se ukládá pouze:

- URI souboru,
- metadata,
- hash,
- autor,
- přiřazení k cestě,
- stav sdílení.

---

# 3. Datový model

## Cesta

```kotlin
@Entity(tableName = "trips")
data class TripEntity(
    @PrimaryKey
    val id: String,

    val name: String,
    val startTimeUtc: Long?,
    val finishTimeUtc: Long?,

    val createdAtUtc: Long,
    val status: String,

    val ownerParticipantId: String,
    val shareCode: String?,

    val timezoneId: String?,
    val coverPhotoId: String? = null
)
```

```kotlin
enum class TripStatus {
    PLANNED,
    ACTIVE,
    FINISHED,
    ARCHIVED
}
```

## Účastník

```kotlin
@Entity(tableName = "participants")
data class ParticipantEntity(
    @PrimaryKey
    val id: String,

    val tripId: String,
    val displayName: String,
    val deviceId: String,
    val isOwner: Boolean,
    val joinedAtUtc: Long
)
```

## Fotografie

```kotlin
@Entity(tableName = "photos")
data class PhotoEntity(
    @PrimaryKey
    val id: String,

    val tripId: String,
    val authorParticipantId: String,

    val contentUri: String,
    val displayName: String,
    val mimeType: String,
    val sizeBytes: Long,

    val dateTakenUtc: Long?,
    val dateAddedUtc: Long?,

    val latitude: Double?,
    val longitude: Double?,
    val altitude: Double?,

    val width: Int?,
    val height: Int?,
    val orientation: Int?,

    val cameraMake: String?,
    val cameraModel: String?,

    val relativePath: String?,
    val ownerPackageName: String?,

    val sha256: String?,
    val perceptualHash: String?,

    val isLocalOriginal: Boolean,
    val sourceDeviceId: String,

    val importState: String,
    val shareState: String
)
```

## Místo

```kotlin
@Entity(tableName = "places")
data class PlaceEntity(
    @PrimaryKey
    val id: String,

    val tripId: String,
    val dayNumber: Int,

    val name: String?,
    val latitude: Double,
    val longitude: Double,

    val arrivalTimeUtc: Long?,
    val departureTimeUtc: Long?,

    val description: String? = null
)
```

---

# 4. Identita zařízení a uživatele

Při prvním spuštění vytvořit náhodné identifikátory:

```kotlin
val deviceId = UUID.randomUUID().toString()
val participantId = UUID.randomUUID().toString()
```

Uložit do DataStore.

Nepoužívat:

- IMEI,
- sériové číslo,
- jiné hardwarové identifikátory zařízení.

---

# 5. Oprávnění

## Přístup k fotografiím

Android 13 a novější:

```xml
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
```

Starší Android:

```xml
<uses-permission
    android:name="android.permission.READ_EXTERNAL_STORAGE"
    android:maxSdkVersion="32" />
```

Přístup k původním GPS údajům v EXIF:

```xml
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
```

## Nearby Connections

```xml
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />

<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />

<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" />
```

Oprávnění žádat až při použití příslušné funkce.

---

# 6. Hlavní obrazovky

## Seznam cest

```text
Živé vzpomínky

[ + Nová cesta ]

Skotsko 2027
Probíhá · den 4

Korea 2024
Dokončeno · 568 fotografií
```

## Založení cesty

Pole:

- název cesty,
- plánovaný začátek,
- plánovaný konec,
- jméno účastníka,
- režim soukromí.

Tlačítka:

```text
Založit a zahájit
Založit na později
```

## Aktivní cesta

```text
Skotsko 2027

Cesta probíhá
Od 1. 7. 2027, 05:42

Dnes:
38 nových fotografií
4 rozpoznaná místa

[ Zkontrolovat fotky ]
[ Přidat účastníka ]
[ Ukončit cestu ]
```

---

# 7. Implementace Start

Po stisku **Zahájit cestu**:

1. uložit aktuální UTC čas,
2. uložit časové pásmo,
3. změnit stav na `ACTIVE`,
4. naplánovat kontrolu nových fotografií.

```kotlin
suspend fun startTrip(tripId: String) {
    val now = System.currentTimeMillis()
    val timezoneId = ZoneId.systemDefault().id

    tripDao.startTrip(
        tripId = tripId,
        startTimeUtc = now,
        timezoneId = timezoneId,
        status = TripStatus.ACTIVE.name
    )

    enqueuePhotoScan(tripId)
}
```

DAO:

```kotlin
@Query("""
    UPDATE trips
    SET startTimeUtc = :startTimeUtc,
        timezoneId = :timezoneId,
        status = :status
    WHERE id = :tripId
""")
suspend fun startTrip(
    tripId: String,
    startTimeUtc: Long,
    timezoneId: String,
    status: String
)
```

---

# 8. Implementace Finish

Po stisku **Ukončit cestu**:

1. uložit aktuální čas,
2. změnit stav na `FINISHED`,
3. spustit kompletní kontrolu fotografií,
4. rozdělit fotografie podle dnů,
5. seskupit podle GPS,
6. zobrazit výsledný náhled.

```kotlin
suspend fun finishTrip(tripId: String) {
    val now = System.currentTimeMillis()

    tripDao.finishTrip(
        tripId = tripId,
        finishTimeUtc = now,
        status = TripStatus.FINISHED.name
    )

    enqueueFinalPhotoScan(tripId)
}
```

Začátek i konec cesty musí jít ručně upravit.

```kotlin
fun recalculateTrip(tripId: String) {
    WorkManager.getInstance(context)
        .enqueueUniqueWork(
            "trip-rescan-$tripId",
            ExistingWorkPolicy.REPLACE,
            createTripScanRequest(tripId)
        )
}
```

---

# 9. Systémové vyhledání fotografií v Androidu

## Základní princip

Aplikace se nesmí spoléhat pouze na pevnou cestu:

```text
/DCIM/Camera
```

Na různých telefonech mohou být fotografie například v:

```text
/DCIM/Camera
/DCIM/100MEDIA
/DCIM/OpenCamera
/DCIM/<název-aplikace>
```

Správné řešení je použít Android API **MediaStore**.

MediaStore poskytne:

- URI fotografie,
- název souboru,
- datum pořízení,
- datum přidání,
- relativní cestu,
- velikost,
- MIME typ,
- případně balíček aplikace, která soubor vytvořila.

Aplikace tedy nemusí znát skutečnou absolutní cestu souboru.

## Datový objekt kandidáta

```kotlin
data class MediaPhotoCandidate(
    val uri: Uri,
    val displayName: String,
    val mimeType: String?,
    val dateTaken: Long?,
    val dateAdded: Long?,
    val relativePath: String?,
    val ownerPackageName: String?,
    val size: Long
)
```

## Vyhledání fotografií podle data

```kotlin
fun queryPhotosForTrip(
    context: Context,
    startUtc: Long,
    finishUtc: Long
): List<MediaPhotoCandidate> {

    val collection = MediaStore.Images.Media.getContentUri(
        MediaStore.VOLUME_EXTERNAL
    )

    val projection = arrayOf(
        MediaStore.Images.Media._ID,
        MediaStore.Images.Media.DISPLAY_NAME,
        MediaStore.Images.Media.MIME_TYPE,
        MediaStore.Images.Media.DATE_TAKEN,
        MediaStore.Images.Media.DATE_ADDED,
        MediaStore.Images.Media.RELATIVE_PATH,
        MediaStore.Images.Media.OWNER_PACKAGE_NAME,
        MediaStore.Images.Media.SIZE
    )

    val selection = """
        ${MediaStore.Images.Media.DATE_TAKEN} >= ?
        AND ${MediaStore.Images.Media.DATE_TAKEN} <= ?
    """.trimIndent()

    val selectionArgs = arrayOf(
        startUtc.toString(),
        finishUtc.toString()
    )

    val sortOrder =
        "${MediaStore.Images.Media.DATE_TAKEN} ASC"

    val result = mutableListOf<MediaPhotoCandidate>()

    context.contentResolver.query(
        collection,
        projection,
        selection,
        selectionArgs,
        sortOrder
    )?.use { cursor ->

        val idColumn = cursor.getColumnIndexOrThrow(
            MediaStore.Images.Media._ID
        )
        val nameColumn = cursor.getColumnIndexOrThrow(
            MediaStore.Images.Media.DISPLAY_NAME
        )
        val mimeColumn = cursor.getColumnIndexOrThrow(
            MediaStore.Images.Media.MIME_TYPE
        )
        val dateTakenColumn = cursor.getColumnIndexOrThrow(
            MediaStore.Images.Media.DATE_TAKEN
        )
        val dateAddedColumn = cursor.getColumnIndexOrThrow(
            MediaStore.Images.Media.DATE_ADDED
        )
        val pathColumn = cursor.getColumnIndexOrThrow(
            MediaStore.Images.Media.RELATIVE_PATH
        )
        val ownerColumn = cursor.getColumnIndexOrThrow(
            MediaStore.Images.Media.OWNER_PACKAGE_NAME
        )
        val sizeColumn = cursor.getColumnIndexOrThrow(
            MediaStore.Images.Media.SIZE
        )

        while (cursor.moveToNext()) {
            val id = cursor.getLong(idColumn)

            val uri = ContentUris.withAppendedId(
                collection,
                id
            )

            result += MediaPhotoCandidate(
                uri = uri,
                displayName = cursor.getString(nameColumn),
                mimeType = cursor.getStringOrNull(mimeColumn),
                dateTaken = cursor.getLongOrNull(dateTakenColumn),
                dateAdded = cursor.getLongOrNull(dateAddedColumn)
                    ?.times(1000),
                relativePath = cursor.getStringOrNull(pathColumn),
                ownerPackageName = cursor.getStringOrNull(ownerColumn),
                size = cursor.getLong(sizeColumn)
            )
        }
    }

    return result
}
```

Pomocné funkce:

```kotlin
fun Cursor.getLongOrNull(index: Int): Long? =
    if (isNull(index)) null else getLong(index)

fun Cursor.getStringOrNull(index: Int): String? =
    if (isNull(index)) null else getString(index)
```

## Záložní dotaz pro fotografie bez DATE_TAKEN

Některé soubory nemusí mít vyplněné `DATE_TAKEN`. Potom je vhodné provést druhý dotaz podle `DATE_ADDED`.

Pozor: `DATE_ADDED` je v sekundách.

```kotlin
fun queryPhotosByDateAdded(
    context: Context,
    startUtc: Long,
    finishUtc: Long
): List<MediaPhotoCandidate> {

    val startSeconds = startUtc / 1000
    val finishSeconds = finishUtc / 1000

    val selection = """
        ${MediaStore.Images.Media.DATE_TAKEN} IS NULL
        AND ${MediaStore.Images.Media.DATE_ADDED} >= ?
        AND ${MediaStore.Images.Media.DATE_ADDED} <= ?
    """.trimIndent()

    val selectionArgs = arrayOf(
        startSeconds.toString(),
        finishSeconds.toString()
    )

    // Použije se stejná projection a zpracování cursoru jako výše.
    return emptyList()
}
```

## Zjištění systémové relativní cesty

Android vrací relativní cestu například:

```text
DCIM/Camera/
DCIM/OpenCamera/
Pictures/LiveVacationMemories/
```

```kotlin
fun getRelativePath(
    context: Context,
    uri: Uri
): String? {

    val projection = arrayOf(
        MediaStore.Images.Media.RELATIVE_PATH
    )

    context.contentResolver.query(
        uri,
        projection,
        null,
        null,
        null
    )?.use { cursor ->

        if (cursor.moveToFirst()) {
            val column = cursor.getColumnIndex(
                MediaStore.Images.Media.RELATIVE_PATH
            )

            if (column >= 0 && !cursor.isNull(column)) {
                return cursor.getString(column)
            }
        }
    }

    return null
}
```

Toto je doporučený způsob systémového zjištění umístění fotografie. Absolutní filesystemová cesta se na moderním Androidu nemá používat jako hlavní identifikátor.

---

# 10. Jak vybrat pouze fotografie z fotoaparátu

## Vyloučené složky

```kotlin
private val excludedPathParts = listOf(
    "screenshots",
    "download",
    "whatsapp",
    "telegram",
    "instagram",
    "facebook",
    "messenger"
)
```

## Pravděpodobné složky fotoaparátu

```kotlin
private val cameraPathParts = listOf(
    "dcim/camera",
    "dcim/100media",
    "dcim/opencamera",
    "dcim"
)
```

## Filtr

```kotlin
fun isLikelyCameraPhoto(
    photo: MediaPhotoCandidate
): Boolean {

    val path = photo.relativePath
        ?.lowercase()
        ?.replace("\\", "/")
        .orEmpty()

    if (excludedPathParts.any(path::contains)) {
        return false
    }

    return cameraPathParts.any(path::contains)
}
```

## Doporučené skórování

```kotlin
fun cameraPhotoScore(
    photo: MediaPhotoCandidate,
    exif: ExifMetadata?
): Int {

    var score = 0

    val path = photo.relativePath
        ?.lowercase()
        .orEmpty()

    if (path.contains("dcim/camera")) score += 5
    else if (path.contains("dcim")) score += 3

    if (excludedPathParts.any(path::contains)) score -= 10

    if (!exif?.cameraModel.isNullOrBlank()) score += 2
    if (!exif?.cameraMake.isNullOrBlank()) score += 1
    if (exif?.dateTaken != null) score += 2
    if (photo.dateTaken != null) score += 1

    return score
}
```

Doporučené rozdělení:

```text
5 bodů a více: pravděpodobně fotoaparát
2 až 4 body: nejistý původ
méně než 2 body: nezahrnout automaticky
```

---

# 11. Čtení EXIF a GPS

```kotlin
data class ExifMetadata(
    val dateTaken: Long?,
    val latitude: Double?,
    val longitude: Double?,
    val altitude: Double?,
    val cameraMake: String?,
    val cameraModel: String?,
    val orientation: Int?
)
```

```kotlin
fun readExif(
    context: Context,
    uri: Uri
): ExifMetadata {

    context.contentResolver
        .openFileDescriptor(uri, "r")
        ?.use { descriptor ->

            val exif = ExifInterface(
                descriptor.fileDescriptor
            )

            val coordinates = exif.latLong

            return ExifMetadata(
                dateTaken = parseExifDate(
                    exif.getAttribute(
                        ExifInterface.TAG_DATETIME_ORIGINAL
                    )
                ),
                latitude = coordinates?.getOrNull(0),
                longitude = coordinates?.getOrNull(1),
                altitude = exif.getAltitude(Double.NaN)
                    .takeUnless { it.isNaN() },
                cameraMake = exif.getAttribute(
                    ExifInterface.TAG_MAKE
                ),
                cameraModel = exif.getAttribute(
                    ExifInterface.TAG_MODEL
                ),
                orientation = exif.getAttributeInt(
                    ExifInterface.TAG_ORIENTATION,
                    ExifInterface.ORIENTATION_UNDEFINED
                )
            )
        }

    return ExifMetadata(
        dateTaken = null,
        latitude = null,
        longitude = null,
        altitude = null,
        cameraMake = null,
        cameraModel = null,
        orientation = null
    )
}
```

Priorita času:

```text
1. EXIF DateTimeOriginal
2. MediaStore DATE_TAKEN
3. MediaStore DATE_ADDED
```

---

# 12. Zpracování fotografií na pozadí

Doporučený řetězec WorkManager úloh:

```text
MediaStoreScanWorker
        ↓
ExifReaderWorker
        ↓
HashWorker
        ↓
PlaceClusteringWorker
        ↓
ThumbnailWorker
```

```kotlin
val scan = OneTimeWorkRequestBuilder<MediaStoreScanWorker>()
    .setInputData(workDataOf("tripId" to tripId))
    .build()

val metadata = OneTimeWorkRequestBuilder<ExifReaderWorker>()
    .setInputData(workDataOf("tripId" to tripId))
    .build()

val clustering = OneTimeWorkRequestBuilder<PlaceClusteringWorker>()
    .setInputData(workDataOf("tripId" to tripId))
    .build()

WorkManager.getInstance(context)
    .beginUniqueWork(
        "trip-processing-$tripId",
        ExistingWorkPolicy.REPLACE,
        scan
    )
    .then(metadata)
    .then(clustering)
    .enqueue()
```

---

# 13. Rozdělení fotografií do dnů

```kotlin
fun dayKey(
    timestampUtc: Long,
    zoneId: ZoneId
): LocalDate {
    return Instant.ofEpochMilli(timestampUtc)
        .atZone(zoneId)
        .toLocalDate()
}
```

---

# 14. Seskupování podle míst

Pro první verzi:

- vzdálenost do 300 až 500 metrů,
- časový odstup nejvýše 2 až 3 hodiny.

```kotlin
fun distanceMeters(
    lat1: Double,
    lon1: Double,
    lat2: Double,
    lon2: Double
): Double {

    val earthRadius = 6_371_000.0

    val phi1 = Math.toRadians(lat1)
    val phi2 = Math.toRadians(lat2)

    val deltaPhi = Math.toRadians(lat2 - lat1)
    val deltaLambda = Math.toRadians(lon2 - lon1)

    val a =
        sin(deltaPhi / 2).pow(2) +
        cos(phi1) *
        cos(phi2) *
        sin(deltaLambda / 2).pow(2)

    return 2 * earthRadius *
        atan2(sqrt(a), sqrt(1 - a))
}
```

---

# 15. Společná cesta

```kotlin
data class TripInvitation(
    val tripId: String,
    val tripName: String,
    val ownerName: String,
    val invitationToken: String,
    val protocolVersion: Int
)
```

QR odkaz:

```text
lvm://join?trip=<tripId>&token=<token>
```

---

# 16. Nearby Connections

Doporučená strategie:

```kotlin
Strategy.P2P_STAR
```

- vlastník cesty je hostitel,
- ostatní účastníci jsou klienti.

```kotlin
class NearbyShareManager(
    private val context: Context
) {
    private val client: ConnectionsClient =
        Nearby.getConnectionsClient(context)

    private val serviceId =
        "cz.coolcode.livevacationmemories"
}
```

## Hostitel

```kotlin
fun startAdvertising(displayName: String) {
    val options = AdvertisingOptions.Builder()
        .setStrategy(Strategy.P2P_STAR)
        .build()

    client.startAdvertising(
        displayName,
        serviceId,
        connectionLifecycleCallback,
        options
    )
}
```

## Účastník

```kotlin
fun startDiscovery() {
    val options = DiscoveryOptions.Builder()
        .setStrategy(Strategy.P2P_STAR)
        .build()

    client.startDiscovery(
        serviceId,
        endpointDiscoveryCallback,
        options
    )
}
```

---

# 17. Ověření spojení

```kotlin
private val connectionLifecycleCallback =
    object : ConnectionLifecycleCallback() {

        override fun onConnectionInitiated(
            endpointId: String,
            connectionInfo: ConnectionInfo
        ) {
            showVerificationDialog(
                endpointId = endpointId,
                deviceName = connectionInfo.endpointName,
                code = connectionInfo.authenticationDigits
            )
        }

        override fun onConnectionResult(
            endpointId: String,
            result: ConnectionResolution
        ) {
            if (result.status.isSuccess) {
                onConnected(endpointId)
            } else {
                onConnectionFailed(endpointId)
            }
        }

        override fun onDisconnected(endpointId: String) {
            onDeviceDisconnected(endpointId)
        }
    }
```

Po potvrzení:

```kotlin
client.acceptConnection(
    endpointId,
    payloadCallback
)
```

---

# 18. Komunikační protokol

```kotlin
enum class MessageType {
    HELLO,
    TRIP_INVITATION,
    PARTICIPANT_INFO,
    PHOTO_CATALOG,
    PHOTO_REQUEST,
    PHOTO_METADATA,
    PHOTO_TRANSFER_STARTED,
    PHOTO_TRANSFER_FINISHED,
    ERROR
}
```

```kotlin
@Serializable
data class ProtocolMessage(
    val protocolVersion: Int = 1,
    val type: MessageType,
    val messageId: String,
    val tripId: String,
    val senderParticipantId: String,
    val payload: String
)
```

---

# 19. Sdílení náhledů

```kotlin
@Serializable
data class SharedPhotoDescriptor(
    val photoId: String,
    val displayName: String,
    val dateTakenUtc: Long?,
    val latitude: Double?,
    val longitude: Double?,
    val width: Int?,
    val height: Int?,
    val originalSize: Long,
    val sha256: String,
    val authorName: String
)
```

Filtry:

```text
Všechny fotografie
Pouze fotografie s GPS
Pouze vybraný den
Pouze chybějící místa
Pouze označené fotografie
```

---

# 20. Přenos originálních fotografií

```kotlin
fun sendPhoto(
    endpointId: String,
    uri: Uri
) {
    val descriptor = context.contentResolver
        .openFileDescriptor(uri, "r")
        ?: return

    val payload = Payload.fromFile(descriptor)

    activeDescriptors[payload.id] = descriptor

    client.sendPayload(
        endpointId,
        payload
    )
}
```

`ParcelFileDescriptor` zavřít až po dokončení přenosu.

---

# 21. Metadata k přenášenému souboru

```kotlin
@Serializable
data class IncomingFileHeader(
    val payloadId: Long,
    val photoId: String,
    val fileName: String,
    val mimeType: String,
    val sha256: String,
    val authorParticipantId: String,
    val dateTakenUtc: Long?,
    val latitude: Double?,
    val longitude: Double?
)
```

Postup:

```text
1. metadata jako byte payload
2. fotografie jako file payload
3. kontrola SHA-256
4. uložení do MediaStore
5. potvrzení dokončení
```

---

# 22. Uložení přijatých fotografií

```text
Pictures/LiveVacationMemories/<název-cesty>/
```

```kotlin
fun createDestinationUri(
    context: Context,
    tripName: String,
    fileName: String,
    mimeType: String
): Uri? {

    val values = ContentValues().apply {
        put(
            MediaStore.Images.Media.DISPLAY_NAME,
            fileName
        )
        put(
            MediaStore.Images.Media.MIME_TYPE,
            mimeType
        )
        put(
            MediaStore.Images.Media.RELATIVE_PATH,
            "Pictures/LiveVacationMemories/$tripName"
        )
        put(
            MediaStore.Images.Media.IS_PENDING,
            1
        )
    }

    return context.contentResolver.insert(
        MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
        values
    )
}
```

Po dokončení:

```kotlin
val values = ContentValues().apply {
    put(MediaStore.Images.Media.IS_PENDING, 0)
}

contentResolver.update(
    destinationUri,
    values,
    null,
    null
)
```

---

# 23. Zachování EXIF a GPS

Přenášet původní soubor beze změny.

```kotlin
inputStream.use { input ->
    outputStream.use { output ->
        input.copyTo(output)
    }
}
```

Nepoužívat dekódování a opětovnou kompresi bitmapy.

Po přenosu:

1. znovu přečíst EXIF,
2. ověřit datum,
3. ověřit GPS,
4. ověřit hash.

---

# 24. SHA-256 a duplicity

```kotlin
fun calculateSha256(
    context: Context,
    uri: Uri
): String {

    val digest =
        MessageDigest.getInstance("SHA-256")

    context.contentResolver
        .openInputStream(uri)
        ?.use { input ->

            val buffer = ByteArray(64 * 1024)

            while (true) {
                val count = input.read(buffer)
                if (count <= 0) break

                digest.update(buffer, 0, count)
            }
        }

    return digest.digest()
        .joinToString("") {
            "%02x".format(it)
        }
}
```

Před přenosem:

```kotlin
photoDao.existsBySha256(hash)
```

---

# 25. Fronta přenosů

```kotlin
@Entity(tableName = "transfers")
data class TransferEntity(
    @PrimaryKey
    val id: String,

    val tripId: String,
    val photoId: String,

    val endpointId: String?,
    val direction: String,

    val payloadId: Long?,
    val bytesTotal: Long,
    val bytesTransferred: Long,

    val status: String,
    val errorMessage: String?,

    val createdAtUtc: Long,
    val updatedAtUtc: Long
)
```

```kotlin
enum class TransferStatus {
    QUEUED,
    WAITING_FOR_CONNECTION,
    TRANSFERRING,
    VERIFYING,
    COMPLETED,
    FAILED,
    CANCELED
}
```

---

# 26. Soukromí

Výchozí stav:

```text
Neodesílat fotografie automaticky.
```

Volby:

```text
[✓] Sdílet GPS metadata
[✓] Sdílet datum a čas
[ ] Povolit další sdílení
[ ] Automaticky nabízet nové fotografie
```

---

# 27. Export pro desktop

```text
Skotsko-2027.lvm.zip
├── manifest.json
├── trip.json
├── participants.json
├── photos.json
├── places.json
├── thumbnails/
└── originals/
```

```json
{
  "format": "live-vacation-memories",
  "version": 1,
  "createdBy": "android",
  "createdAt": "2027-07-11T10:15:00Z"
}
```

---

# 28. Doporučené pořadí vývoje

## Etapa 1 – základ

1. Kotlin + Compose.
2. Navigation.
3. Room.
4. DataStore.
5. seznam cest.
6. vytvoření cesty.
7. Start / Finish.

## Etapa 2 – fotografie

1. oprávnění,
2. MediaStore,
3. systémové vyhledání relativních cest,
4. filtrování podle období,
5. filtrování fotoaparátu,
6. EXIF,
7. GPS,
8. galerie náhledů.

## Etapa 3 – dny a místa

1. rozdělení podle dnů,
2. seskupení podle GPS,
3. seznam míst,
4. mapa,
5. ruční opravy.

## Etapa 4 – společná cesta

1. účastníci,
2. pozvánka,
3. QR kód,
4. Nearby advertising,
5. Nearby discovery,
6. ověřovací kód,
7. protokolové zprávy.

## Etapa 5 – sdílení fotografií

1. katalog,
2. náhledy,
3. výběr,
4. přenos originálů,
5. uložení,
6. SHA-256,
7. duplicity,
8. fronta přenosů.

## Etapa 6 – export

1. JSON formát,
2. ZIP archiv,
3. export,
4. import v desktopové verzi.

---

# 29. Testovací scénáře

## Start / Finish

- cesta během jednoho dne,
- cesta přes půlnoc,
- změna časového pásma,
- zapomenutý Finish,
- ruční změna období,
- fotografie bez GPS,
- fotografie bez DATE_TAKEN.

## MediaStore

- Pixel,
- Samsung,
- Xiaomi,
- fotoaparát třetí strany,
- SD karta,
- JPEG,
- HEIC,
- RAW,
- jiná relativní cesta než `DCIM/Camera`.

## Nearby

- dva telefony,
- více účastníků,
- vypnuté Bluetooth,
- vypnutá Wi-Fi,
- aplikace na pozadí,
- přerušený přenos,
- duplicitní soubor,
- nedostatek místa.

## EXIF

- GPS přítomna,
- GPS chybí,
- poškozený EXIF,
- nesprávná orientace,
- přenos zachová SHA-256.

---

# 30. Rozsah první publikovatelné verze

Zahrnout:

- vytvoření cesty,
- Start / Finish,
- ruční opravu období,
- systémové vyhledání fotek přes MediaStore,
- zjištění relativní cesty,
- automatický výběr fotek z kamery,
- čtení data a GPS,
- rozdělení podle dnů,
- jednoduché seskupení míst,
- mapu,
- společnou cestu,
- připojení účastníků nablízko,
- přenos náhledů,
- ruční výběr,
- přenos originálů,
- zachování EXIF,
- duplicity,
- export projektu.

Zatím vynechat:

- cloud,
- přenos přes mobilní data,
- AI,
- rozpoznávání obličejů,
- průběžný záznam GPS na pozadí,
- automatické sdílení bez potvrzení,
- iOS.

Hlavní tok aplikace:

```text
Založit cestu
→ Start
→ fotografovat
→ Finish
→ MediaStore najde fotografie
→ EXIF doplní čas a GPS
→ spojit telefony nablízko
→ vybrat fotografie ostatních
→ přenést originály
→ vytvořit společný projekt
```
