diff --git a/.gitignore b/.gitignore index 6da98a1..d65d08c 100644 --- a/.gitignore +++ b/.gitignore @@ -15,7 +15,9 @@ __pycache__/ # Local data backends (defaults) galo.db galo.db-* -data/ +# Root-level local FS storage (FS_ROOT=./data); anchored so it does not match +# the Android source package android/.../com/galo/android/data/. +/data/ # Android build output and local toolchain config android/.gradle/ diff --git a/android/README.md b/android/README.md index 085970b..6dbda6e 100644 --- a/android/README.md +++ b/android/README.md @@ -100,6 +100,48 @@ While an album screen is active the client holds `GET /api/sse/albums/:id?token=` and backfills missed changes via `GET /api/albums/:id/photos?since=` after a reconnect. +## Device albums and synchronization + +The album list mixes the server's albums with the device's own albums (MediaStore +buckets). Each card is tagged with its source (`Сервер` / `Телефон`), and a linked +pair shows the connection both ways: the device album shows `→ `, the +server album shows `источник: `. + +- **Access.** Enumerating device albums needs media read access: `READ_MEDIA_IMAGES` + and `READ_MEDIA_VIDEO` on Android 13+, or `READ_EXTERNAL_STORAGE` on Android 12 + and lower. The app requests it at runtime; without (or with partial) access the + device albums are simply not shown, and server albums keep working. +- **Synchronization modes** are chosen per device album from the album screen + (swipe right, or the settings icon): + - **Выключена (A)** — local only. Previews are generated on the device and cached + in the app's private cache; the album makes **no server requests**. + - **Копии оригиналов на сервере (B)** — originals stay on the device, copies are + uploaded to a chosen server album. + - **Перенос оригиналов на сервер (C)** — originals move to the server; the device + keeps previews. Freeing space replaces the original in MediaStore only after the + user confirms the OS write request, in batches. +- Previews are capped at a 1600px long side (JPEG q80) and generated on the device, + including a poster frame for videos. +- **Deduplication.** A device asset is matched to a server photo by the sha256 of the + bytes that leave the device (the server exposes each original's checksum). Content + already present is linked instead of re-uploaded; an edited asset is re-uploaded and + its previous upload removed; a pure rename is propagated via `PATCH /api/photos/:id`. +- **Deletions.** The server photo is deleted only when this client uploaded it, its + content is gone from the device and no other linked album still holds it. Photos the + client did not upload are never touched, and a photo already gone from the server is + only reflected in the local index. +- **Background.** Synchronization runs on app start and while the app is open (a + MediaStore content observer), and periodically in the background via WorkManager. + The periodic pass honors the **Только Wi-Fi** setting. Freeing space in mode C is + never automatic — it always waits for the user's confirmation. +- **Provenance.** Mode C writes a marker into the EXIF of the preview it leaves + behind (server album/photo, original hash, mode). After a reinstall the app reads + these markers to restore the album link and which uploads are its own; unmarked + media is left alone. +- **Reversibility.** Leaving mode C warns first and offers to fetch the originals + back from the server (space permitting); declining keeps previews only. Unlinking + an album drops the local link and never deletes photos on the server. + ## Testing ```sh diff --git a/android/app/src/androidTest/java/com/galo/android/DeviceSyncE2ETest.kt b/android/app/src/androidTest/java/com/galo/android/DeviceSyncE2ETest.kt new file mode 100644 index 0000000..3080b5c --- /dev/null +++ b/android/app/src/androidTest/java/com/galo/android/DeviceSyncE2ETest.kt @@ -0,0 +1,232 @@ +package com.galo.android + +import android.content.ContentResolver +import android.content.ContentUris +import android.content.ContentValues +import android.content.Context +import android.graphics.Bitmap +import android.net.Uri +import android.provider.MediaStore +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import android.util.Log +import com.galo.android.data.db.entity.DeviceAssetEntity +import com.galo.android.domain.model.LocalAssetState +import com.galo.android.domain.model.SyncMode +import com.galo.android.ui.devicealbum.DeviceAlbumViewModel +import com.galo.android.ui.devicealbum.OriginalSource +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** + * End-to-end device-album synchronization against a running galo instance at + * 10.0.2.2:8088: a device album is linked to a server album, mode B uploads its + * content through the real queue, and a second pass does not duplicate it. + */ +@RunWith(AndroidJUnit4::class) +class DeviceSyncE2ETest { + + private lateinit var context: Context + private lateinit var resolver: ContentResolver + private val created = mutableListOf() + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + resolver = context.contentResolver + } + + @After + fun tearDown() { + created.forEach { runCatching { resolver.delete(it, null, null) } } + } + + private fun insertImage(): Pair { + val values = ContentValues().apply { + put(MediaStore.Images.Media.DISPLAY_NAME, "sync-${System.nanoTime()}.jpg") + put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg") + put(MediaStore.Images.Media.RELATIVE_PATH, "DCIM/GaloSync/") + put(MediaStore.Images.Media.IS_PENDING, 1) + } + val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)!! + val bitmap = Bitmap.createBitmap(320, 240, Bitmap.Config.ARGB_8888) + resolver.openOutputStream(uri)!!.use { bitmap.compress(Bitmap.CompressFormat.JPEG, 90, it) } + values.clear() + values.put(MediaStore.Images.Media.IS_PENDING, 0) + resolver.update(uri, values, null, null) + created += uri + val bucket = resolver.query( + uri, + arrayOf(MediaStore.Images.Media.BUCKET_ID), + null, + null, + null, + )!!.use { cursor -> + cursor.moveToFirst() + cursor.getString(0) + } + return uri to bucket + } + + @Test + fun copyModeUploadsDeviceAlbumAndDeduplicates() = runBlocking { + val app = ApplicationProvider.getApplicationContext() + val container = app.container + Log.i(TAG_LOG, "configure") + E2E.configure(container) + Log.i(TAG_LOG, "register") + val token = E2E.registerAuthenticated(container) + Log.i(TAG_LOG, "createAlbum") + val serverAlbum = E2E.createAlbum(token, "Device sync ${System.currentTimeMillis()}") + + Log.i(TAG_LOG, "insert") + val (_, bucket) = insertImage() + Log.i(TAG_LOG, "link") + container.syncRepository.link(bucket, "Sync", serverAlbum, SyncMode.COPY) + + Log.i(TAG_LOG, "reconcile") + val contacted = withTimeout(30_000) { container.syncCoordinator.reconcile(bucket) } + Log.i(TAG_LOG, "reconcile=$contacted") + assertTrue("reconcile should contact the server", contacted) + + Log.i(TAG_LOG, "await photos") + val uploaded = awaitPhotos(token, serverAlbum, minimum = 1) + Log.i(TAG_LOG, "uploaded=$uploaded") + assertTrue("device photo should reach the server", uploaded >= 1) + + // A second pass must not duplicate content already on the server. + Log.i(TAG_LOG, "second reconcile") + container.syncCoordinator.reconcile(bucket) + delay(2_000) + val afterSecond = E2E.listPhotos(token, serverAlbum).size + Log.i(TAG_LOG, "afterSecond=$afterSecond") + assertEquals(uploaded, afterSecond) + Log.i(TAG_LOG, "done") + Unit + } + + private suspend fun awaitPhotos(token: String, albumId: String, minimum: Int): Int { + val deadline = System.currentTimeMillis() + 60_000 + while (System.currentTimeMillis() < deadline) { + val count = E2E.listPhotos(token, albumId).size + if (count >= minimum) return count + delay(500) + } + return E2E.listPhotos(token, albumId).size + } + + @Test + fun moveModeUploadsThenOffloadsTheOriginal() = runBlocking { + val app = ApplicationProvider.getApplicationContext() + val container = app.container + E2E.configure(container) + val token = E2E.registerAuthenticated(container) + val serverAlbum = E2E.createAlbum(token, "Move ${System.currentTimeMillis()}") + + val (uri, bucket) = insertImage() + val key = DeviceAssetEntity.key(bucket, ContentUris.parseId(uri)) + container.syncRepository.link(bucket, "Move", serverAlbum, SyncMode.MOVE) + + assertTrue("reconcile should contact the server", container.syncCoordinator.reconcile(bucket)) + assertTrue("photo should reach the server", awaitPhotos(token, serverAlbum, minimum = 1) >= 1) + + // The original is offloaded automatically right after the upload. + val offloaded = waitForState(container, bucket, key, LocalAssetState.PREVIEW) + assertTrue("asset should be offloaded automatically", offloaded) + } + + @Test + fun switchingFromCopiesToMoveOffloadsExistingUploads() = runBlocking { + val app = ApplicationProvider.getApplicationContext() + val container = app.container + E2E.configure(container) + val token = E2E.registerAuthenticated(container) + val serverAlbum = E2E.createAlbum(token, "CopyThenMove ${System.currentTimeMillis()}") + + val (uri, bucket) = insertImage() + val key = DeviceAssetEntity.key(bucket, ContentUris.parseId(uri)) + + // First, copies: upload while keeping the original. + container.syncRepository.link(bucket, "C", serverAlbum, SyncMode.COPY) + assertTrue(container.syncCoordinator.reconcile(bucket)) + assertTrue("photo should reach the server", awaitPhotos(token, serverAlbum, minimum = 1) >= 1) + assertTrue("upload should be recorded", waitForState(container, bucket, key, LocalAssetState.UPLOADED)) + + // Switch to move: the already-uploaded asset is offloaded. + container.syncRepository.link(bucket, "C", serverAlbum, SyncMode.MOVE) + assertTrue(container.syncCoordinator.reconcile(bucket)) + assertTrue( + "existing upload should be offloaded on switching to move", + waitForState(container, bucket, key, LocalAssetState.PREVIEW), + ) + } + + @Test + fun offloadedAssetExposesTheServerOriginalForZoom() = runBlocking { + val app = ApplicationProvider.getApplicationContext() + val container = app.container + E2E.configure(container) + val token = E2E.registerAuthenticated(container) + val serverAlbum = E2E.createAlbum(token, "Zoom ${System.currentTimeMillis()}") + + val (uri, bucket) = insertImage() + val mediaId = ContentUris.parseId(uri) + val key = DeviceAssetEntity.key(bucket, mediaId) + container.syncRepository.link(bucket, "Z", serverAlbum, SyncMode.MOVE) + assertTrue(container.syncCoordinator.reconcile(bucket)) + assertTrue("photo should reach the server", awaitPhotos(token, serverAlbum, minimum = 1) >= 1) + assertTrue("asset should be offloaded", waitForState(container, bucket, key, LocalAssetState.PREVIEW)) + + val viewModel = DeviceAlbumViewModel(container, bucket) + val collector = launch { viewModel.state.collect { } } + try { + var source: OriginalSource? = null + val deadline = System.currentTimeMillis() + 8_000 + while (System.currentTimeMillis() < deadline) { + source = viewModel.originalSource(mediaId) + if (source != null) break + delay(200) + } + assertNotNull("an offloaded asset must expose the server original", source) + assertTrue("the natural size must be known", source!!.width > 0 && source!!.height > 0) + // The URL must actually be reachable and return the original bytes. + val bytes = okhttp3.OkHttpClient().newCall( + okhttp3.Request.Builder().url(source!!.url).build(), + ).execute().use { response -> + assertTrue("original should be fetched, HTTP ${response.code}", response.isSuccessful) + response.body?.bytes()?.size ?: 0 + } + assertTrue("original should not be empty", bytes > 0) + } finally { + collector.cancel() + } + } + + private suspend fun waitForState( + container: com.galo.android.di.AppContainer, + bucket: String, + key: String, + state: LocalAssetState, + ): Boolean { + val deadline = System.currentTimeMillis() + 30_000 + while (System.currentTimeMillis() < deadline) { + val asset = container.syncRepository.assets(bucket).firstOrNull { it.assetKey == key } + if (asset?.state == state.name) return true + delay(300) + } + return false + } + + private companion object { + const val TAG_LOG = "GaloSyncE2E" + } +} diff --git a/android/app/src/androidTest/java/com/galo/android/E2E.kt b/android/app/src/androidTest/java/com/galo/android/E2E.kt index bf4bbf6..5334098 100644 --- a/android/app/src/androidTest/java/com/galo/android/E2E.kt +++ b/android/app/src/androidTest/java/com/galo/android/E2E.kt @@ -66,8 +66,7 @@ object E2E { } } - fun createAlbum(token: String, title: String): String { - val payload = """{"title":"$title"}""".toRequestBody("application/json".toMediaType()) + fun createAlbum(token: String, title: String): String { val payload = """{"title":"$title"}""".toRequestBody("application/json".toMediaType()) val request = Request.Builder() .url("$BASE_URL/api/albums") .header("Authorization", "Bearer $token") @@ -79,8 +78,7 @@ object E2E { } } - fun upload(token: String, albumId: String, filename: String, bytes: ByteArray, mime: String) { - val multipart = MultipartBody.Builder() + fun upload(token: String, albumId: String, filename: String, bytes: ByteArray, mime: String) { val multipart = MultipartBody.Builder() .setType(MultipartBody.FORM) .addFormDataPart("files", filename, bytes.toRequestBody(mime.toMediaType())) .build() @@ -95,6 +93,17 @@ object E2E { } } + fun listPhotos(token: String, albumId: String): List { + val request = Request.Builder() + .url("$BASE_URL/api/albums/$albumId/photos?limit=50") + .header("Authorization", "Bearer $token") + .build() + client.newCall(request).execute().use { response -> + val body = response.body?.string().orEmpty() + return GaloJson.decodeFromString(body).photos + } + } + fun awaitPhotoWithPlayback(token: String, albumId: String, timeoutMs: Long): PhotoDto { val deadline = System.currentTimeMillis() + timeoutMs while (System.currentTimeMillis() < deadline) { diff --git a/android/app/src/androidTest/java/com/galo/android/E2EFlowTest.kt b/android/app/src/androidTest/java/com/galo/android/E2EFlowTest.kt index 7c664bc..1cb7762 100644 --- a/android/app/src/androidTest/java/com/galo/android/E2EFlowTest.kt +++ b/android/app/src/androidTest/java/com/galo/android/E2EFlowTest.kt @@ -22,6 +22,7 @@ import com.galo.android.ui.album.TAG_PHOTO_PREFIX import com.galo.android.ui.albums.TAG_ALBUM_CARD_PREFIX import com.galo.android.ui.albums.TAG_ALBUMS_GRID import com.galo.android.ui.auth.TAG_AUTH_EMAIL +import com.galo.android.ui.auth.TAG_AUTH_IDENTIFIER import com.galo.android.ui.auth.TAG_AUTH_NAME import com.galo.android.ui.auth.TAG_AUTH_PASSWORD import com.galo.android.ui.auth.TAG_AUTH_SUBMIT @@ -70,7 +71,7 @@ class E2EFlowTest { // Registration. compose.waitUntil(TIMEOUT) { - compose.onAllNodesWithTag(TAG_AUTH_EMAIL).fetchSemanticsNodes().isNotEmpty() + compose.onAllNodesWithTag(TAG_AUTH_IDENTIFIER).fetchSemanticsNodes().isNotEmpty() } compose.onNodeWithText("Создать аккаунт").performClick() compose.waitUntil(TIMEOUT) { diff --git a/android/app/src/androidTest/java/com/galo/android/ModePersistenceTest.kt b/android/app/src/androidTest/java/com/galo/android/ModePersistenceTest.kt new file mode 100644 index 0000000..6fc1502 --- /dev/null +++ b/android/app/src/androidTest/java/com/galo/android/ModePersistenceTest.kt @@ -0,0 +1,69 @@ +package com.galo.android + +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.galo.android.domain.model.SyncMode +import com.galo.android.ui.devicealbum.DeviceAlbumViewModel +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Choosing a synchronization mode must persist even before a server album is + * picked, so leaving and reopening the album keeps the choice. + */ +@RunWith(AndroidJUnit4::class) +class ModePersistenceTest { + + @Test + fun turningSyncOffClearsTheLinkedAlbum() = runBlocking { + val app = ApplicationProvider.getApplicationContext() + val container = app.container + val albumId = "off-${System.currentTimeMillis()}" + val viewModel = DeviceAlbumViewModel(container, albumId) + + try { + container.syncRepository.link(albumId, "Camera", "srv1", SyncMode.COPY) + viewModel.setMode(SyncMode.DISABLED) + + var cleared = false + val deadline = System.currentTimeMillis() + 5_000 + while (System.currentTimeMillis() < deadline) { + if (container.syncRepository.currentLink(albumId) == null) { + cleared = true + break + } + delay(100) + } + assertTrue("switching sync off should clear the linked album", cleared) + } finally { + container.syncRepository.unlink(albumId) + } + } + + @Test + fun modePersistsBeforeTargetChosen() = runBlocking { + val app = ApplicationProvider.getApplicationContext() + val container = app.container + val albumId = "persist-${System.currentTimeMillis()}" + val viewModel = DeviceAlbumViewModel(container, albumId) + + try { + viewModel.setMode(SyncMode.COPY) + + var stored: SyncMode? = null + val deadline = System.currentTimeMillis() + 5_000 + while (System.currentTimeMillis() < deadline) { + stored = container.syncRepository.currentLink(albumId)?.let { SyncMode.from(it.mode) } + if (stored == SyncMode.COPY) break + delay(100) + } + assertEquals(SyncMode.COPY, stored) + } finally { + container.syncRepository.unlink(albumId) + } + } +} diff --git a/android/app/src/androidTest/java/com/galo/android/OffloadInstrumentedTest.kt b/android/app/src/androidTest/java/com/galo/android/OffloadInstrumentedTest.kt new file mode 100644 index 0000000..4f592e6 --- /dev/null +++ b/android/app/src/androidTest/java/com/galo/android/OffloadInstrumentedTest.kt @@ -0,0 +1,226 @@ +package com.galo.android + +import android.content.ContentResolver +import android.content.ContentUris +import android.content.ContentValues +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import android.provider.MediaStore +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.galo.android.data.db.GaloDatabase +import com.galo.android.data.db.entity.DeviceAssetEntity +import com.galo.android.data.db.entity.SyncedAlbumEntity +import com.galo.android.data.media.MediaStoreDeviceSource +import com.galo.android.data.sync.OffloadManager +import com.galo.android.data.sync.OffloadResult +import com.galo.android.data.sync.OriginalFetcher +import com.galo.android.data.sync.ProvenanceStamp +import com.galo.android.data.sync.RestoreManager +import com.galo.android.data.sync.SyncRecovery +import com.galo.android.domain.model.LocalAssetState +import com.galo.android.domain.model.SyncMode +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.io.ByteArrayOutputStream + +@RunWith(AndroidJUnit4::class) +class OffloadInstrumentedTest { + + private lateinit var context: Context + private lateinit var resolver: ContentResolver + private lateinit var db: GaloDatabase + private val created = mutableListOf() + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + resolver = context.contentResolver + db = Room.inMemoryDatabaseBuilder(context, GaloDatabase::class.java) + .allowMainThreadQueries() + .build() + } + + @After + fun tearDown() { + created.forEach { runCatching { resolver.delete(it, null, null) } } + db.close() + } + + private fun bucketOf(uri: Uri): String = + resolver.query(uri, arrayOf(MediaStore.MediaColumns.BUCKET_ID), null, null, null)!!.use { cursor -> + cursor.moveToFirst() + cursor.getString(0) + } + + private fun sizeOf(uri: Uri): Long = + resolver.openInputStream(uri)!!.use { it.readBytes().size.toLong() } + + private fun dimensionsOf(uri: Uri): Pair = + resolver.openInputStream(uri)!!.use { stream -> + val bitmap = BitmapFactory.decodeStream(stream) + bitmap.width to bitmap.height + } + + private fun exists(uri: Uri): Boolean = + resolver.query(uri, arrayOf(MediaStore.MediaColumns._ID), null, null, null)!!.use { it.count > 0 } + + private fun insertImage(width: Int, height: Int): Pair { + val values = ContentValues().apply { + put(MediaStore.Images.Media.DISPLAY_NAME, "orig-${System.nanoTime()}.jpg") + put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg") + put(MediaStore.Images.Media.RELATIVE_PATH, "DCIM/GaloTest/") + put(MediaStore.Images.Media.IS_PENDING, 1) + } + val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)!! + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + resolver.openOutputStream(uri)!!.use { bitmap.compress(Bitmap.CompressFormat.JPEG, 90, it) } + values.clear() + values.put(MediaStore.Images.Media.IS_PENDING, 0) + resolver.update(uri, values, null, null) + created += uri + return uri to bucketOf(uri) + } + + private fun insertVideo(): Pair { + val values = ContentValues().apply { + put(MediaStore.Video.Media.DISPLAY_NAME, "clip-${System.nanoTime()}.mp4") + put(MediaStore.Video.Media.MIME_TYPE, "video/mp4") + put(MediaStore.Video.Media.RELATIVE_PATH, "DCIM/GaloTest/") + put(MediaStore.Video.Media.IS_PENDING, 1) + } + val uri = resolver.insert(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, values)!! + val bytes = InstrumentationRegistry.getInstrumentation().context.assets + .open("test.mp4").use { it.readBytes() } + resolver.openOutputStream(uri)!!.use { it.write(bytes) } + values.clear() + values.put(MediaStore.Video.Media.IS_PENDING, 0) + resolver.update(uri, values, null, null) + created += uri + return uri to bucketOf(uri) + } + + private fun assetOf(uri: Uri, bucket: String, name: String, mime: String): DeviceAssetEntity { + val id = ContentUris.parseId(uri) + return DeviceAssetEntity( + assetKey = DeviceAssetEntity.key(bucket, id), + deviceAlbumId = bucket, + mediaStoreId = id, + uri = uri.toString(), + displayName = name, + mimeType = mime, + size = sizeOf(uri), + dateModified = 0L, + contentHash = "hash", + serverPhotoId = "photo1", + uploadedAt = null, + state = LocalAssetState.AWAITING_FREE.name, + ) + } + + @Test + fun offloadReplacesOriginalWithPreviewAndStampsProvenance() = runBlocking { + val (uri, bucket) = insertImage(2000, 1500) + val asset = assetOf(uri, bucket, "orig.jpg", "image/jpeg") + db.syncDao().upsertSyncedAlbum(SyncedAlbumEntity(bucket, "Test", "srvX", SyncMode.MOVE.name, 1L)) + db.syncDao().upsertAssets(listOf(asset)) + + val result = OffloadManager(context, db.syncDao()).offload(asset) + + assertEquals(OffloadResult.Done, result) + // The file is still there but now a capped preview. + assertEquals(1600 to 1200, dimensionsOf(uri)) + // Provenance travels with the file. + val marker = ProvenanceStamp.read(context, uri) + assertNotNull(marker) + assertEquals("photo1", marker?.serverPhotoId) + assertEquals("srvX", marker?.serverAlbumId) + assertEquals(LocalAssetState.PREVIEW.name, db.syncDao().getAsset(asset.assetKey)?.state) + } + + @Test + fun offloadVideoReplacesItWithAPosterInTheSameFolder() = runBlocking { + val (uri, bucket) = insertVideo() + val asset = assetOf(uri, bucket, "clip.mp4", "video/mp4") + db.syncDao().upsertSyncedAlbum(SyncedAlbumEntity(bucket, "Test", "srvX", SyncMode.MOVE.name, 1L)) + db.syncDao().upsertAssets(listOf(asset)) + + val result = OffloadManager(context, db.syncDao()).offload(asset) + + assertEquals(OffloadResult.Done, result) + assertTrue("video row should be gone", !exists(uri)) + + val posters = mutableListOf() + resolver.query( + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + arrayOf(MediaStore.Images.Media._ID, MediaStore.Images.Media.DISPLAY_NAME), + "${MediaStore.Images.Media.RELATIVE_PATH} LIKE ?", + arrayOf("DCIM/GaloTest/%"), + null, + )!!.use { cursor -> + while (cursor.moveToNext()) { + val id = cursor.getLong(0) + posters += cursor.getString(1) + created += ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id) + } + } + assertTrue("expected a poster image", posters.any { it.endsWith("-preview.jpg") }) + assertEquals(LocalAssetState.PREVIEW.name, db.syncDao().getAsset(asset.assetKey)?.state) + } + + @Test + fun recoveryRebuildsLinkAndProvenanceFromMarkers() = runBlocking { + val (uri, bucket) = insertImage(1000, 800) + val asset = assetOf(uri, bucket, "orig.jpg", "image/jpeg") + val key = asset.assetKey + db.syncDao().upsertSyncedAlbum(SyncedAlbumEntity(bucket, "Test", "srvX", SyncMode.MOVE.name, 1L)) + assertEquals(OffloadResult.Done, OffloadManager(context, db.syncDao()).offload(asset)) + + // Simulate a reinstall: the app's index is gone. + db.syncDao().clearAlbum(bucket) + db.syncDao().deleteSyncedAlbum(bucket) + + val recovered = SyncRecovery(context, db.syncDao(), MediaStoreDeviceSource(context)) + .recover(bucket) + + assertTrue("expected at least one recovered asset", recovered >= 1) + assertEquals("photo1", db.syncDao().getAsset(key)?.serverPhotoId) + assertEquals("srvX", db.syncDao().getSyncedAlbum(bucket)?.serverAlbumId) + } + + @Test + fun restoreWritesTheOriginalBackOverThePreview() = runBlocking { + val (uri, bucket) = insertImage(2400, 1800) + val asset = assetOf(uri, bucket, "orig.jpg", "image/jpeg") + db.syncDao().upsertSyncedAlbum(SyncedAlbumEntity(bucket, "Test", "srvX", SyncMode.MOVE.name, 1L)) + db.syncDao().upsertAssets(listOf(asset)) + + assertEquals(OffloadResult.Done, OffloadManager(context, db.syncDao()).offload(asset)) + assertEquals(1600 to 1200, dimensionsOf(uri)) + + val original = Bitmap.createBitmap(2400, 1800, Bitmap.Config.ARGB_8888) + val bytes = ByteArrayOutputStream().use { out -> + original.compress(Bitmap.CompressFormat.JPEG, 90, out) + out.toByteArray() + } + val fetcher = object : OriginalFetcher { + override suspend fun fetch(photoId: String): ByteArray = bytes + } + + val report = RestoreManager(context, db.syncDao(), fetcher).restore(listOf(asset)) + + assertEquals(1, report.restored) + assertEquals(2400 to 1800, dimensionsOf(uri)) + assertEquals(LocalAssetState.LOCAL.name, db.syncDao().getAsset(asset.assetKey)?.state) + } +} diff --git a/android/app/src/androidTest/java/com/galo/android/TargetSelectionTest.kt b/android/app/src/androidTest/java/com/galo/android/TargetSelectionTest.kt new file mode 100644 index 0000000..67ff051 --- /dev/null +++ b/android/app/src/androidTest/java/com/galo/android/TargetSelectionTest.kt @@ -0,0 +1,97 @@ +package com.galo.android + +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.galo.android.domain.model.SyncMode +import com.galo.android.ui.devicealbum.DeviceAlbumViewModel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Choosing "copies on the server" must offer the server albums as targets, and + * picking one must persist the link (mode + target). + */ +@RunWith(AndroidJUnit4::class) +class TargetSelectionTest { + + @Test + fun choosingCopyThenTargetPersistsTheLink() = runBlocking { val app = ApplicationProvider.getApplicationContext() + val container = app.container + E2E.configure(container) + val token = E2E.registerAuthenticated(container) + val serverAlbum = E2E.createAlbum(token, "Target ${System.currentTimeMillis()}") + + val bucket = "target-${System.currentTimeMillis()}" + val viewModel = DeviceAlbumViewModel(container, bucket) + + var targetsLoaded = false + val collector = launch { + viewModel.state.collect { state -> + if (state.serverAlbums.any { it.id == serverAlbum }) targetsLoaded = true + } + } + + try { + waitFor("server albums offered") { targetsLoaded } + + viewModel.setMode(SyncMode.COPY) + waitFor("mode persisted") { + container.syncRepository.currentLink(bucket)?.let { SyncMode.from(it.mode) } == SyncMode.COPY + } + + viewModel.setTarget(serverAlbum) + waitFor("target persisted") { + container.syncRepository.currentLink(bucket)?.serverAlbumId == serverAlbum + } + + val link = container.syncRepository.currentLink(bucket) + assertEquals(SyncMode.COPY, link?.let { SyncMode.from(it.mode) }) + assertEquals(serverAlbum, link?.serverAlbumId) + assertTrue(targetsLoaded) + } finally { + collector.cancel() + container.syncRepository.unlink(bucket) + } + } + + @Test + fun creatingANewAlbumLinksAndSyncs() = runBlocking { + val app = ApplicationProvider.getApplicationContext() + val container = app.container + E2E.configure(container) + E2E.registerAuthenticated(container) + + val bucket = "create-${System.currentTimeMillis()}" + val viewModel = DeviceAlbumViewModel(container, bucket) + val collector = launch { viewModel.state.collect { } } + + try { + viewModel.setMode(SyncMode.COPY) + val title = "Созданный ${System.currentTimeMillis()}" + viewModel.createAlbum(title) + + waitFor("link created") { + container.syncRepository.currentLink(bucket)?.serverAlbumId?.isNotBlank() == true + } + val link = container.syncRepository.currentLink(bucket) + assertEquals(SyncMode.COPY, link?.let { SyncMode.from(it.mode) }) + assertTrue("created album should exist on the server", container.api.albums().any { it.title == title }) + } finally { + collector.cancel() + container.syncRepository.unlink(bucket) + } + } + + private suspend fun waitFor(what: String, condition: suspend () -> Boolean) { val deadline = System.currentTimeMillis() + 8_000 + while (System.currentTimeMillis() < deadline) { + if (condition()) return + delay(100) + } + throw AssertionError("timed out waiting for: $what") + } +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 1e5d837..80b9d21 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -4,6 +4,20 @@ + + + + + + + + @@ -17,6 +31,8 @@ > + + @Query("SELECT * FROM albums ORDER BY updatedAt DESC, title ASC") + suspend fun getAll(): List + + @Upsert + suspend fun upsertAll(albums: List) + + @Query("DELETE FROM albums WHERE id IN (:ids)") + suspend fun deleteByIds(ids: List) + + @Query("DELETE FROM albums") + suspend fun clear() + + @Query("SELECT COUNT(*) FROM albums") + suspend fun count(): Int + + /** Replaces the cached album set with the latest server snapshot atomically. */ + @Transaction + suspend fun replaceAll(albums: List) { + clear() + upsertAll(albums) + } +} diff --git a/android/app/src/main/java/com/galo/android/data/db/dao/PhotoDao.kt b/android/app/src/main/java/com/galo/android/data/db/dao/PhotoDao.kt new file mode 100644 index 0000000..a0cb7ac --- /dev/null +++ b/android/app/src/main/java/com/galo/android/data/db/dao/PhotoDao.kt @@ -0,0 +1,32 @@ +package com.galo.android.data.db.dao + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.Upsert +import com.galo.android.data.db.entity.PhotoEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface PhotoDao { + + @Query("SELECT * FROM photos WHERE albumId = :albumId ORDER BY createdAt DESC, id DESC") + fun observeForAlbum(albumId: String): Flow> + + @Query("SELECT * FROM photos WHERE albumId = :albumId ORDER BY createdAt DESC, id DESC") + suspend fun getForAlbum(albumId: String): List + + @Query("SELECT * FROM photos WHERE id = :id") + suspend fun getById(id: String): PhotoEntity? + + @Upsert + suspend fun upsertAll(photos: List) + + @Query("DELETE FROM photos WHERE id = :id") + suspend fun deleteById(id: String) + + @Query("DELETE FROM photos WHERE albumId = :albumId") + suspend fun clearAlbum(albumId: String) + + @Query("SELECT COUNT(*) FROM photos WHERE albumId = :albumId") + suspend fun countForAlbum(albumId: String): Int +} diff --git a/android/app/src/main/java/com/galo/android/data/db/dao/SyncDao.kt b/android/app/src/main/java/com/galo/android/data/db/dao/SyncDao.kt new file mode 100644 index 0000000..75b90f5 --- /dev/null +++ b/android/app/src/main/java/com/galo/android/data/db/dao/SyncDao.kt @@ -0,0 +1,67 @@ +package com.galo.android.data.db.dao + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.Upsert +import com.galo.android.data.db.entity.DeviceAssetEntity +import com.galo.android.data.db.entity.SyncedAlbumEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface SyncDao { + + // --- Linked albums ----------------------------------------------------- + + @Query("SELECT * FROM synced_albums ORDER BY deviceAlbumName") + fun observeSyncedAlbums(): Flow> + + @Query("SELECT * FROM synced_albums WHERE deviceAlbumId = :deviceAlbumId") + suspend fun getSyncedAlbum(deviceAlbumId: String): SyncedAlbumEntity? + + @Query("SELECT * FROM synced_albums WHERE serverAlbumId = :serverAlbumId") + suspend fun getSyncedAlbumsForServer(serverAlbumId: String): List + + @Upsert + suspend fun upsertSyncedAlbum(album: SyncedAlbumEntity) + + @Query("DELETE FROM synced_albums WHERE deviceAlbumId = :deviceAlbumId") + suspend fun deleteSyncedAlbum(deviceAlbumId: String) + + // --- Device asset index and provenance --------------------------------- + + @Query("SELECT * FROM device_assets WHERE deviceAlbumId = :deviceAlbumId") + fun observeAssets(deviceAlbumId: String): Flow> + + @Query("SELECT * FROM device_assets WHERE deviceAlbumId = :deviceAlbumId") + suspend fun getAssets(deviceAlbumId: String): List + + @Query("SELECT * FROM device_assets WHERE assetKey = :assetKey") + suspend fun getAsset(assetKey: String): DeviceAssetEntity? + + @Query("SELECT * FROM device_assets WHERE contentHash = :contentHash LIMIT 1") + suspend fun getAssetByHash(contentHash: String): DeviceAssetEntity? + + @Query("SELECT * FROM device_assets WHERE serverPhotoId = :photoId") + suspend fun getAssetsByServerPhoto(photoId: String): List + + @Query( + "SELECT DISTINCT contentHash FROM device_assets " + + "WHERE deviceAlbumId != :deviceAlbumId AND contentHash IS NOT NULL", + ) + suspend fun hashesOutside(deviceAlbumId: String): List + + @Query("UPDATE device_assets SET displayName = :name WHERE serverPhotoId = :photoId") + suspend fun updateDisplayNameByServerPhoto(photoId: String, name: String) + + @Query("UPDATE device_assets SET state = :state WHERE assetKey = :assetKey") + suspend fun markState(assetKey: String, state: String) + + @Upsert + suspend fun upsertAssets(assets: List) + + @Query("DELETE FROM device_assets WHERE assetKey = :assetKey") + suspend fun deleteAsset(assetKey: String) + + @Query("DELETE FROM device_assets WHERE deviceAlbumId = :deviceAlbumId") + suspend fun clearAlbum(deviceAlbumId: String) +} diff --git a/android/app/src/main/java/com/galo/android/data/db/dao/UploadDao.kt b/android/app/src/main/java/com/galo/android/data/db/dao/UploadDao.kt new file mode 100644 index 0000000..a5f9da4 --- /dev/null +++ b/android/app/src/main/java/com/galo/android/data/db/dao/UploadDao.kt @@ -0,0 +1,45 @@ +package com.galo.android.data.db.dao + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.Upsert +import com.galo.android.data.db.entity.UploadJobEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface UploadDao { + + @Query("SELECT * FROM upload_jobs ORDER BY createdAt DESC") + fun observeAll(): Flow> + + @Query("SELECT * FROM upload_jobs WHERE id = :id") + suspend fun getById(id: String): UploadJobEntity? + + @Query("SELECT * FROM upload_jobs WHERE status IN ('QUEUED','RUNNING') ORDER BY createdAt ASC") + suspend fun getPending(): List + + @Upsert + suspend fun upsert(job: UploadJobEntity) + + @Query( + "UPDATE upload_jobs SET status = :status, progress = :progress, error = :error, " + + "attempts = :attempts, convertedPath = :convertedPath WHERE id = :id", + ) + suspend fun updateState( + id: String, + status: String, + progress: Int, + error: String?, + attempts: Int, + convertedPath: String?, + ) + + @Query("UPDATE upload_jobs SET status = 'QUEUED' WHERE status = 'RUNNING'") + suspend fun resetRunning() + + @Query("DELETE FROM upload_jobs WHERE id = :id") + suspend fun delete(id: String) + + @Query("DELETE FROM upload_jobs WHERE status = 'SUCCESS'") + suspend fun clearCompleted() +} diff --git a/android/app/src/main/java/com/galo/android/data/db/entity/AlbumEntity.kt b/android/app/src/main/java/com/galo/android/data/db/entity/AlbumEntity.kt new file mode 100644 index 0000000..0f56576 --- /dev/null +++ b/android/app/src/main/java/com/galo/android/data/db/entity/AlbumEntity.kt @@ -0,0 +1,18 @@ +package com.galo.android.data.db.entity + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "albums") +data class AlbumEntity( + @PrimaryKey val id: String, + val title: String, + val description: String, + val ownerId: String, + val coverPhotoId: String?, + val coverThumbUrl: String?, + val coverMediumUrl: String?, + val photosCount: Int, + val membersCount: Int, + val updatedAt: String?, +) diff --git a/android/app/src/main/java/com/galo/android/data/db/entity/PhotoEntity.kt b/android/app/src/main/java/com/galo/android/data/db/entity/PhotoEntity.kt new file mode 100644 index 0000000..3a560ab --- /dev/null +++ b/android/app/src/main/java/com/galo/android/data/db/entity/PhotoEntity.kt @@ -0,0 +1,31 @@ +package com.galo.android.data.db.entity + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +@Entity( + tableName = "photos", + indices = [Index("albumId")], +) +data class PhotoEntity( + @PrimaryKey val id: String, + val albumId: String, + val originalFilename: String, + val kind: String, + val mimeType: String?, + val width: Int, + val height: Int, + val durationMs: Long, + val size: Long, + val takenAt: String?, + val caption: String, + val isPanorama: Boolean, + val thumbUrl: String?, + val mediumUrl: String?, + val previewUrl: String?, + val playbackUrl: String?, + val originalUrl: String?, + val downloadUrl: String?, + val createdAt: String?, +) diff --git a/android/app/src/main/java/com/galo/android/data/db/entity/SyncEntities.kt b/android/app/src/main/java/com/galo/android/data/db/entity/SyncEntities.kt new file mode 100644 index 0000000..29033f5 --- /dev/null +++ b/android/app/src/main/java/com/galo/android/data/db/entity/SyncEntities.kt @@ -0,0 +1,46 @@ +package com.galo.android.data.db.entity + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +/** A device album linked to a server album, with its synchronization mode. */ +@Entity(tableName = "synced_albums") +data class SyncedAlbumEntity( + @PrimaryKey val deviceAlbumId: String, + val deviceAlbumName: String, + val serverAlbumId: String, + val mode: String, + val updatedAt: Long, +) + +/** + * Local index of a device asset plus provenance: the server photo it was + * uploaded as (when known) and the content hash used to match it. + */ +@Entity( + tableName = "device_assets", + indices = [ + Index("deviceAlbumId"), + Index("contentHash"), + Index("serverPhotoId"), + ], +) +data class DeviceAssetEntity( + @PrimaryKey val assetKey: String, + val deviceAlbumId: String, + val mediaStoreId: Long, + val uri: String, + val displayName: String, + val mimeType: String?, + val size: Long, + val dateModified: Long, + val contentHash: String?, + val serverPhotoId: String?, + val uploadedAt: Long?, + val state: String, +) { + companion object { + fun key(deviceAlbumId: String, mediaStoreId: Long): String = "$deviceAlbumId/$mediaStoreId" + } +} diff --git a/android/app/src/main/java/com/galo/android/data/db/entity/UploadJobEntity.kt b/android/app/src/main/java/com/galo/android/data/db/entity/UploadJobEntity.kt new file mode 100644 index 0000000..ef34273 --- /dev/null +++ b/android/app/src/main/java/com/galo/android/data/db/entity/UploadJobEntity.kt @@ -0,0 +1,31 @@ +package com.galo.android.data.db.entity + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +@Entity( + tableName = "upload_jobs", + indices = [Index("status"), Index("albumId")], +) +data class UploadJobEntity( + @PrimaryKey val id: String, + val albumId: String, + val uri: String, + val filename: String, + val mimeType: String?, + val size: Long, + val status: String, + val progress: Int, + val error: String?, + val attempts: Int, + val wifiOnly: Boolean, + val createdAt: Long, + val convertedPath: String?, + // Device-album synchronization: when set, the completed upload records + // provenance for the asset in the sync index. + val deviceAlbumId: String? = null, + val assetKey: String? = null, + val contentHash: String? = null, + val syncMode: String? = null, +) diff --git a/android/app/src/main/java/com/galo/android/data/image/ObjectCacheKey.kt b/android/app/src/main/java/com/galo/android/data/image/ObjectCacheKey.kt new file mode 100644 index 0000000..b849488 --- /dev/null +++ b/android/app/src/main/java/com/galo/android/data/image/ObjectCacheKey.kt @@ -0,0 +1,17 @@ +package com.galo.android.data.image + +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull + +/** + * Coil cache key derived from the object path only, dropping the signed query + * (`?e=..&s=..`). The signature rotates every window, so keying by the full URL + * would miss the cache on every refresh; keying by path reuses the cached object. + */ +object ObjectCacheKey { + + fun keyFor(url: String?): String? { + if (url.isNullOrBlank()) return null + val parsed = url.toHttpUrlOrNull() ?: return url + return parsed.newBuilder().query(null).build().toString() + } +} diff --git a/android/app/src/main/java/com/galo/android/data/mapper/Mappers.kt b/android/app/src/main/java/com/galo/android/data/mapper/Mappers.kt new file mode 100644 index 0000000..abaf89a --- /dev/null +++ b/android/app/src/main/java/com/galo/android/data/mapper/Mappers.kt @@ -0,0 +1,104 @@ +package com.galo.android.data.mapper + +import com.galo.android.data.db.entity.AlbumEntity +import com.galo.android.data.db.entity.PhotoEntity +import com.galo.android.data.network.ObjectUrlResolver +import com.galo.android.data.network.dto.AlbumDto +import com.galo.android.data.network.dto.PhotoDto +import com.galo.android.domain.model.Album +import com.galo.android.domain.model.MediaKind +import com.galo.android.domain.model.Photo + +// Remote DTO -> persisted entity (URLs kept raw so the cache survives a base-URL change). +fun AlbumDto.toEntity(): AlbumEntity = AlbumEntity( + id = id, + title = title, + description = description, + ownerId = ownerId, + coverPhotoId = coverPhotoId, + coverThumbUrl = coverThumbUrl, + coverMediumUrl = coverMediumUrl, + photosCount = photosCount, + membersCount = membersCount, + updatedAt = updatedAt, +) + +fun PhotoDto.toEntity(): PhotoEntity = PhotoEntity( + id = id, + albumId = albumId, + originalFilename = originalFilename, + kind = kind, + mimeType = mimeType, + width = width, + height = height, + durationMs = durationMs, + size = size, + takenAt = takenAt, + caption = caption, + isPanorama = tags.any { MediaKind.isPanoTag(it.name) }, + thumbUrl = thumbUrl, + mediumUrl = mediumUrl, + previewUrl = previewUrl, + playbackUrl = playbackUrl, + originalUrl = originalUrl, + downloadUrl = downloadUrl, + createdAt = createdAt, +) + +// Persisted entity -> domain (URLs resolved against the current base URL). +fun AlbumEntity.toDomain(resolver: ObjectUrlResolver): Album = Album( + id = id, + title = title, + description = description, + ownerId = ownerId, + coverPhotoId = coverPhotoId, + coverThumbUrl = resolver.resolve(coverThumbUrl), + coverMediumUrl = resolver.resolve(coverMediumUrl), + photosCount = photosCount, + membersCount = membersCount, + updatedAt = updatedAt, +) + +fun PhotoEntity.toDomain(resolver: ObjectUrlResolver): Photo = Photo( + id = id, + albumId = albumId, + originalFilename = originalFilename, + kind = MediaKind.from(kind), + mimeType = mimeType, + width = width, + height = height, + durationMs = durationMs, + size = size, + takenAt = takenAt, + caption = caption, + isPanorama = isPanorama, + thumbUrl = resolver.resolve(thumbUrl), + mediumUrl = resolver.resolve(mediumUrl), + previewUrl = resolver.resolve(previewUrl), + playbackUrl = resolver.resolve(playbackUrl), + originalUrl = resolver.resolve(originalUrl), + downloadUrl = resolver.resolve(downloadUrl), + createdAt = createdAt, +) + +fun PhotoDto.toDomain(resolver: ObjectUrlResolver): Photo = Photo( + id = id, + albumId = albumId, + originalFilename = originalFilename, + kind = MediaKind.from(kind), + mimeType = mimeType, + width = width, + height = height, + durationMs = durationMs, + size = size, + takenAt = takenAt, + caption = caption, + isPanorama = tags.any { MediaKind.isPanoTag(it.name) }, + thumbUrl = resolver.resolve(thumbUrl), + mediumUrl = resolver.resolve(mediumUrl), + previewUrl = resolver.resolve(previewUrl), + playbackUrl = resolver.resolve(playbackUrl), + originalUrl = resolver.resolve(originalUrl), + downloadUrl = resolver.resolve(downloadUrl), + createdAt = createdAt, +) diff --git a/android/app/src/main/java/com/galo/android/data/media/MediaAccessChecker.kt b/android/app/src/main/java/com/galo/android/data/media/MediaAccessChecker.kt new file mode 100644 index 0000000..5e86e84 --- /dev/null +++ b/android/app/src/main/java/com/galo/android/data/media/MediaAccessChecker.kt @@ -0,0 +1,27 @@ +package com.galo.android.data.media + +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import androidx.core.content.ContextCompat +import com.galo.android.domain.media.MediaPermissions + +/** Whether the app can enumerate and read device media through MediaStore. */ +interface MediaAccess { + fun hasAccess(): Boolean +} + +/** + * Checks runtime access to device media. Access requires every permission the + * running platform needs ([MediaPermissions.required]); partial access (for + * example images granted but video denied) is treated as no access. + */ +class MediaAccessChecker(private val context: Context) : MediaAccess { + + override fun hasAccess(): Boolean { + val required = MediaPermissions.required(Build.VERSION.SDK_INT) + return required.isNotEmpty() && required.all { permission -> + ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED + } + } +} diff --git a/android/app/src/main/java/com/galo/android/data/media/MediaStoreDeviceSource.kt b/android/app/src/main/java/com/galo/android/data/media/MediaStoreDeviceSource.kt new file mode 100644 index 0000000..b69fa7a --- /dev/null +++ b/android/app/src/main/java/com/galo/android/data/media/MediaStoreDeviceSource.kt @@ -0,0 +1,109 @@ +package com.galo.android.data.media + +import android.content.ContentUris +import android.content.Context +import android.database.Cursor +import android.net.Uri +import android.provider.MediaStore +import com.galo.android.domain.model.DeviceAlbum +import com.galo.android.domain.model.DeviceMedia +import com.galo.android.domain.model.MediaKind + +/** + * Maps MediaStore cursors to [DeviceMedia] and groups media into [DeviceAlbum]s. + * Kept free of provider access so the mapping can be exercised with a cursor. + */ +object DeviceMediaMapping { + + val projection = arrayOf( + MediaStore.MediaColumns._ID, + MediaStore.MediaColumns.DISPLAY_NAME, + MediaStore.MediaColumns.MIME_TYPE, + MediaStore.MediaColumns.SIZE, + MediaStore.MediaColumns.DATE_MODIFIED, + MediaStore.MediaColumns.DATE_TAKEN, + MediaStore.MediaColumns.BUCKET_ID, + MediaStore.MediaColumns.BUCKET_DISPLAY_NAME, + MediaStore.MediaColumns.RELATIVE_PATH, + MediaStore.MediaColumns.WIDTH, + MediaStore.MediaColumns.HEIGHT, + ) + + fun mediaFromCursor(cursor: Cursor, collection: Uri, kind: MediaKind): List { + val idCol = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID) + val nameCol = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME) + val mimeCol = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.MIME_TYPE) + val sizeCol = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.SIZE) + val modifiedCol = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED) + val takenCol = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_TAKEN) + val bucketIdCol = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.BUCKET_ID) + val bucketNameCol = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.BUCKET_DISPLAY_NAME) + val pathCol = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.RELATIVE_PATH) + val widthCol = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.WIDTH) + val heightCol = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.HEIGHT) + + val out = ArrayList(cursor.count) + while (cursor.moveToNext()) { + val id = cursor.getLong(idCol) + val bucketId = cursor.getString(bucketIdCol) ?: "" + val taken = if (cursor.isNull(takenCol)) null else cursor.getLong(takenCol) + out += DeviceMedia( + id = id, + uri = ContentUris.withAppendedId(collection, id).toString(), + albumId = bucketId, + albumName = cursor.getString(bucketNameCol) ?: "", + displayName = cursor.getString(nameCol) ?: "", + mimeType = cursor.getString(mimeCol), + size = cursor.getLong(sizeCol), + width = cursor.getInt(widthCol), + height = cursor.getInt(heightCol), + dateModified = cursor.getLong(modifiedCol), + dateTaken = taken, + relativePath = cursor.getString(pathCol) ?: "", + kind = kind, + ) + } + return out + } + + /** Groups media by bucket, picking the most recent item as the cover. */ + fun albums(media: List): List = + media.groupBy { it.albumId } + .map { (albumId, items) -> + val name = items.firstOrNull { it.albumName.isNotBlank() }?.albumName ?: "" + val cover = items.maxByOrNull { it.dateTaken ?: (it.dateModified * 1000) } + DeviceAlbum( + id = albumId, + name = name.ifBlank { "Без названия" }, + itemCount = items.size, + coverUri = cover?.uri, + relativePath = items.first().relativePath, + ) + } + .sortedBy { it.name.lowercase() } +} + +/** + * Reads device albums and their media through MediaStore. Requires media read + * access; without it queries fail with a security error and are reported as an + * empty result so the UI simply shows no device albums. + */ +class MediaStoreDeviceSource(private val context: Context) { + + fun albums(): List = DeviceMediaMapping.albums(allMedia()) + + fun items(albumId: String): List = + allMedia().filter { it.albumId == albumId } + + private fun allMedia(): List = + query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, MediaKind.IMAGE) + + query(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, MediaKind.VIDEO) + + private fun query(collection: Uri, kind: MediaKind): List = + runCatching { + context.contentResolver + .query(collection, DeviceMediaMapping.projection, null, null, null) + ?.use { cursor -> DeviceMediaMapping.mediaFromCursor(cursor, collection, kind) } + ?: emptyList() + }.getOrDefault(emptyList()) +} diff --git a/android/app/src/main/java/com/galo/android/data/media/PreviewGenerator.kt b/android/app/src/main/java/com/galo/android/data/media/PreviewGenerator.kt new file mode 100644 index 0000000..7d40d23 --- /dev/null +++ b/android/app/src/main/java/com/galo/android/data/media/PreviewGenerator.kt @@ -0,0 +1,84 @@ +package com.galo.android.data.media + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.media.MediaMetadataRetriever +import android.net.Uri +import com.galo.android.domain.model.MediaKind +import java.io.File +import java.io.FileOutputStream +import kotlin.math.max +import kotlin.math.roundToInt + +/** + * Generates the light preview that stays on the device (cache in modes A/B and + * the replacement file in mode C). The long side is capped and the output is a + * quality-limited JPEG. + */ +object PreviewGenerator { + + const val MAX_LONG_SIDE = 1600 + const val JPEG_QUALITY = 80 + + /** Scales dimensions down so the long side is at most [MAX_LONG_SIDE]. */ + fun scaledSize(width: Int, height: Int): Pair { + if (width <= 0 || height <= 0) return 0 to 0 + val longSide = max(width, height) + if (longSide <= MAX_LONG_SIDE) return width to height + val ratio = MAX_LONG_SIDE.toDouble() / longSide + return max(1, (width * ratio).roundToInt()) to max(1, (height * ratio).roundToInt()) + } + + /** Cache file for the preview of a device asset, keyed by the asset key. */ + fun previewFile(context: Context, assetKey: String): File { + val dir = File(context.cacheDir, "previews").apply { mkdirs() } + return File(dir, assetKey.replace('/', '_') + ".jpg") + } + + /** + * Unique temporary file for one offload pass. Each pass must use its own file + * so concurrent offloads cannot delete each other's preview mid-write. + */ + fun tempPreviewFile(context: Context, assetKey: String): File { + val dir = File(context.cacheDir, "previews").apply { mkdirs() } + return File(dir, assetKey.replace('/', '_') + "-" + System.nanoTime() + ".jpg") + } + + /** + * Decodes the source (image file, or a poster frame for video), scales it and + * writes a JPEG preview to [out]. Returns false when the source is unreadable. + */ + fun generate(context: Context, uri: Uri, kind: MediaKind, out: File): Boolean { + val source = runCatching { + if (kind == MediaKind.VIDEO) videoFrame(context, uri) else decode(context, uri) + }.getOrNull() ?: return false + + val (width, height) = scaledSize(source.width, source.height) + val scaled = if (width <= 0 || height <= 0) { + source + } else { + Bitmap.createScaledBitmap(source, width, height, true) + } + if (scaled !== source) source.recycle() + val ok = runCatching { encodeJpeg(scaled, out) }.getOrDefault(false) + scaled.recycle() + return ok + } + + fun encodeJpeg(bitmap: Bitmap, out: File): Boolean = + FileOutputStream(out).use { bitmap.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, it) } + + private fun decode(context: Context, uri: Uri): Bitmap? = + context.contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it) } + + private fun videoFrame(context: Context, uri: Uri): Bitmap? { + val retriever = MediaMetadataRetriever() + return try { + retriever.setDataSource(context, uri) + retriever.getFrameAtTime(0) + } finally { + runCatching { retriever.release() } + } + } +} diff --git a/android/app/src/main/java/com/galo/android/data/network/AlbumEvent.kt b/android/app/src/main/java/com/galo/android/data/network/AlbumEvent.kt new file mode 100644 index 0000000..ff84c24 --- /dev/null +++ b/android/app/src/main/java/com/galo/android/data/network/AlbumEvent.kt @@ -0,0 +1,41 @@ +package com.galo.android.data.network + +import com.galo.android.data.network.dto.PhotoDto +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +sealed interface AlbumEvent { + data class PhotoUpserted(val photo: PhotoDto) : AlbumEvent + data class PhotoDeleted(val photoId: String) : AlbumEvent + data class SocialChanged(val photoId: String) : AlbumEvent + data object AlbumChanged : AlbumEvent + + /** Emitted after a dropped SSE connection so the caller can catch up via `?since=`. */ + data object Reconnected : AlbumEvent +} + +/** + * Maps the server's SSE `event:`/`data:` pairs to typed [AlbumEvent]s. + * Unknown event types return null and are ignored. + */ +object AlbumEventParser { + + fun parse(type: String, data: String): AlbumEvent? = when (type) { + "photo.created", "photo.updated" -> + runCatching { AlbumEvent.PhotoUpserted(GaloJson.decodeFromString(data)) } + .getOrNull() + + "photo.deleted" -> stringField(data, "id")?.let { AlbumEvent.PhotoDeleted(it) } + + "album.updated", "album.deleted" -> AlbumEvent.AlbumChanged + + "like.added", "like.removed", "comment.created" -> + stringField(data, "photo_id")?.let { AlbumEvent.SocialChanged(it) } + + else -> null + } + + private fun stringField(data: String, field: String): String? = runCatching { + GaloJson.parseToJsonElement(data).jsonObject[field]?.jsonPrimitive?.content + }.getOrNull() +} diff --git a/android/app/src/main/java/com/galo/android/data/network/AlbumSseClient.kt b/android/app/src/main/java/com/galo/android/data/network/AlbumSseClient.kt new file mode 100644 index 0000000..378f504 --- /dev/null +++ b/android/app/src/main/java/com/galo/android/data/network/AlbumSseClient.kt @@ -0,0 +1,71 @@ +package com.galo.android.data.network + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.channelFlow +import okhttp3.OkHttpClient +import okhttp3.Response +import okhttp3.sse.EventSource +import okhttp3.sse.EventSourceListener +import okhttp3.sse.EventSources + +/** + * Live album updates over SSE (`GET /api/sse/albums/:id`). Reconnects with + * exponential backoff and emits [AlbumEvent.Reconnected] after each drop so the + * consumer can backfill missed changes. + */ +class AlbumSseClient( + private val api: GaloApi, + private val client: OkHttpClient, +) { + + fun events(albumId: String): Flow = channelFlow { + val factory = EventSources.createFactory(client) + var backoff = INITIAL_BACKOFF_MS + + while (true) { + val request = runCatching { api.sseRequest(albumId) }.getOrNull() ?: break + val closed = CompletableDeferred() + val listener = object : EventSourceListener() { + override fun onOpen(eventSource: EventSource, response: Response) { + backoff = INITIAL_BACKOFF_MS + } + + override fun onEvent( + eventSource: EventSource, + id: String?, + type: String?, + data: String, + ) { + if (type.isNullOrBlank()) return + AlbumEventParser.parse(type, data)?.let { trySend(it) } + } + + override fun onClosed(eventSource: EventSource) { + closed.complete(Unit) + } + + override fun onFailure( + eventSource: EventSource, + t: Throwable?, + response: Response?, + ) { + closed.complete(Unit) + } + } + val source = factory.newEventSource(request, listener) + closed.await() + source.cancel() + if (isClosedForSend) break + trySend(AlbumEvent.Reconnected) + delay(backoff) + backoff = (backoff * 2).coerceAtMost(MAX_BACKOFF_MS) + } + } + + private companion object { + const val INITIAL_BACKOFF_MS = 1_000L + const val MAX_BACKOFF_MS = 30_000L + } +} diff --git a/android/app/src/main/java/com/galo/android/data/network/ApiClient.kt b/android/app/src/main/java/com/galo/android/data/network/ApiClient.kt new file mode 100644 index 0000000..1ea1985 --- /dev/null +++ b/android/app/src/main/java/com/galo/android/data/network/ApiClient.kt @@ -0,0 +1,81 @@ +package com.galo.android.data.network + +import com.galo.android.data.auth.TokenStore +import com.galo.android.domain.ConnectionPolicy +import okhttp3.Interceptor +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import java.util.concurrent.TimeUnit + +/** + * Shared HTTP stack for the galo API. + * + * - resolves every endpoint against the configured base URL, + * - enforces the insecure-connection gate, + * - attaches the bearer token and transparently refreshes it on 401. + */ +class ApiClient( + private val tokenStore: TokenStore, + private val baseUrlProvider: () -> String?, + private val insecureAllowedProvider: () -> Boolean = { false }, + debugLogging: Boolean = false, +) { + + val objectUrls: ObjectUrlResolver = ObjectUrlResolver(baseUrlProvider) + + private val refreshClient: OkHttpClient = OkHttpClient.Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .build() + + val client: OkHttpClient = refreshClient.newBuilder() + .addInterceptor(AuthHeaderInterceptor(tokenStore)) + .authenticator(TokenAuthenticator(tokenStore, refreshClient, ::refreshUrlOrNull)) + .apply { + if (debugLogging) { + val logging = okhttp3.logging.HttpLoggingInterceptor().apply { + level = okhttp3.logging.HttpLoggingInterceptor.Level.BASIC + redactHeader("Authorization") + } + addInterceptor(logging) + } + } + .build() + + /** Client tuned for long-lived SSE streams (no read timeout). */ + val sseClient: OkHttpClient = refreshClient.newBuilder() + .addInterceptor(AuthHeaderInterceptor(tokenStore)) + .readTimeout(0, TimeUnit.MILLISECONDS) + .build() + + fun baseUrl(): String { + val base = baseUrlProvider()?.trim()?.trimEnd('/') + if (base.isNullOrEmpty()) { + throw IllegalStateException("server base URL is not configured") + } + ConnectionPolicy.check(base, insecureAllowedProvider()) + return base + } + + fun endpoint(path: String): String = baseUrl() + "/" + path.trimStart('/') + + fun refreshUrl(): String = endpoint("api/auth/refresh") + + private fun refreshUrlOrNull(): String? = runCatching { refreshUrl() }.getOrNull() + + private class AuthHeaderInterceptor(private val tokenStore: TokenStore) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val original = chain.request() + if (original.header("Authorization") != null) return chain.proceed(original) + val token = tokenStore.accessToken() + val request: Request = if (token.isNullOrBlank()) { + original + } else { + original.newBuilder().header("Authorization", "Bearer $token").build() + } + return chain.proceed(request) + } + } +} diff --git a/android/app/src/main/java/com/galo/android/data/network/ErrorMapper.kt b/android/app/src/main/java/com/galo/android/data/network/ErrorMapper.kt new file mode 100644 index 0000000..53dfcd3 --- /dev/null +++ b/android/app/src/main/java/com/galo/android/data/network/ErrorMapper.kt @@ -0,0 +1,76 @@ +package com.galo.android.data.network + +import com.galo.android.data.network.dto.ErrorEnvelopeDto +import com.galo.android.domain.model.ApiError + +/** + * Turns any HTTP failure body into a normalised [ApiError] and a message safe to + * show to the user. The server contract is `{error:{code,message}}`; malformed + * or missing bodies degrade to a generic internal error. + */ +object ErrorMapper { + + fun parse(status: Int, body: String?): ApiError { + val envelope = body + ?.takeIf { it.isNotBlank() } + ?.let { raw -> + runCatching { GaloJson.decodeFromString(raw) }.getOrNull() + } + val error = envelope?.error + if (error != null) { + return ApiError( + code = error.code.ifBlank { "internal_error" }, + message = error.message.ifBlank { "internal server error" }, + status = status, + ) + } + return ApiError(code = fallbackCode(status), message = fallbackMessage(status), status = status) + } + + /** Human-friendly message; prefers a known localisation, else the server text. */ + fun userMessage(error: ApiError): String { + val localized = when (error.code) { + "invalid_credentials" -> "Неверный идентификатор или пароль" + "email_taken" -> "Такой email уже зарегистрирован" + "phone_taken" -> "Такой телефон уже зарегистрирован" + "invalid_phone" -> "Телефон должен быть в формате E.164 (например, +15551234567)" + "invalid_email" -> "Некорректный email" + "identifier_required" -> "Укажите email или телефон" + "invalid_name" -> "Укажите имя" + "weak_password" -> "Пароль должен быть не короче 8 символов" + "current_password_incorrect" -> "Неверный текущий пароль" + "email_already_set", "phone_already_set" -> + "Идентификатор уже задан и не может быть изменён" + "identifiers_complete" -> "Оба идентификатора уже заданы" + "unauthorized" -> "Требуется вход в аккаунт" + "forbidden" -> "Недостаточно прав" + "not_found", "album_not_found", "photo_not_found", "object_not_found" -> + "Не найдено" + "file_too_large" -> "Файл превышает допустимый размер" + "no_files" -> "Файлы не выбраны" + "invalid_request" -> "Некорректный запрос" + "conflict" -> "Конфликт данных" + "internal_error" -> "Ошибка сервера, попробуйте позже" + else -> null + } + return localized ?: error.message + } + + private fun fallbackCode(status: Int): String = when (status) { + 401 -> "unauthorized" + 403 -> "forbidden" + 404 -> "not_found" + 409 -> "conflict" + 413 -> "file_too_large" + else -> "internal_error" + } + + private fun fallbackMessage(status: Int): String = when (status) { + 401 -> "unauthorized" + 403 -> "forbidden" + 404 -> "not found" + 409 -> "conflict" + 413 -> "payload too large" + else -> "internal server error" + } +} diff --git a/android/app/src/main/java/com/galo/android/data/network/GaloApi.kt b/android/app/src/main/java/com/galo/android/data/network/GaloApi.kt new file mode 100644 index 0000000..081fd42 --- /dev/null +++ b/android/app/src/main/java/com/galo/android/data/network/GaloApi.kt @@ -0,0 +1,274 @@ +package com.galo.android.data.network + +import com.galo.android.data.network.dto.AddIdentifierRequest +import com.galo.android.data.network.dto.AlbumDto +import com.galo.android.data.network.dto.AlbumEnvelopeDto +import com.galo.android.data.network.dto.AlbumListDto +import com.galo.android.data.network.dto.AuthTokensDto +import com.galo.android.data.network.dto.BatchRequest +import com.galo.android.data.network.dto.ChangePasswordRequest +import com.galo.android.data.network.dto.CreateAlbumRequest +import com.galo.android.data.network.dto.DeleteAccountRequest +import com.galo.android.data.network.dto.HealthDto +import com.galo.android.data.network.dto.LoginRequest +import com.galo.android.data.network.dto.LogoutRequest +import com.galo.android.data.network.dto.PhotoDto +import com.galo.android.data.network.dto.PhotoEnvelopeDto +import com.galo.android.data.network.dto.PhotoListDto +import com.galo.android.data.network.dto.RegisterRequest +import com.galo.android.data.network.dto.RenamePhotoRequest +import com.galo.android.data.network.dto.TokenEnvelopeDto +import com.galo.android.data.network.dto.UpdateProfileRequest +import com.galo.android.data.network.dto.UploadResponseDto +import com.galo.android.data.network.dto.UserDto +import com.galo.android.data.network.dto.UserEnvelopeDto +import com.galo.android.domain.model.ApiError +import com.galo.android.domain.model.ApiException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.MultipartBody +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import java.io.IOException + +/** Profile operations the UI depends on; implemented by [GaloApi]. */ +interface ProfileApi { + suspend fun me(): UserDto + suspend fun updateProfile(name: String): UserDto + suspend fun changePassword(currentPassword: String, newPassword: String) + suspend fun addIdentifier(email: String, phone: String): UserDto + suspend fun deleteAccount(password: String) + suspend fun uploadAvatar(body: RequestBody): UserDto + suspend fun deleteAvatar(): UserDto +} + +/** + * Typed galo REST client. All calls resolve against the configured base URL and + * surface failures as [ApiException] with a normalised [ApiError]. + */ +class GaloApi( + private val api: ApiClient, + private val json: Json = GaloJson, + private val client: OkHttpClient = api.client, +) : ProfileApi { + + private val jsonMedia = "application/json; charset=utf-8".toMediaType() + + suspend fun health(): HealthDto = + get("api/health").decode() + + suspend fun register(email: String, phone: String, password: String, name: String): AuthTokensDto = + postJson("api/auth/register", json.encodeToString(RegisterRequest(email, phone, password, name))) + .decode() + .tokens + + suspend fun login(identifier: String, password: String): AuthTokensDto = + postJson("api/auth/login", json.encodeToString(LoginRequest(identifier, password))) + .decode() + .tokens + + override suspend fun me(): UserDto = + get("api/auth/me").decode().user + + override suspend fun updateProfile(name: String): UserDto = + patchJson("api/auth/me", json.encodeToString(UpdateProfileRequest(name))) + .decode() + .user + + override suspend fun changePassword(currentPassword: String, newPassword: String) { + postJson( + "api/auth/me/password", + json.encodeToString(ChangePasswordRequest(currentPassword, newPassword)), + ) + } + + override suspend fun addIdentifier(email: String, phone: String): UserDto = + postJson("api/auth/me/identifiers", json.encodeToString(AddIdentifierRequest(email, phone))) + .decode() + .user + + override suspend fun deleteAccount(password: String) { + deleteJson("api/auth/me", json.encodeToString(DeleteAccountRequest(password))) + } + + /** Uploads a cropped square avatar and returns the updated profile. */ + override suspend fun uploadAvatar(body: RequestBody): UserDto { + val multipart = MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart("file", "avatar.jpg", body) + .build() + val request = Request.Builder() + .url(api.endpoint("api/auth/me/avatar")) + .post(multipart) + .build() + return execute(request).decode().user + } + + /** Removes the avatar and returns the updated profile. */ + override suspend fun deleteAvatar(): UserDto = + execute(Request.Builder().url(api.endpoint("api/auth/me/avatar")).delete().build()) + .decode() + .user + + /** Revokes the refresh token server-side. Best-effort: callers clear local state anyway. */ + suspend fun logout(refreshToken: String) { + postJson("api/auth/logout", json.encodeToString(LogoutRequest(refreshToken))) + } + + suspend fun albums(): List = + get("api/albums").decode().albums + + suspend fun album(albumId: String): AlbumDto = + get("api/albums/$albumId").decode().album + + /** Creates a server album and returns it. */ + suspend fun createAlbum(title: String): AlbumDto = + postJson("api/albums", json.encodeToString(CreateAlbumRequest(title))) + .decode() + .album + + suspend fun photo(photoId: String): PhotoDto = + get("api/photos/$photoId").decode().photo + + suspend fun photos( + albumId: String, + cursor: String? = null, + since: String? = null, + limit: Int = 50, + ): PhotoListDto { + val query = buildList { + cursor?.let { add("cursor=" + urlEncode(it)) } + since?.let { add("since=" + urlEncode(it)) } + add("limit=$limit") + }.joinToString("&") + return get("api/albums/$albumId/photos?$query").decode() + } + + suspend fun upload( + albumId: String, + filename: String, + body: RequestBody, + ): PhotoDto { + val multipart = MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart("files", filename, body) + .build() + val request = Request.Builder() + .url(api.endpoint("api/albums/$albumId/photos")) + .post(multipart) + .build() + return execute(request).decode().photos.firstOrNull() + ?: throw ApiException(ApiError("empty_response", "server returned no photo", 201)) + } + + /** Renames a photo; the server returns the updated photo. */ + suspend fun renamePhoto(photoId: String, filename: String): PhotoDto = + patchJson("api/photos/$photoId", json.encodeToString(RenamePhotoRequest(filename))) + .decode() + .photo + + /** Soft-deletes a photo (moves it to the server trash). */ + suspend fun deletePhoto(photoId: String) { + execute(Request.Builder().url(api.endpoint("api/photos/$photoId")).delete().build()) + } + + /** Moves photos to another album in one batch. */ + suspend fun movePhotos(photoIds: List, albumId: String) { + postJson("api/photo-batch/move", json.encodeToString(BatchRequest(photoIds, albumId))) + } + + /** + * Control sums of an album's photos keyed by checksum, following pagination. + * Used by the device-album sync to avoid re-uploading existing content. + */ + suspend fun albumChecksums(albumId: String): Map { + val out = HashMap() + var cursor: String? = null + while (true) { + val page = photos(albumId, cursor = cursor, limit = 100) + page.photos.forEach { p -> + val checksum = p.originalChecksum + if (!checksum.isNullOrBlank()) out[checksum] = p.id + } + // The server reports "no more pages" as an empty cursor, not null. + val next = page.nextCursor + if (next.isNullOrBlank()) break + cursor = next + } + return out + } + + /** Raw authenticated request for the SSE stream. */ + fun sseRequest(albumId: String): Request = + Request.Builder() + .url(api.endpoint("api/sse/albums/$albumId")) + .header("Accept", "text/event-stream") + .build() + + private suspend fun get(path: String): HttpResult = + execute(Request.Builder().url(api.endpoint(path)).get().build()) + + private suspend fun postJson(path: String, body: String): HttpResult = + execute( + Request.Builder() + .url(api.endpoint(path)) + .post(body.toRequestBody(jsonMedia)) + .build(), + ) + + private suspend fun patchJson(path: String, body: String): HttpResult = + execute( + Request.Builder() + .url(api.endpoint(path)) + .patch(body.toRequestBody(jsonMedia)) + .build(), + ) + + private suspend fun deleteJson(path: String, body: String): HttpResult = + execute( + Request.Builder() + .url(api.endpoint(path)) + .delete(body.toRequestBody(jsonMedia)) + .build(), + ) + + /** + * Executes the request off the main thread and, crucially, buffers the whole + * response body inside the IO dispatcher. Reading the body lazily afterwards + * would hit the socket on the caller's thread and throw + * [android.os.NetworkOnMainThreadException] for large (chunked) responses. + */ + private suspend fun execute(request: Request): HttpResult = withContext(Dispatchers.IO) { + val response = try { + client.newCall(request).execute() + } catch (e: IOException) { + throw ApiException(ApiError("network_error", e.message ?: "network error", 0)) + } + val status = response.code + val body = response.use { runCatching { it.body?.string() }.getOrNull() } + if (!response.isSuccessful) { + throw ApiException(ErrorMapper.parse(status, body)) + } + HttpResult(status, body) + } + + private data class HttpResult(val status: Int, val body: String?) + + private inline fun HttpResult.decode(): T { + val text = body ?: throw ApiException( + ApiError("empty_response", "empty response body", status), + ) + return runCatching { json.decodeFromString(text) }.getOrElse { e -> + throw ApiException(ApiError("invalid_response", "malformed response: ${e.message}", status)) + } + } + + private fun urlEncode(value: String): String = + java.net.URLEncoder.encode(value, "UTF-8") +} diff --git a/android/app/src/main/java/com/galo/android/data/network/GaloJson.kt b/android/app/src/main/java/com/galo/android/data/network/GaloJson.kt new file mode 100644 index 0000000..62b0055 --- /dev/null +++ b/android/app/src/main/java/com/galo/android/data/network/GaloJson.kt @@ -0,0 +1,9 @@ +package com.galo.android.data.network + +import kotlinx.serialization.json.Json + +val GaloJson: Json = Json { + ignoreUnknownKeys = true + explicitNulls = false + encodeDefaults = true +} diff --git a/android/app/src/main/java/com/galo/android/data/network/HealthChecker.kt b/android/app/src/main/java/com/galo/android/data/network/HealthChecker.kt new file mode 100644 index 0000000..6588533 --- /dev/null +++ b/android/app/src/main/java/com/galo/android/data/network/HealthChecker.kt @@ -0,0 +1,33 @@ +package com.galo.android.data.network + +import com.galo.android.data.network.dto.HealthDto +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.IOException + +/** + * Standalone `/api/health` probe used during onboarding, before any base URL is + * persisted. Returns the parsed body or throws on transport failure. + */ +class HealthChecker(private val client: OkHttpClient) { + + suspend fun check(baseUrl: String): HealthDto = withContext(Dispatchers.IO) { + val url = baseUrl.trim().trimEnd('/') + "/api/health" + val request = Request.Builder().url(url).get().build() + val response = try { + client.newCall(request).execute() + } catch (e: IOException) { + throw IOException("health check failed: ${e.message}", e) + } + response.use { + val body = it.body?.string().orEmpty() + if (!it.isSuccessful) { + throw IOException("health returned HTTP ${it.code}") + } + runCatching { GaloJson.decodeFromString(body) } + .getOrElse { throw IOException("malformed health response") } + } + } +} diff --git a/android/app/src/main/java/com/galo/android/data/network/ObjectUrlResolver.kt b/android/app/src/main/java/com/galo/android/data/network/ObjectUrlResolver.kt new file mode 100644 index 0000000..b7b6b87 --- /dev/null +++ b/android/app/src/main/java/com/galo/android/data/network/ObjectUrlResolver.kt @@ -0,0 +1,23 @@ +package com.galo.android.data.network + +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull + +/** + * Resolves the origin-relative object URLs returned by the galo API + * (`/api/objects/?e=..&s=..`) against the configured base URL. + * + * Absolute URLs are passed through untouched; blank inputs yield null. + */ +class ObjectUrlResolver( + private val baseUrlProvider: () -> String?, +) { + + fun resolve(raw: String?): String? { + if (raw.isNullOrBlank()) return null + if (raw.toHttpUrlOrNull() != null) return raw + + val base = baseUrlProvider()?.trim()?.takeIf { it.isNotEmpty() } ?: return raw + val parsedBase = base.toHttpUrlOrNull() ?: return raw + return parsedBase.resolve(raw)?.toString() ?: raw + } +} diff --git a/android/app/src/main/java/com/galo/android/data/network/StreamingRequestBody.kt b/android/app/src/main/java/com/galo/android/data/network/StreamingRequestBody.kt new file mode 100644 index 0000000..c1d66d9 --- /dev/null +++ b/android/app/src/main/java/com/galo/android/data/network/StreamingRequestBody.kt @@ -0,0 +1,40 @@ +package com.galo.android.data.network + +import okhttp3.MediaType +import okhttp3.RequestBody +import okio.BufferedSink +import java.io.InputStream + +/** + * Streams a payload from [openStream] without buffering the whole file in memory + * (video originals can be very large) and reports upload progress. + */ +class StreamingRequestBody( + private val length: Long, + private val contentType: MediaType?, + private val openStream: () -> InputStream, + private val onProgress: (sent: Long, total: Long) -> Unit, +) : RequestBody() { + + override fun contentType(): MediaType? = contentType + + override fun contentLength(): Long = length + + override fun writeTo(sink: BufferedSink) { + openStream().use { input -> + val buffer = ByteArray(CHUNK_SIZE) + var sent = 0L + while (true) { + val read = input.read(buffer) + if (read < 0) break + sink.write(buffer, 0, read) + sent += read + onProgress(sent, length) + } + } + } + + private companion object { + const val CHUNK_SIZE = 16 * 1024 + } +} diff --git a/android/app/src/main/java/com/galo/android/data/network/TokenAuthenticator.kt b/android/app/src/main/java/com/galo/android/data/network/TokenAuthenticator.kt new file mode 100644 index 0000000..9f662ad --- /dev/null +++ b/android/app/src/main/java/com/galo/android/data/network/TokenAuthenticator.kt @@ -0,0 +1,92 @@ +package com.galo.android.data.network + +import com.galo.android.data.auth.TokenStore +import com.galo.android.data.network.dto.AuthTokensDto +import com.galo.android.data.network.dto.RefreshRequest +import com.galo.android.data.network.dto.TokenEnvelopeDto +import okhttp3.Authenticator +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import okhttp3.Route +import kotlinx.serialization.encodeToString + +/** + * Transparently refreshes the access token on a 401 and retries the original + * request once. A failed refresh clears the session so the UI can return to the + * login screen. + */ +class TokenAuthenticator( + private val tokenStore: TokenStore, + private val refreshClient: OkHttpClient, + private val refreshUrlProvider: () -> String?, +) : Authenticator { + + private val jsonMediaType = "application/json; charset=utf-8".toMediaType() + + override fun authenticate(route: Route?, response: Response): Request? { + // Only attempt refresh for requests that actually carried a token. + if (response.request.header(HEADER_AUTHORIZATION) == null) return null + if (responseCount(response) >= MAX_RETRIES) { + tokenStore.clear() + return null + } + + synchronized(this) { + // Another thread may already have refreshed while this one waited. + val current = response.request.header(HEADER_AUTHORIZATION) + val latest = tokenStore.accessToken()?.let { "$BEARER $it" } + if (latest != null && latest != current) { + return response.request.newBuilder() + .header(HEADER_AUTHORIZATION, latest) + .build() + } + + val refreshed = refresh() ?: run { + tokenStore.clear() + return null + } + return response.request.newBuilder() + .header(HEADER_AUTHORIZATION, "$BEARER ${refreshed.accessToken}") + .build() + } + } + + private fun refresh(): AuthTokensDto? { + val refreshToken = tokenStore.refreshToken() ?: return null + val url = refreshUrlProvider() ?: return null + val payload = GaloJson.encodeToString(RefreshRequest(refreshToken)) + val request = Request.Builder() + .url(url) + .post(payload.toRequestBody(jsonMediaType)) + .build() + return runCatching { + refreshClient.newCall(request).execute().use { resp -> + if (!resp.isSuccessful) return null + val body = resp.body?.string() ?: return null + val tokens = GaloJson.decodeFromString(body).tokens + if (tokens.accessToken.isBlank()) return null + tokenStore.save(tokens.accessToken, tokens.refreshToken) + if (tokens.refreshToken.isBlank()) null else tokens + } + }.getOrNull() + } + + private fun responseCount(response: Response): Int { + var count = 1 + var prior = response.priorResponse + while (prior != null) { + count++ + prior = prior.priorResponse + } + return count + } + + private companion object { + const val HEADER_AUTHORIZATION = "Authorization" + const val BEARER = "Bearer" + const val MAX_RETRIES = 2 + } +} diff --git a/android/app/src/main/java/com/galo/android/data/network/dto/Dtos.kt b/android/app/src/main/java/com/galo/android/data/network/dto/Dtos.kt new file mode 100644 index 0000000..eda7e82 --- /dev/null +++ b/android/app/src/main/java/com/galo/android/data/network/dto/Dtos.kt @@ -0,0 +1,185 @@ +package com.galo.android.data.network.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class HealthDto( + val status: String = "", + val db: String? = null, + val s3: String? = null, + val video: String? = null, +) + +@Serializable +data class AuthTokensDto( + @SerialName("access_token") val accessToken: String = "", + @SerialName("refresh_token") val refreshToken: String = "", + @SerialName("token_type") val tokenType: String = "Bearer", + @SerialName("expires_in") val expiresIn: Long = 0, +) + +/** Login/register/refresh all wrap the token pair under `tokens`. */ +@Serializable +data class TokenEnvelopeDto( + val tokens: AuthTokensDto = AuthTokensDto(), +) + +@Serializable +data class UserDto( + val id: String = "", + val email: String? = null, + val phone: String? = null, + val name: String = "", + @SerialName("avatar_url") val avatarUrl: String? = null, +) + +@Serializable +data class UserEnvelopeDto( + val user: UserDto = UserDto(), +) + +@Serializable +data class RegisterRequest( + val email: String = "", + val phone: String = "", + val password: String, + val name: String, +) + +@Serializable +data class LoginRequest( + val identifier: String, + val password: String, +) + +@Serializable +data class UpdateProfileRequest( + val name: String, +) + +@Serializable +data class ChangePasswordRequest( + @SerialName("current_password") val currentPassword: String, + @SerialName("new_password") val newPassword: String, +) + +@Serializable +data class AddIdentifierRequest( + val email: String = "", + val phone: String = "", +) + +@Serializable +data class DeleteAccountRequest( + val password: String, +) + +@Serializable +data class RefreshRequest( + @SerialName("refresh_token") val refreshToken: String, +) + +@Serializable +data class LogoutRequest( + @SerialName("refresh_token") val refreshToken: String, +) + +@Serializable +data class RenamePhotoRequest( + @SerialName("original_filename") val originalFilename: String, +) + +@Serializable +data class BatchRequest( + val ids: List, + @SerialName("album_id") val albumId: String? = null, +) + +@Serializable +data class CreateAlbumRequest( + val title: String, +) + +@Serializable +data class AlbumDto( + val id: String = "", + val title: String = "", + val description: String = "", + @SerialName("owner_id") val ownerId: String = "", + @SerialName("cover_photo_id") val coverPhotoId: String? = null, + @SerialName("cover_thumb_url") val coverThumbUrl: String? = null, + @SerialName("cover_medium_url") val coverMediumUrl: String? = null, + @SerialName("photos_count") val photosCount: Int = 0, + @SerialName("members_count") val membersCount: Int = 0, + @SerialName("created_at") val createdAt: String? = null, + @SerialName("updated_at") val updatedAt: String? = null, +) + +@Serializable +data class AlbumListDto( + val albums: List = emptyList(), +) + +@Serializable +data class AlbumEnvelopeDto( + val album: AlbumDto, + val role: String? = null, +) + +@Serializable +data class PhotoDto( + val id: String = "", + @SerialName("album_id") val albumId: String = "", + @SerialName("original_filename") val originalFilename: String = "", + val kind: String = "", + @SerialName("mime_type") val mimeType: String? = null, + @SerialName("original_checksum") val originalChecksum: String? = null, + val width: Int = 0, + val height: Int = 0, + @SerialName("duration_ms") val durationMs: Long = 0, + val size: Long = 0, + @SerialName("taken_at") val takenAt: String? = null, + val caption: String = "", + val tags: List = emptyList(), + @SerialName("thumb_url") val thumbUrl: String? = null, + @SerialName("medium_url") val mediumUrl: String? = null, + @SerialName("preview_url") val previewUrl: String? = null, + @SerialName("playback_url") val playbackUrl: String? = null, + @SerialName("original_url") val originalUrl: String? = null, + @SerialName("download_url") val downloadUrl: String? = null, + @SerialName("created_at") val createdAt: String? = null, +) + +@Serializable +data class TagDto( + val id: String = "", + val name: String = "", +) + +@Serializable +data class PhotoListDto( + val photos: List = emptyList(), + @SerialName("next_cursor") val nextCursor: String? = null, +) + +@Serializable +data class PhotoEnvelopeDto( + val photo: PhotoDto, +) + +@Serializable +data class UploadResponseDto( + val photos: List = emptyList(), +) + +@Serializable +data class ErrorEnvelopeDto( + val error: ErrorBodyDto? = null, +) + +@Serializable +data class ErrorBodyDto( + val code: String = "internal_error", + val message: String = "internal server error", +) diff --git a/android/app/src/main/java/com/galo/android/data/prefs/SettingsRepository.kt b/android/app/src/main/java/com/galo/android/data/prefs/SettingsRepository.kt new file mode 100644 index 0000000..b0c27f3 --- /dev/null +++ b/android/app/src/main/java/com/galo/android/data/prefs/SettingsRepository.kt @@ -0,0 +1,60 @@ +package com.galo.android.data.prefs + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +private val Context.settingsDataStore: DataStore by preferencesDataStore( + name = "galo_settings", +) + +/** Non-secret connection settings persisted in DataStore. */ +data class SettingsState( + val baseUrl: String, + val insecureAllowed: Boolean, + val wifiOnlyUploads: Boolean, +) + +class SettingsRepository(context: Context) { + + private val dataStore = context.applicationContext.settingsDataStore + + val flow: Flow = dataStore.data.map { prefs -> + SettingsState( + baseUrl = prefs[KEY_BASE_URL].orEmpty(), + insecureAllowed = prefs[KEY_INSECURE] ?: false, + wifiOnlyUploads = prefs[KEY_WIFI_ONLY] ?: false, + ) + } + + suspend fun setBaseUrl(url: String) { + dataStore.edit { it[KEY_BASE_URL] = url.trim() } + } + + suspend fun setInsecureAllowed(allowed: Boolean) { + dataStore.edit { it[KEY_INSECURE] = allowed } + } + + suspend fun setWifiOnlyUploads(enabled: Boolean) { + dataStore.edit { it[KEY_WIFI_ONLY] = enabled } + } + + suspend fun clear() { + dataStore.edit { prefs -> + prefs.remove(KEY_BASE_URL) + prefs.remove(KEY_INSECURE) + } + } + + private companion object { + val KEY_BASE_URL = stringPreferencesKey("base_url") + val KEY_INSECURE = booleanPreferencesKey("insecure_allowed") + val KEY_WIFI_ONLY = booleanPreferencesKey("wifi_only_uploads") + } +} diff --git a/android/app/src/main/java/com/galo/android/data/repo/AlbumRepository.kt b/android/app/src/main/java/com/galo/android/data/repo/AlbumRepository.kt new file mode 100644 index 0000000..fc281e6 --- /dev/null +++ b/android/app/src/main/java/com/galo/android/data/repo/AlbumRepository.kt @@ -0,0 +1,43 @@ +package com.galo.android.data.repo + +import com.galo.android.data.mapper.toDomain +import com.galo.android.data.mapper.toEntity +import com.galo.android.data.network.ObjectUrlResolver +import com.galo.android.domain.model.Album +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +/** + * Album browsing with a cache-first flow: the UI always observes Room, and a + * [refresh] fetches the server snapshot when the network is available. A failed + * refresh leaves the cached data intact (offline mode). + */ +class AlbumRepository( + private val remote: AlbumRemoteSource, + private val local: AlbumLocalSource, + private val resolver: ObjectUrlResolver, +) { + + fun observeAlbums(): Flow> = + local.observeEntities().map { entities -> entities.map { it.toDomain(resolver) } } + + fun observeAlbum(albumId: String): Flow = + observeAlbums().map { albums -> albums.firstOrNull { it.id == albumId } } + + /** @return true when the server snapshot was fetched, false when offline. */ + suspend fun refresh(): Boolean = try { + val albums = remote.fetchAlbums() + local.replaceAll(albums.map { it.toEntity() }) + true + } catch (e: Exception) { + false + } + + /** Refreshes a single album (covers metadata the list endpoint may omit). */ + suspend fun loadAlbum(albumId: String): Boolean = try { + local.upsert(listOf(remote.fetchAlbum(albumId).toEntity())) + true + } catch (_: Exception) { + false + } +} diff --git a/android/app/src/main/java/com/galo/android/data/repo/PhotoRepository.kt b/android/app/src/main/java/com/galo/android/data/repo/PhotoRepository.kt new file mode 100644 index 0000000..d496790 --- /dev/null +++ b/android/app/src/main/java/com/galo/android/data/repo/PhotoRepository.kt @@ -0,0 +1,64 @@ +package com.galo.android.data.repo + +import com.galo.android.data.mapper.toDomain +import com.galo.android.data.mapper.toEntity +import com.galo.android.data.network.ObjectUrlResolver +import com.galo.android.data.network.dto.PhotoDto +import com.galo.android.domain.model.Photo +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +/** Outcome of a page fetch; [online] is false when the cache was served instead. */ +data class PageResult( + val nextCursor: String?, + val online: Boolean, +) + +/** + * Album media browsing. Pages fetched by cursor are merged into the local store + * so the grid can be replayed offline, and cache rows are keyed by stable photo + * id so repeated pages do not duplicate entries. + */ +class PhotoRepository( + private val remote: PhotoRemoteSource, + private val local: PhotoLocalSource, + private val resolver: ObjectUrlResolver, +) { + + fun observePhotos(albumId: String): Flow> = + local.observeForAlbum(albumId).map { entities -> entities.map { it.toDomain(resolver) } } + + suspend fun loadNextPage(albumId: String, cursor: String?): PageResult = try { + val page = remote.fetchPage(albumId, cursor) + local.upsertAll(page.photos.map { it.toEntity() }) + PageResult(nextCursor = page.nextCursor, online = true) + } catch (e: Exception) { + PageResult(nextCursor = null, online = false) + } + + /** Catches up on changes missed while an SSE stream was disconnected. */ + suspend fun loadSince(albumId: String, since: String): Boolean = try { + val page = remote.fetchSince(albumId, since) + local.upsertAll(page.photos.map { it.toEntity() }) + true + } catch (e: Exception) { + android.util.Log.w("PhotoRepository", "loadSince failed", e) + false + } + + suspend fun upsert(photo: PhotoDto) { + local.upsertAll(listOf(photo.toEntity())) + } + + /** Re-fetches a single photo (e.g. after a social event) and updates the cache. */ + suspend fun refreshPhoto(photoId: String): Boolean = try { + local.upsertAll(listOf(remote.fetchPhoto(photoId).toEntity())) + true + } catch (_: Exception) { + false + } + + suspend fun remove(photoId: String) { + local.deleteById(photoId) + } +} diff --git a/android/app/src/main/java/com/galo/android/data/repo/Sources.kt b/android/app/src/main/java/com/galo/android/data/repo/Sources.kt new file mode 100644 index 0000000..89ef96b --- /dev/null +++ b/android/app/src/main/java/com/galo/android/data/repo/Sources.kt @@ -0,0 +1,69 @@ +package com.galo.android.data.repo + +import com.galo.android.data.db.dao.AlbumDao +import com.galo.android.data.db.dao.PhotoDao +import com.galo.android.data.db.entity.AlbumEntity +import com.galo.android.data.db.entity.PhotoEntity +import com.galo.android.data.network.GaloApi +import com.galo.android.data.network.dto.AlbumDto +import com.galo.android.data.network.dto.PhotoDto +import com.galo.android.data.network.dto.PhotoListDto +import kotlinx.coroutines.flow.Flow + +interface AlbumRemoteSource { + suspend fun fetchAlbums(): List + suspend fun fetchAlbum(albumId: String): AlbumDto +} + +interface AlbumLocalSource { + fun observeEntities(): Flow> + suspend fun replaceAll(albums: List) + suspend fun upsert(albums: List) + suspend fun count(): Int +} + +interface PhotoRemoteSource { + suspend fun fetchPage(albumId: String, cursor: String?): PhotoListDto + suspend fun fetchSince(albumId: String, since: String): PhotoListDto + suspend fun fetchPhoto(photoId: String): PhotoDto +} + +interface PhotoLocalSource { + fun observeForAlbum(albumId: String): Flow> + suspend fun upsertAll(photos: List) + suspend fun deleteById(id: String) + suspend fun countForAlbum(albumId: String): Int +} + +class ApiAlbumRemoteSource(private val api: GaloApi) : AlbumRemoteSource { + override suspend fun fetchAlbums(): List = api.albums() + override suspend fun fetchAlbum(albumId: String): AlbumDto = api.album(albumId) +} + +class RoomAlbumLocalSource(private val dao: AlbumDao) : AlbumLocalSource { + override fun observeEntities(): Flow> = dao.observeAll() + override suspend fun replaceAll(albums: List) = dao.replaceAll(albums) + override suspend fun upsert(albums: List) = dao.upsertAll(albums) + override suspend fun count(): Int = dao.count() +} + +class ApiPhotoRemoteSource(private val api: GaloApi) : PhotoRemoteSource { + override suspend fun fetchPage(albumId: String, cursor: String?): PhotoListDto = + api.photos(albumId, cursor = cursor) + + override suspend fun fetchSince(albumId: String, since: String): PhotoListDto = + api.photos(albumId, since = since, limit = 200) + + override suspend fun fetchPhoto(photoId: String): PhotoDto = api.photo(photoId) +} + +class RoomPhotoLocalSource(private val dao: PhotoDao) : PhotoLocalSource { + override fun observeForAlbum(albumId: String): Flow> = + dao.observeForAlbum(albumId) + + override suspend fun upsertAll(photos: List) = dao.upsertAll(photos) + + override suspend fun deleteById(id: String) = dao.deleteById(id) + + override suspend fun countForAlbum(albumId: String): Int = dao.countForAlbum(albumId) +} diff --git a/android/app/src/main/java/com/galo/android/data/repo/SyncRepository.kt b/android/app/src/main/java/com/galo/android/data/repo/SyncRepository.kt new file mode 100644 index 0000000..df77d92 --- /dev/null +++ b/android/app/src/main/java/com/galo/android/data/repo/SyncRepository.kt @@ -0,0 +1,80 @@ +package com.galo.android.data.repo + +import com.galo.android.data.db.dao.SyncDao +import com.galo.android.data.db.entity.DeviceAssetEntity +import com.galo.android.data.db.entity.SyncedAlbumEntity +import com.galo.android.data.media.MediaStoreDeviceSource +import com.galo.android.domain.model.DeviceAlbum +import com.galo.android.domain.model.LocalAssetState +import com.galo.android.domain.model.SyncMode +import kotlinx.coroutines.flow.Flow + +/** Device albums, their links to server albums and their asset index. */ +class SyncRepository( + private val dao: SyncDao, + private val deviceSource: MediaStoreDeviceSource, +) { + + fun observeLinks(): Flow> = dao.observeSyncedAlbums() + + fun observeAssets(deviceAlbumId: String): Flow> = + dao.observeAssets(deviceAlbumId) + + suspend fun assets(deviceAlbumId: String): List = dao.getAssets(deviceAlbumId) + + suspend fun getAsset(assetKey: String): DeviceAssetEntity? = dao.getAsset(assetKey) + + suspend fun currentLink(deviceAlbumId: String): SyncedAlbumEntity? = + dao.getSyncedAlbum(deviceAlbumId) + + fun deviceAlbums(): List = deviceSource.albums() + + fun deviceItems(albumId: String) = deviceSource.items(albumId) + + suspend fun link( + deviceAlbumId: String, + deviceAlbumName: String, + serverAlbumId: String, + mode: SyncMode, + ) = dao.upsertSyncedAlbum( + SyncedAlbumEntity( + deviceAlbumId = deviceAlbumId, + deviceAlbumName = deviceAlbumName, + serverAlbumId = serverAlbumId, + mode = mode.name, + updatedAt = System.currentTimeMillis(), + ), + ) + + suspend fun unlink(deviceAlbumId: String) = dao.deleteSyncedAlbum(deviceAlbumId) + + /** Records the server photo created for a device asset (called after upload). */ + suspend fun recordUploaded( + deviceAlbumId: String, + assetKey: String, + mediaStoreId: Long, + uri: String, + displayName: String, + mimeType: String?, + contentHash: String?, + serverPhotoId: String, + state: LocalAssetState, + ) = dao.upsertAssets( + listOf( + DeviceAssetEntity( + assetKey = assetKey, + deviceAlbumId = deviceAlbumId, + mediaStoreId = mediaStoreId, + uri = uri, + displayName = displayName, + mimeType = mimeType, + size = 0L, + dateModified = 0L, + contentHash = contentHash, + serverPhotoId = serverPhotoId, + uploadedAt = System.currentTimeMillis(), + state = state.name, + ), + ), + ) +} diff --git a/android/app/src/main/java/com/galo/android/data/sync/ApiOriginalFetcher.kt b/android/app/src/main/java/com/galo/android/data/sync/ApiOriginalFetcher.kt new file mode 100644 index 0000000..709b30d --- /dev/null +++ b/android/app/src/main/java/com/galo/android/data/sync/ApiOriginalFetcher.kt @@ -0,0 +1,24 @@ +package com.galo.android.data.sync + +import com.galo.android.data.network.ApiClient +import com.galo.android.data.network.GaloApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.Request + +/** [OriginalFetcher] that downloads the original through the authenticated client. */ +class ApiOriginalFetcher( + private val api: GaloApi, + private val apiClient: ApiClient, +) : OriginalFetcher { + + override suspend fun fetch(photoId: String): ByteArray? = withContext(Dispatchers.IO) { + val photo = runCatching { api.photo(photoId) }.getOrNull() ?: return@withContext null + val url = apiClient.objectUrls.resolve(photo.downloadUrl ?: photo.originalUrl) ?: return@withContext null + runCatching { + apiClient.client.newCall(Request.Builder().url(url).build()).execute().use { response -> + if (!response.isSuccessful) null else response.body?.bytes() + } + }.getOrNull() + } +} diff --git a/android/app/src/main/java/com/galo/android/data/sync/ApiSyncRemote.kt b/android/app/src/main/java/com/galo/android/data/sync/ApiSyncRemote.kt new file mode 100644 index 0000000..f9191d1 --- /dev/null +++ b/android/app/src/main/java/com/galo/android/data/sync/ApiSyncRemote.kt @@ -0,0 +1,22 @@ +package com.galo.android.data.sync + +import com.galo.android.data.network.GaloApi + +/** [SyncRemote] backed by the galo REST API. */ +class ApiSyncRemote(private val api: GaloApi) : SyncRemote { + + override suspend fun serverChecksums(serverAlbumId: String): Map = + api.albumChecksums(serverAlbumId) + + override suspend fun renamePhoto(photoId: String, newName: String) { + api.renamePhoto(photoId, newName) + } + + override suspend fun deletePhoto(photoId: String) { + api.deletePhoto(photoId) + } + + override suspend fun movePhotos(photoIds: List, serverAlbumId: String) { + api.movePhotos(photoIds, serverAlbumId) + } +} diff --git a/android/app/src/main/java/com/galo/android/data/sync/ChangeDetection.kt b/android/app/src/main/java/com/galo/android/data/sync/ChangeDetection.kt new file mode 100644 index 0000000..66f5561 --- /dev/null +++ b/android/app/src/main/java/com/galo/android/data/sync/ChangeDetection.kt @@ -0,0 +1,31 @@ +package com.galo.android.data.sync + +import android.content.Context +import android.os.Build +import android.provider.MediaStore + +/** Decides whether MediaStore changed since the last observed generation. */ +object ChangeDetector { + + /** + * A zero generation means "unknown" (unsupported or first run); the caller + * then falls back to a full pass rather than reporting a change. + */ + fun changed(lastGeneration: Long, currentGeneration: Long): Boolean = + lastGeneration != 0L && currentGeneration != lastGeneration +} + +/** Reads the MediaStore generation, used to detect device media changes. */ +object MediaStoreGeneration { + + fun current(context: Context): Long = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + runCatching { + MediaStore.getGeneration(context, MediaStore.VOLUME_EXTERNAL_PRIMARY) + }.getOrDefault(0L) + } else { + // getGeneration is unavailable before Android 11; callers fall back + // to a full pass. + 0L + } +} diff --git a/android/app/src/main/java/com/galo/android/data/sync/ContentHash.kt b/android/app/src/main/java/com/galo/android/data/sync/ContentHash.kt new file mode 100644 index 0000000..cb06f60 --- /dev/null +++ b/android/app/src/main/java/com/galo/android/data/sync/ContentHash.kt @@ -0,0 +1,33 @@ +package com.galo.android.data.sync + +import java.io.InputStream +import java.security.MessageDigest + +/** Content hash (sha256, lowercase hex) of the bytes that leave the device. */ +object ContentHash { + + fun sha256(bytes: ByteArray): String = + hex(MessageDigest.getInstance("SHA-256").digest(bytes)) + + fun sha256(input: InputStream): String { + val digest = MessageDigest.getInstance("SHA-256") + val buffer = ByteArray(64 * 1024) + while (true) { + val read = input.read(buffer) + if (read < 0) break + digest.update(buffer, 0, read) + } + return hex(digest.digest()) + } + + private fun hex(bytes: ByteArray): String = + buildString(bytes.size * 2) { + for (b in bytes) { + val v = b.toInt() and 0xFF + append(HEX[v ushr 4]) + append(HEX[v and 0x0F]) + } + } + + private const val HEX = "0123456789abcdef" +} diff --git a/android/app/src/main/java/com/galo/android/data/sync/DeviceMediaObserver.kt b/android/app/src/main/java/com/galo/android/data/sync/DeviceMediaObserver.kt new file mode 100644 index 0000000..34ff56c --- /dev/null +++ b/android/app/src/main/java/com/galo/android/data/sync/DeviceMediaObserver.kt @@ -0,0 +1,48 @@ +package com.galo.android.data.sync + +import android.content.Context +import android.database.ContentObserver +import android.net.Uri +import android.os.Handler +import android.os.Looper +import android.provider.MediaStore + +/** + * Watches MediaStore while the app process is alive and notifies on changes so + * synchronization can run promptly in the foreground. Background changes are + * covered by the periodic worker. + */ +class DeviceMediaObserver( + private val context: Context, + private val onChanged: () -> Unit, +) { + + private val observer = object : ContentObserver(Handler(Looper.getMainLooper())) { + override fun onChange(selfChange: Boolean) { + onChanged() + } + + override fun onChange(selfChange: Boolean, uri: Uri?) { + onChanged() + } + } + + fun start() { + runCatching { + context.contentResolver.registerContentObserver( + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + true, + observer, + ) + context.contentResolver.registerContentObserver( + MediaStore.Video.Media.EXTERNAL_CONTENT_URI, + true, + observer, + ) + } + } + + fun stop() { + runCatching { context.contentResolver.unregisterContentObserver(observer) } + } +} diff --git a/android/app/src/main/java/com/galo/android/data/sync/LocalAssetProvider.kt b/android/app/src/main/java/com/galo/android/data/sync/LocalAssetProvider.kt new file mode 100644 index 0000000..9634102 --- /dev/null +++ b/android/app/src/main/java/com/galo/android/data/sync/LocalAssetProvider.kt @@ -0,0 +1,51 @@ +package com.galo.android.data.sync + +import android.content.Context +import android.net.Uri +import com.galo.android.data.db.dao.SyncDao +import com.galo.android.data.db.entity.DeviceAssetEntity +import com.galo.android.data.media.MediaStoreDeviceSource +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.InputStream + +/** Supplies the device's current content to the synchronization engine. */ +interface LocalAssetProvider { + suspend fun assets(deviceAlbumId: String): List + + /** Content hashes still present in other albums linked to the same server. */ + suspend fun hashesOutside(deviceAlbumId: String): Set + + fun openStream(uri: String): InputStream? +} + +/** Reads device assets through MediaStore and hashes their (uploadable) bytes. */ +class DeviceLocalAssetProvider( + private val context: Context, + private val dao: SyncDao, + private val deviceSource: MediaStoreDeviceSource, +) : LocalAssetProvider { + + override suspend fun assets(deviceAlbumId: String): List = withContext(Dispatchers.IO) { + deviceSource.items(deviceAlbumId).map { media -> + LocalAsset( + assetKey = DeviceAssetEntity.key(deviceAlbumId, media.id), + mediaStoreId = media.id, + displayName = media.displayName, + mimeType = media.mimeType, + kind = media.kind, + contentHash = hash(media.uri), + uri = media.uri, + ) + } + } + + override suspend fun hashesOutside(deviceAlbumId: String): Set = + dao.hashesOutside(deviceAlbumId).toSet() + + override fun openStream(uri: String): InputStream? = + runCatching { context.contentResolver.openInputStream(Uri.parse(uri)) }.getOrNull() + + private fun hash(uri: String): String? = + runCatching { openStream(uri)?.use { ContentHash.sha256(it) } }.getOrNull() +} diff --git a/android/app/src/main/java/com/galo/android/data/sync/OffloadManager.kt b/android/app/src/main/java/com/galo/android/data/sync/OffloadManager.kt new file mode 100644 index 0000000..5ed4ce5 --- /dev/null +++ b/android/app/src/main/java/com/galo/android/data/sync/OffloadManager.kt @@ -0,0 +1,170 @@ +package com.galo.android.data.sync + +import android.content.ContentValues +import android.content.Context +import android.content.IntentSender +import android.net.Uri +import android.os.Build +import android.provider.MediaStore +import androidx.exifinterface.media.ExifInterface +import com.galo.android.data.db.dao.SyncDao +import com.galo.android.data.db.entity.DeviceAssetEntity +import com.galo.android.data.media.PreviewGenerator +import com.galo.android.domain.model.LocalAssetState +import com.galo.android.domain.model.MediaKind +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileOutputStream + +/** Outcome of offloading one device asset in mode C. */ +sealed interface OffloadResult { + data object Done : OffloadResult + data class NeedsWriteConsent(val uris: List) : OffloadResult + data class NeedsDeleteConsent(val uris: List) : OffloadResult + data object Failed : OffloadResult +} + +/** Replaces a device original with its offloaded version (mode C). */ +interface AssetOffloader { + suspend fun offload(asset: DeviceAssetEntity): OffloadResult +} + +/** + * Mode C offload: replaces a device original with a light preview, in place for + * images and as a poster for videos. Media the app did not create cannot be + * changed silently, so the operation reports that the OS consent request is + * needed instead of failing. + */ +class OffloadManager( + private val context: Context, + private val dao: SyncDao, +) : AssetOffloader { + + private val mutex = Mutex() + + override suspend fun offload(asset: DeviceAssetEntity): OffloadResult = withContext(Dispatchers.IO) { + // Serialize offloads so two passes never fight over the same asset. + mutex.withLock { + runCatching { + val uri = Uri.parse(asset.uri) + val serverAlbumId = dao.getSyncedAlbum(asset.deviceAlbumId)?.serverAlbumId + if (kindOf(asset.mimeType) == MediaKind.VIDEO) { + offloadVideo(asset, uri, serverAlbumId) + } else { + offloadPhoto(asset, uri, serverAlbumId) + } + }.getOrDefault(OffloadResult.Failed) + } + } + + /** A batch write-consent request for images that must be overwritten. */ + fun writeConsent(uris: List): IntentSender? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && uris.isNotEmpty()) { + MediaStore.createWriteRequest(context.contentResolver, uris).intentSender + } else { + null + } + + /** A batch delete-consent request for videos that must be replaced. */ + fun deleteConsent(uris: List): IntentSender? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && uris.isNotEmpty()) { + MediaStore.createDeleteRequest(context.contentResolver, uris).intentSender + } else { + null + } + + private suspend fun offloadPhoto(asset: DeviceAssetEntity, uri: Uri, serverAlbumId: String?): OffloadResult { + val preview = PreviewGenerator.tempPreviewFile(context, asset.assetKey) + val sourceExif = runCatching { + context.contentResolver.openInputStream(uri)?.use { ExifInterface(it) } + }.getOrNull() + + if (!PreviewGenerator.generate(context, uri, MediaKind.IMAGE, preview)) return OffloadResult.Failed + ProvenanceStamp.copyDateTaken(sourceExif, preview) + ProvenanceStamp.write(preview, provenanceOf(asset, serverAlbumId)) + + return try { + writeInPlace(uri, preview) + markPreview(asset) + OffloadResult.Done + } catch (e: SecurityException) { + OffloadResult.NeedsWriteConsent(listOf(uri)) + } finally { + preview.delete() + } + } + + private suspend fun offloadVideo(asset: DeviceAssetEntity, uri: Uri, serverAlbumId: String?): OffloadResult { + val poster = PreviewGenerator.tempPreviewFile(context, asset.assetKey) + if (!PreviewGenerator.generate(context, uri, MediaKind.VIDEO, poster)) return OffloadResult.Failed + ProvenanceStamp.write(poster, provenanceOf(asset, serverAlbumId)) + val relativePath = relativePathOf(uri) + + return try { + // Deleting the video is the step that needs consent for media the app + // did not create; do it before inserting the poster to avoid a duplicate. + context.contentResolver.delete(uri, null, null) + insertPoster(asset, poster, relativePath) ?: return OffloadResult.Failed + markPreview(asset) + OffloadResult.Done + } catch (e: SecurityException) { + OffloadResult.NeedsDeleteConsent(listOf(uri)) + } finally { + poster.delete() + } + } + + private fun writeInPlace(uri: Uri, preview: File) { + val pfd = context.contentResolver.openFileDescriptor(uri, "rwt") + ?: throw IllegalStateException("cannot open $uri") + pfd.use { descriptor -> + FileOutputStream(descriptor.fileDescriptor).use { out -> + preview.inputStream().use { it.copyTo(out) } + } + } + } + + private fun insertPoster(asset: DeviceAssetEntity, poster: File, relativePath: String): Uri? { + val name = asset.displayName.substringBeforeLast('.') + "-preview.jpg" + val values = ContentValues().apply { + put(MediaStore.Images.Media.DISPLAY_NAME, name) + put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg") + put(MediaStore.Images.Media.RELATIVE_PATH, relativePath) + put(MediaStore.Images.Media.IS_PENDING, 1) + } + val collection = MediaStore.Images.Media.EXTERNAL_CONTENT_URI + val inserted = context.contentResolver.insert(collection, values) ?: return null + context.contentResolver.openOutputStream(inserted)?.use { out -> + poster.inputStream().use { it.copyTo(out) } + } + values.clear() + values.put(MediaStore.Images.Media.IS_PENDING, 0) + context.contentResolver.update(inserted, values, null, null) + return inserted + } + + private fun relativePathOf(uri: Uri): String = + runCatching { + context.contentResolver.query(uri, arrayOf(MediaStore.Video.Media.RELATIVE_PATH), null, null, null) + ?.use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null } + }.getOrNull().orEmpty().ifBlank { "DCIM/" } + + private fun provenanceOf(asset: DeviceAssetEntity, serverAlbumId: String?) = Provenance( + serverAlbumId = serverAlbumId, + serverPhotoId = asset.serverPhotoId, + originalHash = asset.contentHash, + deviceAlbumId = asset.deviceAlbumId, + mode = LocalAssetState.PREVIEW.name, + assetKey = asset.assetKey, + ) + + private suspend fun markPreview(asset: DeviceAssetEntity) { + dao.markState(asset.assetKey, LocalAssetState.PREVIEW.name) + } + + private fun kindOf(mimeType: String?): MediaKind = + if (mimeType?.startsWith("video/") == true) MediaKind.VIDEO else MediaKind.IMAGE +} diff --git a/android/app/src/main/java/com/galo/android/data/sync/ProvenanceStamp.kt b/android/app/src/main/java/com/galo/android/data/sync/ProvenanceStamp.kt new file mode 100644 index 0000000..8a5e271 --- /dev/null +++ b/android/app/src/main/java/com/galo/android/data/sync/ProvenanceStamp.kt @@ -0,0 +1,85 @@ +package com.galo.android.data.sync + +import android.content.Context +import android.net.Uri +import androidx.exifinterface.media.ExifInterface +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** Provenance carried by an offloaded preview so it survives a reinstall. */ +data class Provenance( + val serverAlbumId: String?, + val serverPhotoId: String?, + val originalHash: String?, + val deviceAlbumId: String?, + val mode: String?, + val assetKey: String?, +) + +/** + * Reads and writes the provenance marker in the EXIF of the previews the app + * writes back to the gallery (mode C) and of video posters. + */ +object ProvenanceStamp { + + private const val MARK = "galo" + + fun write(file: File, p: Provenance): Boolean = runCatching { + val exif = ExifInterface(file) + exif.setAttribute(ExifInterface.TAG_USER_COMMENT, encode(p)) + exif.saveAttributes() + }.isSuccess + + fun read(file: File): Provenance? = + runCatching { decode(ExifInterface(file).getAttribute(ExifInterface.TAG_USER_COMMENT)) }.getOrNull() + + fun read(context: Context, uri: Uri): Provenance? = runCatching { + context.contentResolver.openInputStream(uri)?.use { stream -> + decode(ExifInterface(stream).getAttribute(ExifInterface.TAG_USER_COMMENT)) + } + }.getOrNull() + + fun encode(p: Provenance): String = buildString { + append(MARK) + append('|').append("album=").append(p.serverAlbumId ?: "") + append('|').append("photo=").append(p.serverPhotoId ?: "") + append('|').append("hash=").append(p.originalHash ?: "") + append('|').append("device=").append(p.deviceAlbumId ?: "") + append('|').append("mode=").append(p.mode ?: "") + append('|').append("asset=").append(p.assetKey ?: "") + } + + fun decode(raw: String?): Provenance? { + if (raw.isNullOrBlank()) return null + val parts = raw.split('|') + if (parts.firstOrNull() != MARK) return null + val map = parts.drop(1).mapNotNull { seg -> + val idx = seg.indexOf('=') + if (idx <= 0) null else seg.substring(0, idx) to seg.substring(idx + 1) + }.toMap() + return Provenance( + serverAlbumId = map["album"]?.ifBlank { null }, + serverPhotoId = map["photo"]?.ifBlank { null }, + originalHash = map["hash"]?.ifBlank { null }, + deviceAlbumId = map["device"]?.ifBlank { null }, + mode = map["mode"]?.ifBlank { null }, + assetKey = map["asset"]?.ifBlank { null }, + ) + } + + /** Copies the capture date of the source onto the generated preview. */ + fun copyDateTaken(source: ExifInterface?, target: File) { + val original = source?.getAttribute(ExifInterface.TAG_DATETIME_ORIGINAL) ?: return + runCatching { + val exif = ExifInterface(target) + exif.setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL, original) + exif.setAttribute(ExifInterface.TAG_DATETIME, original) + exif.saveAttributes() + } + } + + fun formatDate(millis: Long): String = + SimpleDateFormat("yyyy:MM:dd HH:mm:ss", Locale.US).format(Date(millis)) +} diff --git a/android/app/src/main/java/com/galo/android/data/sync/RestoreManager.kt b/android/app/src/main/java/com/galo/android/data/sync/RestoreManager.kt new file mode 100644 index 0000000..6391d11 --- /dev/null +++ b/android/app/src/main/java/com/galo/android/data/sync/RestoreManager.kt @@ -0,0 +1,77 @@ +package com.galo.android.data.sync + +import android.content.Context +import android.net.Uri +import com.galo.android.data.db.dao.SyncDao +import com.galo.android.data.db.entity.DeviceAssetEntity +import com.galo.android.domain.model.LocalAssetState +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.FileOutputStream + +/** Fetches the original bytes of a server photo by its id. */ +interface OriginalFetcher { + suspend fun fetch(photoId: String): ByteArray? +} + +/** Outcome of restoring originals for a set of offloaded assets. */ +data class RestoreReport( + val restored: Int, + val bytes: Long, + val needsConsent: Int, + val outOfSpace: Boolean, +) + +/** + * Returns originals to the device by writing the server bytes back over the + * preview, in place. Fails safe: if restoring would not fit in the free space it + * stops and reports it, and media the app does not own is reported for consent. + */ +class RestoreManager( + private val context: Context, + private val dao: SyncDao, + private val fetcher: OriginalFetcher, +) { + + fun availableBytes(): Long = runCatching { context.cacheDir.usableSpace }.getOrDefault(0L) + + fun canRestore(requiredBytes: Long, availableBytes: Long): Boolean = + Companion.canRestore(requiredBytes, availableBytes) + + companion object { + /** Restoring must fit in the free space; otherwise it is deferred. */ + fun canRestore(requiredBytes: Long, availableBytes: Long): Boolean = + availableBytes >= requiredBytes + } + + suspend fun restore(assets: List): RestoreReport = withContext(Dispatchers.IO) { + var restored = 0 + var bytes = 0L + var needsConsent = 0 + + for (asset in assets) { + val photoId = asset.serverPhotoId ?: continue + val data = runCatching { fetcher.fetch(photoId) }.getOrNull() ?: continue + if (!canRestore(data.size.toLong(), availableBytes())) { + return@withContext RestoreReport(restored, bytes, needsConsent, outOfSpace = true) + } + try { + writeInPlace(Uri.parse(asset.uri), data) + dao.markState(asset.assetKey, LocalAssetState.LOCAL.name) + restored++ + bytes += data.size + } catch (e: SecurityException) { + needsConsent++ + } + } + RestoreReport(restored, bytes, needsConsent, outOfSpace = false) + } + + private fun writeInPlace(uri: Uri, data: ByteArray) { + val pfd = context.contentResolver.openFileDescriptor(uri, "rwt") + ?: throw IllegalStateException("cannot open $uri") + pfd.use { descriptor -> + FileOutputStream(descriptor.fileDescriptor).use { out -> out.write(data) } + } + } +} diff --git a/android/app/src/main/java/com/galo/android/data/sync/SyncCoordinator.kt b/android/app/src/main/java/com/galo/android/data/sync/SyncCoordinator.kt new file mode 100644 index 0000000..511a276 --- /dev/null +++ b/android/app/src/main/java/com/galo/android/data/sync/SyncCoordinator.kt @@ -0,0 +1,127 @@ +package com.galo.android.data.sync + +import com.galo.android.data.db.dao.SyncDao +import com.galo.android.data.db.entity.DeviceAssetEntity +import com.galo.android.domain.model.LocalAssetState +import com.galo.android.domain.model.SyncMode +import com.galo.android.domain.sync.SyncPolicy + +/** + * Reconciles a device album with its server album. Mode A (DISABLED) is local + * only and performs no server requests; the other modes upload missing content, + * dedup by checksum, propagate renames and update the local index. + */ +class SyncCoordinator( + private val dao: SyncDao, + private val local: LocalAssetProvider, + private val remote: SyncRemote, + private val uploadQueue: SyncUploadQueue, + private val offloader: AssetOffloader, +) { + + /** @return true when the server was contacted, false for a local-only album. */ + suspend fun reconcile(deviceAlbumId: String): Boolean { + val link = dao.getSyncedAlbum(deviceAlbumId) ?: return false + val mode = SyncMode.from(link.mode) + if (!SyncPolicy.contactsServer(mode)) return false + // A mode can be chosen before a server album; nothing to sync until then. + if (link.serverAlbumId.isBlank()) return false + + val serverChecksums = remote.serverChecksums(link.serverAlbumId) + val livePhotoIds = serverChecksums.values.toSet() + val assets = local.assets(deviceAlbumId) + val index = dao.getAssets(deviceAlbumId).map { + IndexedAsset(it.assetKey, it.serverPhotoId, it.contentHash, it.displayName, it.state) + } + val hashesOutside = local.hashesOutside(deviceAlbumId) + + val actions = SyncPlanner.plan(assets, index, serverChecksums, hashesOutside) + for (action in actions) apply(deviceAlbumId, link.serverAlbumId, mode, action, livePhotoIds) + + // Mode C: assets already on the server (e.g. after switching from copies) + // are replaced with their offloaded versions, right away. + if (mode == SyncMode.MOVE) offloadUploaded(deviceAlbumId, assets) + return true + } + + /** + * Offloads assets that are already uploaded but still have their original + * locally. When the OS requires consent, the asset is marked as awaiting + * freeing so the album screen can request it. + */ + private suspend fun offloadUploaded(deviceAlbumId: String, localAssets: List) { + val localKeys = localAssets.mapTo(HashSet()) { it.assetKey } + for (entity in dao.getAssets(deviceAlbumId)) { + if (entity.serverPhotoId == null) continue + if (entity.assetKey !in localKeys) continue + if (entity.state != LocalAssetState.UPLOADED.name) continue + val result = runCatching { offloader.offload(entity) }.getOrNull() ?: continue + when (result) { + is OffloadResult.Done, OffloadResult.Failed -> Unit + is OffloadResult.NeedsWriteConsent, is OffloadResult.NeedsDeleteConsent -> + dao.markState(entity.assetKey, LocalAssetState.AWAITING_FREE.name) + } + } + } + + /** Moves photos that this client uploaded to another server album. */ + suspend fun moveToServerAlbum(photoIds: List, serverAlbumId: String) { + if (photoIds.isEmpty()) return + remote.movePhotos(photoIds, serverAlbumId) + } + + private suspend fun apply( + deviceAlbumId: String, + serverAlbumId: String, + mode: SyncMode, + action: SyncAction, + livePhotoIds: Set, + ) { + when (action) { + is SyncAction.Link -> + record(deviceAlbumId, action.asset, action.photoId, mode) + + is SyncAction.Upload -> + uploadQueue.enqueue(deviceAlbumId, serverAlbumId, action.asset, mode) + + is SyncAction.RenameOnServer -> { + remote.renamePhoto(action.photoId, action.newName) + dao.updateDisplayNameByServerPhoto(action.photoId, action.newName) + } + + is SyncAction.DeleteOnServer -> { + // A photo already gone from the server is only reflected locally: + // the device original is never touched and nothing is re-deleted. + if (action.photoId in livePhotoIds) remote.deletePhoto(action.photoId) + dao.deleteAsset(action.assetKey) + } + } + } + + private suspend fun record( + deviceAlbumId: String, + asset: LocalAsset, + photoId: String, + mode: SyncMode, + ) { + val state = if (mode == SyncMode.MOVE) LocalAssetState.AWAITING_FREE else LocalAssetState.UPLOADED + dao.upsertAssets( + listOf( + DeviceAssetEntity( + assetKey = asset.assetKey, + deviceAlbumId = deviceAlbumId, + mediaStoreId = asset.mediaStoreId, + uri = asset.uri, + displayName = asset.displayName, + mimeType = asset.mimeType, + size = 0L, + dateModified = 0L, + contentHash = asset.contentHash, + serverPhotoId = photoId, + uploadedAt = System.currentTimeMillis(), + state = state.name, + ), + ), + ) + } +} diff --git a/android/app/src/main/java/com/galo/android/data/sync/SyncPlan.kt b/android/app/src/main/java/com/galo/android/data/sync/SyncPlan.kt new file mode 100644 index 0000000..ab31b4a --- /dev/null +++ b/android/app/src/main/java/com/galo/android/data/sync/SyncPlan.kt @@ -0,0 +1,93 @@ +package com.galo.android.data.sync + +import com.galo.android.domain.model.LocalAssetState +import com.galo.android.domain.model.MediaKind + +/** A media item currently present in a device album. */ +data class LocalAsset( + val assetKey: String, + val mediaStoreId: Long, + val displayName: String, + val mimeType: String?, + val kind: MediaKind, + val contentHash: String?, + val uri: String = "", +) + +/** What the local index remembers about a device asset. */ +data class IndexedAsset( + val assetKey: String, + val serverPhotoId: String?, + val contentHash: String?, + val displayName: String, + val state: String = "", +) + +/** A change to apply to the server album. */ +sealed interface SyncAction { + data class Upload(val asset: LocalAsset) : SyncAction + data class Link(val asset: LocalAsset, val photoId: String) : SyncAction + data class DeleteOnServer(val photoId: String, val assetKey: String) : SyncAction + data class RenameOnServer(val photoId: String, val newName: String) : SyncAction +} + +/** + * Computes the changes to apply to a server album from the device's current + * content, the local index (provenance) and the server's photo checksums. + * + * Rules: + * - content already on the server is linked instead of uploaded (dedup); + * - an indexed asset whose content changed is re-uploaded and its old upload is + * removed; + * - a rename is propagated when only the filename changed; + * - a photo is deleted on the server only when it was uploaded by this client and + * its content is gone from the device and from every other linked album. + */ +object SyncPlanner { + + fun plan( + local: List, + index: List, + serverChecksums: Map, + hashesPresentElsewhere: Set, + ): List { + val actions = mutableListOf() + + for (asset in local) { + val indexed = index.firstOrNull { it.assetKey == asset.assetKey } + // An offloaded asset keeps only a preview locally, so its content hash + // no longer matches the original: it must not be re-uploaded. + if (indexed?.state == LocalAssetState.PREVIEW.name) continue + when { + indexed?.serverPhotoId != null -> { + val changed = asset.contentHash != null && + indexed.contentHash != null && + asset.contentHash != indexed.contentHash + if (changed) { + actions += SyncAction.Upload(asset) + actions += SyncAction.DeleteOnServer(indexed.serverPhotoId, asset.assetKey) + } else if (asset.displayName != indexed.displayName) { + actions += SyncAction.RenameOnServer(indexed.serverPhotoId, asset.displayName) + } + } + + asset.contentHash != null && serverChecksums.containsKey(asset.contentHash) -> { + actions += SyncAction.Link(asset, serverChecksums.getValue(asset.contentHash)) + } + + else -> actions += SyncAction.Upload(asset) + } + } + + val localKeys = local.mapTo(HashSet()) { it.assetKey } + for (indexed in index) { + val photoId = indexed.serverPhotoId ?: continue + if (indexed.assetKey in localKeys) continue + val hash = indexed.contentHash + if (hash != null && hash in hashesPresentElsewhere) continue + actions += SyncAction.DeleteOnServer(photoId, indexed.assetKey) + } + + return actions + } +} diff --git a/android/app/src/main/java/com/galo/android/data/sync/SyncRecovery.kt b/android/app/src/main/java/com/galo/android/data/sync/SyncRecovery.kt new file mode 100644 index 0000000..f4de2af --- /dev/null +++ b/android/app/src/main/java/com/galo/android/data/sync/SyncRecovery.kt @@ -0,0 +1,66 @@ +package com.galo.android.data.sync + +import android.content.Context +import android.net.Uri +import com.galo.android.data.db.dao.SyncDao +import com.galo.android.data.db.entity.DeviceAssetEntity +import com.galo.android.data.db.entity.SyncedAlbumEntity +import com.galo.android.data.media.MediaStoreDeviceSource +import com.galo.android.domain.model.LocalAssetState +import com.galo.android.domain.model.SyncMode +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Rebuilds the link and provenance from the provenance markers carried by + * offloaded previews, so a reinstall can recover which server album an album + * points at and which server photos this client uploaded. Unmarked media is + * ignored (never deleted or re-uploaded). + */ +class SyncRecovery( + private val context: Context, + private val dao: SyncDao, + private val deviceSource: MediaStoreDeviceSource, +) { + + /** @return how many assets were recovered from their markers. */ + suspend fun recover(deviceAlbumId: String): Int = withContext(Dispatchers.IO) { + var recovered = 0 + for (media in deviceSource.items(deviceAlbumId)) { + val marker = ProvenanceStamp.read(context, Uri.parse(media.uri)) ?: continue + val photoId = marker.serverPhotoId ?: continue + + dao.upsertAssets( + listOf( + DeviceAssetEntity( + assetKey = DeviceAssetEntity.key(deviceAlbumId, media.id), + deviceAlbumId = deviceAlbumId, + mediaStoreId = media.id, + uri = media.uri, + displayName = media.displayName, + mimeType = media.mimeType, + size = media.size, + dateModified = media.dateModified, + contentHash = marker.originalHash, + serverPhotoId = photoId, + uploadedAt = null, + state = LocalAssetState.PREVIEW.name, + ), + ), + ) + marker.serverAlbumId?.let { serverAlbumId -> + dao.upsertSyncedAlbum( + SyncedAlbumEntity( + deviceAlbumId = deviceAlbumId, + deviceAlbumName = media.albumName, + serverAlbumId = serverAlbumId, + mode = marker.mode ?: SyncMode.MOVE.name, + updatedAt = System.currentTimeMillis(), + ), + ) + } + recovered++ + } + recovered + } +} diff --git a/android/app/src/main/java/com/galo/android/data/sync/SyncRemote.kt b/android/app/src/main/java/com/galo/android/data/sync/SyncRemote.kt new file mode 100644 index 0000000..e010fca --- /dev/null +++ b/android/app/src/main/java/com/galo/android/data/sync/SyncRemote.kt @@ -0,0 +1,13 @@ +package com.galo.android.data.sync + +/** Server-side operations the synchronization engine needs. */ +interface SyncRemote { + /** Control sums of the server photos in an album, keyed by checksum. */ + suspend fun serverChecksums(serverAlbumId: String): Map + + suspend fun renamePhoto(photoId: String, newName: String) + + suspend fun deletePhoto(photoId: String) + + suspend fun movePhotos(photoIds: List, serverAlbumId: String) +} diff --git a/android/app/src/main/java/com/galo/android/data/sync/SyncUploadQueue.kt b/android/app/src/main/java/com/galo/android/data/sync/SyncUploadQueue.kt new file mode 100644 index 0000000..1f48cbf --- /dev/null +++ b/android/app/src/main/java/com/galo/android/data/sync/SyncUploadQueue.kt @@ -0,0 +1,40 @@ +package com.galo.android.data.sync + +import com.galo.android.data.upload.UploadRepository +import com.galo.android.domain.model.SyncMode + +/** Hands uploads to the persistent queue instead of uploading inline. */ +interface SyncUploadQueue { + suspend fun enqueue( + deviceAlbumId: String, + serverAlbumId: String, + asset: LocalAsset, + mode: SyncMode, + ) +} + +/** [SyncUploadQueue] backed by the existing [UploadRepository]/WorkManager queue. */ +class RepositorySyncUploadQueue( + private val uploads: UploadRepository, + private val wifiOnly: () -> Boolean, +) : SyncUploadQueue { + + override suspend fun enqueue( + deviceAlbumId: String, + serverAlbumId: String, + asset: LocalAsset, + mode: SyncMode, + ) { + uploads.enqueueSync( + deviceAlbumId = deviceAlbumId, + serverAlbumId = serverAlbumId, + assetKey = asset.assetKey, + uri = asset.uri, + filename = asset.displayName, + mimeType = asset.mimeType, + contentHash = asset.contentHash, + mode = mode.name, + wifiOnly = wifiOnly(), + ) + } +} diff --git a/android/app/src/main/java/com/galo/android/data/sync/SyncWorker.kt b/android/app/src/main/java/com/galo/android/data/sync/SyncWorker.kt new file mode 100644 index 0000000..8113eeb --- /dev/null +++ b/android/app/src/main/java/com/galo/android/data/sync/SyncWorker.kt @@ -0,0 +1,78 @@ +package com.galo.android.data.sync + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.Constraints +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import com.galo.android.GaloApplication +import kotlinx.coroutines.flow.first +import java.util.concurrent.TimeUnit + +/** + * Reconciles every linked device album. Run periodically in the background and + * on demand; mode A albums are skipped by the coordinator. + */ +class SyncWorker( + appContext: Context, + params: WorkerParameters, +) : CoroutineWorker(appContext, params) { + + override suspend fun doWork(): Result { + val app = applicationContext as? GaloApplication ?: return Result.failure() + val container = app.container + val links = runCatching { container.syncRepository.observeLinks().first() } + .getOrElse { return Result.retry() } + + var failed = false + for (link in links) { + runCatching { container.syncCoordinator.reconcile(link.deviceAlbumId) } + .onFailure { failed = true } + } + return if (failed) Result.retry() else Result.success() + } +} + +object SyncScheduler { + + const val UNIQUE_PERIODIC = "galo-device-sync" + const val UNIQUE_NOW = "galo-device-sync-now" + const val TAG_SYNC = "galo-device-sync" + + fun buildPeriodicRequest(wifiOnly: Boolean) = + PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES) + .setConstraints(constraints(wifiOnly)) + .addTag(TAG_SYNC) + .build() + + fun constraints(wifiOnly: Boolean): Constraints = + Constraints.Builder() + .setRequiredNetworkType(if (wifiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED) + .build() + + /** Registers the recurring background synchronization with the given limit. */ + fun ensurePeriodic(context: Context, wifiOnly: Boolean) { + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + UNIQUE_PERIODIC, + ExistingPeriodicWorkPolicy.UPDATE, + buildPeriodicRequest(wifiOnly), + ) + } + + /** Requests a one-off pass (used on app start). */ + fun runNow(context: Context) { + val request = OneTimeWorkRequestBuilder() + .addTag(TAG_SYNC) + .build() + WorkManager.getInstance(context).enqueueUniqueWork( + UNIQUE_NOW, + ExistingWorkPolicy.KEEP, + request, + ) + } +} diff --git a/android/app/src/main/java/com/galo/android/data/upload/HeicConverter.kt b/android/app/src/main/java/com/galo/android/data/upload/HeicConverter.kt new file mode 100644 index 0000000..caa7c78 --- /dev/null +++ b/android/app/src/main/java/com/galo/android/data/upload/HeicConverter.kt @@ -0,0 +1,90 @@ +package com.galo.android.data.upload + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.ImageDecoder +import android.net.Uri +import androidx.exifinterface.media.ExifInterface +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.io.IOException +import java.util.UUID + +/** + * Converts HEIC/HEIF originals to JPEG on-device (the server decodes only + * JPEG/PNG/GIF/BMP/TIFF). Capture date and geolocation are carried over through + * EXIF, and the base filename is preserved with a `.jpg` extension. + */ +object HeicConverter { + + data class Converted(val file: File, val filename: String, val mimeType: String) + + private val HEIC_MIME = setOf( + "image/heic", + "image/heif", + "image/heic-sequence", + "image/heif-sequence", + ) + + fun needsConversion(filename: String?, mimeType: String?): Boolean { + if (mimeType?.lowercase() in HEIC_MIME) return true + val ext = filename?.substringAfterLast('.', "")?.lowercase() + return ext == "heic" || ext == "heif" + } + + fun jpegName(filename: String): String { + val base = filename.substringBeforeLast('.', filename).ifBlank { "photo" } + return "$base.jpg" + } + + suspend fun convert(context: Context, source: Uri, filename: String): Converted = + withContext(Dispatchers.IO) { + val bitmap = decode(context, source) + val output = File(context.cacheDir, "heic_${UUID.randomUUID()}.jpg") + try { + java.io.FileOutputStream(output).use { out -> + if (!bitmap.compress(Bitmap.CompressFormat.JPEG, 90, out)) { + throw IOException("JPEG encode failed") + } + } + copyExif(context, source, output) + } finally { + bitmap.recycle() + } + Converted(file = output, filename = jpegName(filename), mimeType = "image/jpeg") + } + + @Suppress("DEPRECATION") + private fun decode(context: Context, source: Uri): Bitmap { + val imageSource = ImageDecoder.createSource(context.contentResolver, source) + return ImageDecoder.decodeBitmap(imageSource) { decoder, _, _ -> + decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE + } + } + + private fun copyExif(context: Context, source: Uri, target: File) { + val input = context.contentResolver.openInputStream(source)?.use { ExifInterface(it) } + ?: return + val output = ExifInterface(target.absolutePath) + COPY_TAGS.forEach { tag -> + input.getAttribute(tag)?.let { output.setAttribute(tag, it) } + } + input.latLong?.let { coordinates -> output.setLatLong(coordinates[0], coordinates[1]) } + output.saveAttributes() + } + + private val COPY_TAGS = listOf( + ExifInterface.TAG_DATETIME_ORIGINAL, + ExifInterface.TAG_OFFSET_TIME_ORIGINAL, + ExifInterface.TAG_DATETIME_DIGITIZED, + ExifInterface.TAG_OFFSET_TIME_DIGITIZED, + ExifInterface.TAG_SUBSEC_TIME, + ExifInterface.TAG_MAKE, + ExifInterface.TAG_MODEL, + ExifInterface.TAG_F_NUMBER, + ExifInterface.TAG_EXPOSURE_TIME, + ExifInterface.TAG_FOCAL_LENGTH, + ExifInterface.TAG_FLASH, + ) +} diff --git a/android/app/src/main/java/com/galo/android/data/upload/UploadRepository.kt b/android/app/src/main/java/com/galo/android/data/upload/UploadRepository.kt new file mode 100644 index 0000000..a2860a5 --- /dev/null +++ b/android/app/src/main/java/com/galo/android/data/upload/UploadRepository.kt @@ -0,0 +1,174 @@ +package com.galo.android.data.upload + +import android.content.Context +import android.net.Uri +import android.provider.OpenableColumns +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.workDataOf +import com.galo.android.data.db.dao.UploadDao +import com.galo.android.data.db.entity.UploadJobEntity +import com.galo.android.domain.model.UploadJob +import com.galo.android.domain.model.UploadStatus +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import java.util.UUID +import java.util.concurrent.TimeUnit + +/** + * Persistent upload queue. Jobs live in Room (surviving process death) and each + * is scheduled as its own WorkManager request — one file per HTTP request, per + * the server's fail-fast, size-capped batch upload. + */ +class UploadRepository( + private val context: Context, + private val dao: UploadDao, +) { + + fun observeJobs(): Flow> = + dao.observeAll().map { jobs -> jobs.map { it.toDomain() } } + + suspend fun enqueue(albumId: String, uris: List, wifiOnly: Boolean): Int { + var queued = 0 + for (uri in uris) { + val meta = queryMeta(uri) ?: continue + val job = UploadJobEntity( + id = UUID.randomUUID().toString(), + albumId = albumId, + uri = uri.toString(), + filename = meta.name, + mimeType = meta.mimeType, + size = meta.size, + status = UploadStatus.QUEUED.name, + progress = 0, + error = null, + attempts = 0, + wifiOnly = wifiOnly, + createdAt = System.currentTimeMillis(), + convertedPath = null, + ) + dao.upsert(job) + UploadScheduler.enqueue(context, job.id, wifiOnly) + queued++ + } + return queued + } + + /** + * Enqueues one device asset from a synced album. On success the worker records + * the created photo as provenance for the asset. + */ + suspend fun enqueueSync( + deviceAlbumId: String, + serverAlbumId: String, + assetKey: String, + uri: String, + filename: String, + mimeType: String?, + contentHash: String?, + mode: String, + wifiOnly: Boolean, + ) { + val job = UploadJobEntity( + id = UUID.randomUUID().toString(), + albumId = serverAlbumId, + uri = uri, + filename = filename, + mimeType = mimeType, + size = 0L, + status = UploadStatus.QUEUED.name, + progress = 0, + error = null, + attempts = 0, + wifiOnly = wifiOnly, + createdAt = System.currentTimeMillis(), + convertedPath = null, + deviceAlbumId = deviceAlbumId, + assetKey = assetKey, + contentHash = contentHash, + syncMode = mode, + ) + dao.upsert(job) + UploadScheduler.enqueue(context, job.id, wifiOnly) + } + + /** Re-schedules unfinished work after a restart and clears stale RUNNING status. */ + suspend fun requeuePending() { + dao.resetRunning() + dao.getPending().forEach { job -> + UploadScheduler.enqueue(context, job.id, job.wifiOnly) + } + } + + suspend fun getById(id: String): UploadJobEntity? = dao.getById(id) + + suspend fun updateState( + id: String, + status: UploadStatus, + progress: Int, + error: String?, + attempts: Int, + convertedPath: String?, + ) = dao.updateState(id, status.name, progress, error, attempts, convertedPath) + + suspend fun clearCompleted() = dao.clearCompleted() + + private data class UriMeta(val name: String, val mimeType: String?, val size: Long) + + private fun queryMeta(uri: Uri): UriMeta? { + val resolver = context.contentResolver + val mimeType = runCatching { resolver.getType(uri) }.getOrNull() + val projection = arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE) + runCatching { resolver.query(uri, projection, null, null, null) }.getOrNull()?.use { cursor -> + if (cursor.moveToFirst()) { + val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE) + val name = if (nameIndex >= 0) cursor.getString(nameIndex) else null + val size = if (sizeIndex >= 0 && !cursor.isNull(sizeIndex)) cursor.getLong(sizeIndex) else 0L + return UriMeta(name ?: "file", mimeType, size) + } + } + return UriMeta(uri.lastPathSegment ?: "file", mimeType, 0L) + } +} + +object UploadScheduler { + + const val KEY_JOB_ID = "job_id" + + fun enqueue(context: Context, jobId: String, wifiOnly: Boolean) { + val constraints = Constraints.Builder() + .setRequiredNetworkType( + if (wifiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED, + ) + .build() + val request = OneTimeWorkRequestBuilder() + .setInputData(workDataOf(KEY_JOB_ID to jobId)) + .setConstraints(constraints) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 15, TimeUnit.SECONDS) + .addTag(TAG_UPLOAD) + .build() + WorkManager.getInstance(context) + .enqueueUniqueWork(jobId, ExistingWorkPolicy.KEEP, request) + } + + const val TAG_UPLOAD = "galo-upload" +} + +private fun UploadJobEntity.toDomain(): UploadJob = UploadJob( + id = id, + albumId = albumId, + filename = filename, + mimeType = mimeType, + size = size, + status = UploadStatus.from(status), + progress = progress, + error = error, + attempts = attempts, + wifiOnly = wifiOnly, + createdAt = createdAt, +) diff --git a/android/app/src/main/java/com/galo/android/data/upload/UploadWorker.kt b/android/app/src/main/java/com/galo/android/data/upload/UploadWorker.kt new file mode 100644 index 0000000..32ba887 --- /dev/null +++ b/android/app/src/main/java/com/galo/android/data/upload/UploadWorker.kt @@ -0,0 +1,210 @@ +package com.galo.android.data.upload + +import android.content.Context +import android.content.pm.ServiceInfo +import android.net.Uri +import androidx.core.app.NotificationCompat +import androidx.work.CoroutineWorker +import androidx.work.ForegroundInfo +import androidx.work.WorkerParameters +import com.galo.android.GaloApplication +import com.galo.android.data.db.entity.UploadJobEntity +import com.galo.android.data.network.ErrorMapper +import com.galo.android.data.network.StreamingRequestBody +import com.galo.android.di.AppContainer +import com.galo.android.domain.model.ApiException +import com.galo.android.domain.model.LocalAssetState +import com.galo.android.domain.model.SyncMode +import com.galo.android.domain.model.UploadStatus +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import java.io.File +import java.io.IOException + +/** + * Uploads a single queue entry as one multipart POST. Using a foreground + * notification while running lets large/video uploads continue when the app is + * backgrounded; transient failures retry with WorkManager's exponential backoff + * up to [MAX_ATTEMPTS]. + */ +class UploadWorker( + appContext: Context, + params: WorkerParameters, +) : CoroutineWorker(appContext, params) { + + override suspend fun doWork(): Result { + val app = applicationContext as? GaloApplication ?: return Result.failure() + val container = app.container + val repository = container.uploadRepository + + val jobId = inputData.getString(UploadScheduler.KEY_JOB_ID) ?: return Result.failure() + val job = repository.getById(jobId) ?: return Result.success() + if (job.status == UploadStatus.SUCCESS.name) return Result.success() + + val attempt = runAttemptCount + repository.updateState(jobId, UploadStatus.RUNNING, job.progress, null, attempt, job.convertedPath) + + var tempPath = job.convertedPath + return try { + setForeground(foregroundInfo(jobId, job.filename, 0)) + val uri = Uri.parse(job.uri) + + val (filename, body) = prepareBody(uri, job.filename, job.mimeType, job.size) { percent -> + onProgress(repository, jobId, job.filename, percent, attempt, tempPath) + }.also { if (it.tempPath != null) tempPath = it.tempPath } + + val photo = container.api.upload(job.albumId, filename, body) + container.photoRepository.upsert(photo) + recordProvenance(container, job, photo.id) + repository.updateState(jobId, UploadStatus.SUCCESS, 100, null, attempt, tempPath) + tempPath?.let { runCatching { File(it).delete() } } + Result.success() + } catch (e: Exception) { + val message = describe(e) + if (attempt + 1 >= MAX_ATTEMPTS) { + repository.updateState(jobId, UploadStatus.ERROR, job.progress, message, attempt, tempPath) + Result.failure() + } else { + repository.updateState(jobId, UploadStatus.QUEUED, job.progress, message, attempt, tempPath) + Result.retry() + } + } + } + + /** + * For a job created by the device-album sync, records the created server photo + * as the provenance of the source asset so later passes can dedup and delete. + */ + private suspend fun recordProvenance(container: AppContainer, job: UploadJobEntity, photoId: String) { + val deviceAlbumId = job.deviceAlbumId ?: return + val assetKey = job.assetKey ?: return + val mode = SyncMode.from(job.syncMode) + val state = if (mode == SyncMode.MOVE) LocalAssetState.AWAITING_FREE else LocalAssetState.UPLOADED + container.syncRepository.recordUploaded( + deviceAlbumId = deviceAlbumId, + assetKey = assetKey, + mediaStoreId = assetKey.substringAfterLast('/').toLongOrNull() ?: 0L, + uri = job.uri, + displayName = job.filename, + mimeType = job.mimeType, + contentHash = job.contentHash, + serverPhotoId = photoId, + state = state, + ) + // Mode C: as soon as the object is uploaded, replace the local original + // with the offloaded (preview) version. Media the app does not own needs + // the OS write consent, which is requested from the album screen. + if (mode == SyncMode.MOVE) { + container.syncRepository.getAsset(assetKey)?.let { asset -> + runCatching { container.offloadManager.offload(asset) } + } + } + } + + private data class PreparedBody( + val filename: String, + val body: StreamingRequestBody, + val tempPath: String? = null, + ) + + private suspend fun prepareBody( + uri: Uri, + originalName: String, + mimeType: String?, + size: Long, + onProgress: (Int) -> Unit, + ): PreparedBody { + if (HeicConverter.needsConversion(originalName, mimeType)) { + val converted = HeicConverter.convert(applicationContext, uri, originalName) + val file = converted.file + return PreparedBody( + filename = converted.filename, + body = StreamingRequestBody( + length = file.length(), + contentType = converted.mimeType.toMediaTypeOrNull(), + openStream = { file.inputStream() }, + onProgress = { sent, total -> onProgress(percent(sent, total)) }, + ), + tempPath = file.absolutePath, + ) + } + val type = mimeType?.toMediaTypeOrNull() + val length = resolveLength(uri, size) + return PreparedBody( + filename = originalName, + body = StreamingRequestBody( + length = length, + contentType = type, + openStream = { + applicationContext.contentResolver.openInputStream(uri) + ?: throw IOException("cannot read selected file") + }, + onProgress = { sent, total -> onProgress(percent(sent, total)) }, + ), + ) + } + + /** + * Content providers do not always report a size via the projection; fall back + * to the asset file descriptor, and finally to -1 (chunked) so the declared + * length always matches the bytes actually written. + */ + private fun resolveLength(uri: Uri, declared: Long): Long { + if (declared > 0) return declared + val afd = runCatching { + applicationContext.contentResolver.openAssetFileDescriptor(uri, "r") + }.getOrNull() + afd?.use { descriptor -> + if (descriptor.length >= 0) return descriptor.length + } + return -1L + } + + private fun onProgress( + repository: UploadRepository, + jobId: String, + filename: String, + percent: Int, + attempt: Int, + tempPath: String?, + ) { + if (percent == lastPercent) return + lastPercent = percent + runBlocking(Dispatchers.IO) { + repository.updateState(jobId, UploadStatus.RUNNING, percent, null, attempt, tempPath) + setForeground(foregroundInfo(jobId, filename, percent)) + } + } + + private fun foregroundInfo(jobId: String, filename: String, percent: Int): ForegroundInfo { + val notification = NotificationCompat.Builder(applicationContext, GaloApplication.UPLOAD_CHANNEL_ID) + .setSmallIcon(android.R.drawable.stat_sys_upload) + .setContentTitle("Загрузка в galo") + .setContentText(filename) + .setOnlyAlertOnce(true) + .setOngoing(true) + .setProgress(100, percent, percent == 0) + .build() + return ForegroundInfo( + jobId.hashCode(), + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC, + ) + } + + private fun describe(e: Exception): String = when (e) { + is ApiException -> ErrorMapper.userMessage(e.error) + else -> e.message ?: "Ошибка загрузки" + } + + private fun percent(sent: Long, total: Long): Int = + if (total > 0) ((sent * 100) / total).toInt().coerceIn(0, 100) else 0 + + @Volatile + private var lastPercent = -1 + + private companion object { + const val MAX_ATTEMPTS = 3 + } +} diff --git a/android/app/src/main/java/com/galo/android/di/AppContainer.kt b/android/app/src/main/java/com/galo/android/di/AppContainer.kt index b4f1710..ac1f61e 100644 --- a/android/app/src/main/java/com/galo/android/di/AppContainer.kt +++ b/android/app/src/main/java/com/galo/android/di/AppContainer.kt @@ -4,6 +4,9 @@ import android.content.Context import com.galo.android.data.auth.SecureTokenStore import com.galo.android.data.auth.TokenStore import com.galo.android.data.db.GaloDatabase +import com.galo.android.data.media.MediaAccess +import com.galo.android.data.media.MediaAccessChecker +import com.galo.android.data.media.MediaStoreDeviceSource import com.galo.android.data.network.AlbumSseClient import com.galo.android.data.network.ApiClient import com.galo.android.data.network.GaloApi @@ -16,6 +19,18 @@ import com.galo.android.data.repo.ApiPhotoRemoteSource import com.galo.android.data.repo.PhotoRepository import com.galo.android.data.repo.RoomAlbumLocalSource import com.galo.android.data.repo.RoomPhotoLocalSource +import com.galo.android.data.repo.SyncRepository +import com.galo.android.data.sync.ApiOriginalFetcher +import com.galo.android.data.sync.ApiSyncRemote +import com.galo.android.data.sync.DeviceLocalAssetProvider +import com.galo.android.data.sync.DeviceMediaObserver +import com.galo.android.data.sync.OffloadManager +import com.galo.android.data.sync.RepositorySyncUploadQueue +import com.galo.android.data.sync.RestoreManager +import com.galo.android.data.sync.SyncCoordinator +import com.galo.android.data.sync.SyncRecovery +import com.galo.android.data.sync.SyncRemote +import com.galo.android.data.sync.SyncScheduler import com.galo.android.data.upload.UploadRepository import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -63,13 +78,49 @@ class AppContainer(context: Context) { val uploadRepository = UploadRepository(appContext, database.uploadDao()) + val mediaAccess: MediaAccess = MediaAccessChecker(appContext) + + val deviceSource = MediaStoreDeviceSource(appContext) + + val syncRepository = SyncRepository(database.syncDao(), deviceSource) + + val syncRemote: SyncRemote = ApiSyncRemote(api) + + val offloadManager = OffloadManager(appContext, database.syncDao()) + + val syncCoordinator = SyncCoordinator( + dao = database.syncDao(), + local = DeviceLocalAssetProvider(appContext, database.syncDao(), deviceSource), + remote = syncRemote, + uploadQueue = RepositorySyncUploadQueue( + uploads = uploadRepository, + wifiOnly = { settingsState?.wifiOnlyUploads == true }, + ), + offloader = offloadManager, + ) + + val restoreManager = RestoreManager( + context = appContext, + dao = database.syncDao(), + fetcher = ApiOriginalFetcher(api, apiClient), + ) + + val syncRecovery = SyncRecovery(appContext, database.syncDao(), deviceSource) + fun start() { scope.launch { - settingsRepository.flow.collect { settingsState = it } + settingsRepository.flow.collect { settings -> + settingsState = settings + runCatching { SyncScheduler.ensurePeriodic(appContext, settings.wifiOnlyUploads) } + } } scope.launch { runCatching { uploadRepository.requeuePending() } } + runCatching { SyncScheduler.runNow(appContext) } + runCatching { + DeviceMediaObserver(appContext) { runCatching { SyncScheduler.runNow(appContext) } }.start() + } } fun currentSettings(): SettingsState? = settingsState diff --git a/android/app/src/main/java/com/galo/android/domain/media/AllFilesAccess.kt b/android/app/src/main/java/com/galo/android/domain/media/AllFilesAccess.kt new file mode 100644 index 0000000..e9180d8 --- /dev/null +++ b/android/app/src/main/java/com/galo/android/domain/media/AllFilesAccess.kt @@ -0,0 +1,32 @@ +package com.galo.android.domain.media + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.Settings + +/** + * The special "all files access" permission. When granted, mode C can replace + * gallery originals without the per-batch OS write-consent dialog. + */ +object AllFilesAccess { + + fun isGranted(): Boolean = + Build.VERSION.SDK_INT < Build.VERSION_CODES.R || Environment.isExternalStorageManager() + + /** System settings screen where the user grants all files access. */ + fun settingsIntent(context: Context): Intent = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + Intent( + Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, + Uri.parse("package:${context.packageName}"), + ) + } else { + Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.parse("package:${context.packageName}"), + ) + } +} diff --git a/android/app/src/main/java/com/galo/android/domain/media/MediaPermissions.kt b/android/app/src/main/java/com/galo/android/domain/media/MediaPermissions.kt new file mode 100644 index 0000000..420ff0c --- /dev/null +++ b/android/app/src/main/java/com/galo/android/domain/media/MediaPermissions.kt @@ -0,0 +1,23 @@ +package com.galo.android.domain.media + +import android.os.Build + +/** + * Runtime permissions needed to enumerate and read device media through + * MediaStore. Android 13 (TIRAMISU) split read access into per-type + * permissions; older releases use the single external-storage read. + */ +object MediaPermissions { + + const val READ_MEDIA_IMAGES = "android.permission.READ_MEDIA_IMAGES" + const val READ_MEDIA_VIDEO = "android.permission.READ_MEDIA_VIDEO" + const val READ_EXTERNAL_STORAGE = "android.permission.READ_EXTERNAL_STORAGE" + + /** Permissions that must all be granted before device albums are shown. */ + fun required(sdkInt: Int): List = + if (sdkInt >= Build.VERSION_CODES.TIRAMISU) { + listOf(READ_MEDIA_IMAGES, READ_MEDIA_VIDEO) + } else { + listOf(READ_EXTERNAL_STORAGE) + } +} diff --git a/android/app/src/main/java/com/galo/android/domain/model/DeviceAlbums.kt b/android/app/src/main/java/com/galo/android/domain/model/DeviceAlbums.kt new file mode 100644 index 0000000..592aca4 --- /dev/null +++ b/android/app/src/main/java/com/galo/android/domain/model/DeviceAlbums.kt @@ -0,0 +1,27 @@ +package com.galo.android.domain.model + +/** An album of the device (a MediaStore bucket). */ +data class DeviceAlbum( + val id: String, + val name: String, + val itemCount: Int, + val coverUri: String?, + val relativePath: String, +) + +/** A single media item of the device found in a [DeviceAlbum]. */ +data class DeviceMedia( + val id: Long, + val uri: String, + val albumId: String, + val albumName: String, + val displayName: String, + val mimeType: String?, + val size: Long, + val width: Int, + val height: Int, + val dateModified: Long, + val dateTaken: Long?, + val relativePath: String, + val kind: MediaKind, +) diff --git a/android/app/src/main/java/com/galo/android/domain/model/Sync.kt b/android/app/src/main/java/com/galo/android/domain/model/Sync.kt new file mode 100644 index 0000000..61dadf2 --- /dev/null +++ b/android/app/src/main/java/com/galo/android/domain/model/Sync.kt @@ -0,0 +1,67 @@ +package com.galo.android.domain.model + +/** Per-album synchronization mode. */ +enum class SyncMode { + /** A — local only; previews are generated and kept on the device. */ + DISABLED, + + /** B — originals stay on the device, copies are uploaded. */ + COPY, + + /** C — originals move to the server, previews stay on the device. */ + MOVE, + ; + + companion object { + fun from(raw: String?): SyncMode = + entries.firstOrNull { it.name == raw } ?: DISABLED + } +} + +/** Where a device asset currently lives relative to the server. */ +enum class LocalAssetState { + /** Original present locally, nothing uploaded yet. */ + LOCAL, + + /** Uploaded; original still present locally (final state of mode B). */ + UPLOADED, + + /** Only a preview is kept locally; the original lives on the server (mode C). */ + PREVIEW, + + /** Known on the server, but the original never came to this device. */ + SERVER_ONLY, + + /** Uploaded, waiting for the user to confirm freeing space (mode C). */ + AWAITING_FREE, + ; + + companion object { + fun from(raw: String?): LocalAssetState = + entries.firstOrNull { it.name == raw } ?: LOCAL + } +} + +/** A device album linked to a server album. */ +data class SyncedAlbum( + val deviceAlbumId: String, + val deviceAlbumName: String, + val serverAlbumId: String, + val mode: SyncMode, +) + +/** Provenance of one device asset: which server photo it became. */ +data class DeviceAsset( + val assetKey: String, + val deviceAlbumId: String, + val mediaStoreId: Long, + val uri: String, + val displayName: String, + val mimeType: String?, + val size: Long, + val dateModified: Long, + val contentHash: String?, + val serverPhotoId: String?, + val uploadedAt: Long?, + val state: LocalAssetState, +) diff --git a/android/app/src/main/java/com/galo/android/domain/sync/SyncPolicy.kt b/android/app/src/main/java/com/galo/android/domain/sync/SyncPolicy.kt new file mode 100644 index 0000000..71865d5 --- /dev/null +++ b/android/app/src/main/java/com/galo/android/domain/sync/SyncPolicy.kt @@ -0,0 +1,10 @@ +package com.galo.android.domain.sync + +import com.galo.android.domain.model.SyncMode + +/** Decides whether a synchronization mode talks to the server at all. */ +object SyncPolicy { + + /** Mode A (DISABLED) is local-only: it never contacts the server. */ + fun contactsServer(mode: SyncMode): Boolean = mode != SyncMode.DISABLED +} diff --git a/android/app/src/main/java/com/galo/android/ui/GaloNavHost.kt b/android/app/src/main/java/com/galo/android/ui/GaloNavHost.kt index 94bb537..16a8aba 100644 --- a/android/app/src/main/java/com/galo/android/ui/GaloNavHost.kt +++ b/android/app/src/main/java/com/galo/android/ui/GaloNavHost.kt @@ -18,6 +18,7 @@ import com.galo.android.data.prefs.SettingsState import com.galo.android.ui.album.AlbumScreen import com.galo.android.ui.albums.AlbumsScreen import com.galo.android.ui.auth.AuthScreen +import com.galo.android.ui.devicealbum.DeviceAlbumScreen import com.galo.android.ui.profile.ProfileScreen import com.galo.android.ui.setup.SetupScreen import com.galo.android.ui.upload.UploadScreen @@ -30,10 +31,13 @@ object Routes { const val UPLOADS = "uploads" const val PROFILE = "profile" const val ALBUM = "album/{albumId}" + const val DEVICE_ALBUM = "deviceAlbum/{deviceAlbumId}" const val VIEWER = "album/{albumId}/viewer?index={index}" fun album(albumId: String) = "album/$albumId" + fun deviceAlbum(deviceAlbumId: String) = "deviceAlbum/$deviceAlbumId" + fun viewer(albumId: String, index: Int) = "album/$albumId/viewer?index=$index" } @@ -83,6 +87,7 @@ fun GaloNavHost(app: GaloApplication) { AlbumsScreen( factory = factory, onOpenAlbum = { id -> navController.navigate(Routes.album(id)) }, + onOpenDeviceAlbum = { id -> navController.navigate(Routes.deviceAlbum(id)) }, onOpenUploads = { navController.navigate(Routes.UPLOADS) }, onOpenProfile = { navController.navigate(Routes.PROFILE) }, onLoggedOut = { @@ -123,6 +128,17 @@ fun GaloNavHost(app: GaloApplication) { onBack = { navController.popBackStack() }, ) } + composable( + route = Routes.DEVICE_ALBUM, + arguments = listOf(navArgument("deviceAlbumId") { type = NavType.StringType }), + ) { entry -> + val deviceAlbumId = entry.arguments?.getString("deviceAlbumId").orEmpty() + DeviceAlbumScreen( + app = app, + deviceAlbumId = deviceAlbumId, + onBack = { navController.popBackStack() }, + ) + } composable( route = Routes.VIEWER, arguments = listOf( diff --git a/android/app/src/main/java/com/galo/android/ui/albums/AlbumListItem.kt b/android/app/src/main/java/com/galo/android/ui/albums/AlbumListItem.kt new file mode 100644 index 0000000..197b973 --- /dev/null +++ b/android/app/src/main/java/com/galo/android/ui/albums/AlbumListItem.kt @@ -0,0 +1,70 @@ +package com.galo.android.ui.albums + +import com.galo.android.domain.model.Album +import com.galo.android.domain.model.DeviceAlbum +import com.galo.android.domain.model.SyncMode + +/** One row of the mixed album list: a server album or an album of the device. */ +sealed interface AlbumListItem { + val key: String + + data class ServerAlbum( + val album: Album, + val linkedDeviceNames: List, + ) : AlbumListItem { + override val key: String get() = "server:${album.id}" + } + + data class DeviceAlbumItem( + val album: DeviceAlbum, + val linkedServerTitle: String?, + val mode: SyncMode, + ) : AlbumListItem { + override val key: String get() = "device:${album.id}" + } +} + +/** A device album linked to a server album (for building the list). */ +data class AlbumLink( + val deviceAlbumId: String, + val deviceAlbumName: String, + val serverAlbumId: String, + val mode: SyncMode, +) + +/** + * Builds the mixed list. A linked device album is shown as a single card carrying + * the connection to its server album; that server album is hidden from the list, + * so a device album and its copy do not appear twice. + */ +object AlbumListBuilder { + + fun build( + albums: List, + links: List, + devices: List, + ): List { + val linksByDevice = links.associateBy { it.deviceAlbumId } + val linkedServerIds = links + .filter { it.serverAlbumId.isNotBlank() } + .mapTo(HashSet()) { it.serverAlbumId } + val serverTitles = albums.associate { it.id to it.title } + + val deviceItems = devices.map { device -> + val link = linksByDevice[device.id] + AlbumListItem.DeviceAlbumItem( + album = device, + linkedServerTitle = link?.serverAlbumId?.takeIf { it.isNotBlank() }?.let { serverId -> + serverTitles[serverId] ?: serverId + }, + mode = link?.mode ?: SyncMode.DISABLED, + ) + } + + val serverItems = albums + .filter { it.id !in linkedServerIds } + .map { AlbumListItem.ServerAlbum(it, linkedDeviceNames = emptyList()) } + + return deviceItems + serverItems + } +} diff --git a/android/app/src/main/java/com/galo/android/ui/albums/AlbumsScreen.kt b/android/app/src/main/java/com/galo/android/ui/albums/AlbumsScreen.kt index 4abe102..6c65e65 100644 --- a/android/app/src/main/java/com/galo/android/ui/albums/AlbumsScreen.kt +++ b/android/app/src/main/java/com/galo/android/ui/albums/AlbumsScreen.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -37,29 +38,40 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.galo.android.domain.model.Album +import com.galo.android.domain.model.DeviceAlbum +import com.galo.android.domain.model.SyncMode import com.galo.android.ui.GaloViewModelFactory import com.galo.android.ui.common.GaloAsyncImage +import com.galo.android.ui.common.RequestMediaAccess import com.galo.android.ui.common.UserAvatar const val TAG_ALBUMS_GRID = "albums_grid" const val TAG_ALBUMS_OFFLINE = "albums_offline" const val TAG_ALBUMS_AVATAR = "albums_avatar" const val TAG_ALBUM_CARD_PREFIX = "album_card_" +const val TAG_DEVICE_ALBUM_CARD_PREFIX = "device_album_card_" +const val TAG_ALBUM_SOURCE_PREFIX = "album_source_" +const val TAG_ALBUM_LINK_PREFIX = "album_link_" +const val TAG_ALBUM_MODE_PREFIX = "album_mode_" @OptIn(ExperimentalMaterial3Api::class) @Composable fun AlbumsScreen( factory: GaloViewModelFactory, onOpenAlbum: (String) -> Unit, + onOpenDeviceAlbum: (String) -> Unit, onOpenUploads: () -> Unit, onOpenProfile: () -> Unit, onLoggedOut: () -> Unit, ) { val viewModel: AlbumsViewModel = viewModel(factory = factory) val state by viewModel.state.collectAsStateWithLifecycle() + // Ask for media access so device albums can be listed; reload once answered. + RequestMediaAccess { viewModel.refresh() } AlbumsContent( state = state, onOpenAlbum = onOpenAlbum, + onOpenDeviceAlbum = onOpenDeviceAlbum, onOpenUploads = onOpenUploads, onOpenProfile = onOpenProfile, onRefresh = viewModel::refresh, @@ -72,6 +84,7 @@ fun AlbumsScreen( fun AlbumsContent( state: AlbumsViewModel.UiState, onOpenAlbum: (String) -> Unit, + onOpenDeviceAlbum: (String) -> Unit, onOpenUploads: () -> Unit, onOpenProfile: () -> Unit, onRefresh: () -> Unit, @@ -113,13 +126,13 @@ fun AlbumsContent( } } when { - state.loading && state.albums.isEmpty() -> { + state.loading && state.items.isEmpty() -> { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() } } - state.albums.isEmpty() -> { + state.items.isEmpty() -> { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text("Пока нет альбомов") } @@ -133,8 +146,18 @@ fun AlbumsContent( verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxSize().testTag(TAG_ALBUMS_GRID), ) { - items(state.albums, key = { it.id }) { album -> - AlbumCard(album = album, onClick = { onOpenAlbum(album.id) }) + items(state.items, key = { it.key }) { item -> + when (item) { + is AlbumListItem.ServerAlbum -> ServerAlbumCard( + item = item, + onClick = { onOpenAlbum(item.album.id) }, + ) + + is AlbumListItem.DeviceAlbumItem -> DeviceAlbumCard( + item = item, + onClick = { onOpenDeviceAlbum(item.album.id) }, + ) + } } } } @@ -144,33 +167,128 @@ fun AlbumsContent( } @Composable -private fun AlbumCard(album: Album, onClick: () -> Unit) { - Card( - modifier = Modifier - .fillMaxWidth() - .testTag(TAG_ALBUM_CARD_PREFIX + album.id) - .clickable(onClick = onClick), - ) { - GaloAsyncImage( - url = album.coverThumbUrl ?: album.coverMediumUrl, - contentDescription = album.title, - modifier = Modifier - .fillMaxWidth() - .aspectRatio(1f) - .clip(MaterialTheme.shapes.medium), +private fun AlbumCardBody( + title: String, + counter: String, + source: String, + link: String?, + key: String, + mode: String? = null, +) { + Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) - Column(modifier = Modifier.padding(12.dp)) { + Text( + text = counter, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Surface( + color = MaterialTheme.colorScheme.secondaryContainer, + shape = MaterialTheme.shapes.small, + modifier = Modifier.testTag(TAG_ALBUM_SOURCE_PREFIX + key), + ) { + Text( + text = source, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + ) + } + if (mode != null) { + Text( + text = mode, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .padding(start = 6.dp) + .testTag(TAG_ALBUM_MODE_PREFIX + key), + ) + } + } + if (link != null) { Text( - text = album.title, - style = MaterialTheme.typography.titleSmall, + text = link, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, maxLines = 1, overflow = TextOverflow.Ellipsis, - ) - Text( - text = "${album.photosCount} фото · ${album.membersCount} участн.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .padding(top = 2.dp) + .testTag(TAG_ALBUM_LINK_PREFIX + key), ) } } } + +@Composable +private fun ServerAlbumCard(item: AlbumListItem.ServerAlbum, onClick: () -> Unit) { val album = item.album + val key = TAG_ALBUM_CARD_PREFIX + album.id + Card( + modifier = Modifier + .fillMaxWidth() + .testTag(key) + .clickable(onClick = onClick), + ) { + Column { + GaloAsyncImage( + url = album.coverThumbUrl ?: album.coverMediumUrl, + contentDescription = album.title, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + .clip(MaterialTheme.shapes.medium), + ) + AlbumCardBody( + title = album.title, + counter = "${album.photosCount} фото · ${album.membersCount} участн.", + source = "Сервер", + link = item.linkedDeviceNames.takeIf { it.isNotEmpty() } + ?.joinToString(prefix = "источник: "), + key = key, + ) + } + } +} + +@Composable +private fun DeviceAlbumCard(item: AlbumListItem.DeviceAlbumItem, onClick: () -> Unit) { + val album: DeviceAlbum = item.album + val key = TAG_DEVICE_ALBUM_CARD_PREFIX + album.id + Card( + modifier = Modifier + .fillMaxWidth() + .testTag(key) + .clickable(onClick = onClick), + ) { + Column { + GaloAsyncImage( + url = album.coverUri, + contentDescription = album.name, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + .clip(MaterialTheme.shapes.medium), + ) + AlbumCardBody( + title = album.name, + counter = "${album.itemCount} эл.", + source = "Телефон", + link = item.linkedServerTitle?.let { "→ $it" }, + key = key, + mode = albumModeLabel(item.mode), + ) + } + } +} + +private fun albumModeLabel(mode: SyncMode): String = when (mode) { + SyncMode.DISABLED -> "синхр. выкл." + SyncMode.COPY -> "копии" + SyncMode.MOVE -> "перенос" +} diff --git a/android/app/src/main/java/com/galo/android/ui/albums/AlbumsViewModel.kt b/android/app/src/main/java/com/galo/android/ui/albums/AlbumsViewModel.kt index baade21..3ce0808 100644 --- a/android/app/src/main/java/com/galo/android/ui/albums/AlbumsViewModel.kt +++ b/android/app/src/main/java/com/galo/android/ui/albums/AlbumsViewModel.kt @@ -3,7 +3,8 @@ package com.galo.android.ui.albums import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.galo.android.di.AppContainer -import com.galo.android.domain.model.Album +import com.galo.android.domain.model.DeviceAlbum +import com.galo.android.domain.model.SyncMode import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -15,11 +16,12 @@ import kotlinx.coroutines.launch class AlbumsViewModel(private val container: AppContainer) : ViewModel() { data class UiState( - val albums: List = emptyList(), + val items: List = emptyList(), val loading: Boolean = false, val offline: Boolean = false, val error: String? = null, val avatarUrl: String? = null, + val deviceAccess: Boolean = false, ) private data class Status( @@ -32,12 +34,38 @@ class AlbumsViewModel(private val container: AppContainer) : ViewModel() { private val avatar = MutableStateFlow(null) + private val deviceAlbums = MutableStateFlow>(emptyList()) + + private val deviceAccess = MutableStateFlow(false) + val state: StateFlow = combine( container.albumRepository.observeAlbums(), + container.syncRepository.observeLinks(), + combine(deviceAlbums, deviceAccess) { devices, access -> devices to access }, status, avatar, - ) { albums, s, a -> - UiState(albums = albums, loading = s.loading, offline = s.offline, error = s.error, avatarUrl = a) + ) { albums, links, device, s, a -> + val items = AlbumListBuilder.build( + albums = albums, + links = links.map { link -> + AlbumLink( + deviceAlbumId = link.deviceAlbumId, + deviceAlbumName = link.deviceAlbumName, + serverAlbumId = link.serverAlbumId, + mode = SyncMode.from(link.mode), + ) + }, + devices = device.first, + ) + + UiState( + items = items, + loading = s.loading, + offline = s.offline, + error = s.error, + avatarUrl = a, + deviceAccess = device.second, + ) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), UiState(loading = true)) init { @@ -47,6 +75,11 @@ class AlbumsViewModel(private val container: AppContainer) : ViewModel() { fun refresh() { viewModelScope.launch { status.update { it.copy(loading = true, error = null) } + + val access = container.mediaAccess.hasAccess() + deviceAccess.value = access + deviceAlbums.value = if (access) container.syncRepository.deviceAlbums() else emptyList() + val online = container.albumRepository.refresh() status.update { it.copy( diff --git a/android/app/src/main/java/com/galo/android/ui/common/MediaPermissionRequest.kt b/android/app/src/main/java/com/galo/android/ui/common/MediaPermissionRequest.kt new file mode 100644 index 0000000..dde7106 --- /dev/null +++ b/android/app/src/main/java/com/galo/android/ui/common/MediaPermissionRequest.kt @@ -0,0 +1,26 @@ +package com.galo.android.ui.common + +import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import com.galo.android.domain.media.MediaPermissions + +/** + * Requests runtime access to device media on first composition and reports the + * outcome. The permission set follows the running platform; partial grants are + * reported as a denial so the UI hides device albums. + */ +@Composable +fun RequestMediaAccess(onResult: (Boolean) -> Unit) { + val permissions = MediaPermissions.required(Build.VERSION.SDK_INT).toTypedArray() + val launcher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestMultiplePermissions(), + ) { result -> + onResult(result.isNotEmpty() && result.values.all { it }) + } + LaunchedEffect(Unit) { + launcher.launch(permissions) + } +} diff --git a/android/app/src/main/java/com/galo/android/ui/devicealbum/DeviceAlbumScreen.kt b/android/app/src/main/java/com/galo/android/ui/devicealbum/DeviceAlbumScreen.kt new file mode 100644 index 0000000..125b9a2 --- /dev/null +++ b/android/app/src/main/java/com/galo/android/ui/devicealbum/DeviceAlbumScreen.kt @@ -0,0 +1,564 @@ +package com.galo.android.ui.devicealbum + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.IntentSenderRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.itemsIndexed +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Cloud +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Button +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalNavigationDrawer +import androidx.compose.material3.ModalDrawerSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.rememberDrawerState +import androidx.compose.material3.DrawerValue +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.compose.LifecycleResumeEffect +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import com.galo.android.GaloApplication +import com.galo.android.domain.media.AllFilesAccess +import com.galo.android.domain.model.Album +import com.galo.android.domain.model.DeviceMedia +import com.galo.android.domain.model.MediaKind +import com.galo.android.domain.model.SyncMode +import com.galo.android.ui.common.GaloAsyncImage +import kotlinx.coroutines.launch + +const val TAG_DEVICE_GRID = "device_album_grid" +const val TAG_DEVICE_ITEM_PREFIX = "device_item_" +const val TAG_TRANSFERRED_PREFIX = "transferred_" +const val TAG_DEVICE_SETTINGS = "device_album_settings" +const val TAG_MODE_PREFIX = "sync_mode_" +const val TAG_TARGET_PREFIX = "sync_target_" +const val TAG_AWAITING_FREE = "device_awaiting_free" +const val TAG_FREE_BUTTON = "device_free_space" +const val TAG_RESTORE_BUTTON = "device_restore_originals" +const val TAG_UNLINK_BUTTON = "device_unlink" +const val TAG_LEAVING_C_WARNING = "device_leaving_c_warning" +const val TAG_RESTORE_DIALOG = "device_restore_dialog" +const val TAG_RESTORE_DIALOG_OK = "device_restore_dialog_ok" +const val TAG_LINKED_ALBUM = "device_linked_album" +const val TAG_CHOOSE_TARGET = "device_choose_target" +const val TAG_ALL_FILES_HINT = "device_all_files_hint" +const val TAG_ALL_FILES_BUTTON = "device_all_files_button" +const val TAG_TARGET_PICKER = "device_target_picker" +const val TAG_MODE_CONFIRM = "device_mode_confirm" +const val TAG_MODE_CONFIRM_OK = "device_mode_confirm_ok" +const val TAG_NO_TARGETS = "device_no_targets" +const val TAG_FREE_BANNER = "device_free_banner" +const val TAG_FREE_BANNER_BUTTON = "device_free_banner_button" +const val TAG_NEW_ALBUM_NAME = "device_new_album_name" +const val TAG_CREATE_TARGET = "device_create_target" + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DeviceAlbumScreen( + app: GaloApplication, + deviceAlbumId: String, + onBack: () -> Unit, +) { + val factory = viewModelFactory { + initializer { DeviceAlbumViewModel(app.container, deviceAlbumId) } + } + val viewModel: DeviceAlbumViewModel = viewModel(factory = factory) + val state by viewModel.state.collectAsStateWithLifecycle() + var viewerIndex by remember { mutableStateOf(null) } + + val context = LocalContext.current + var allFilesGranted by remember { mutableStateOf(AllFilesAccess.isGranted()) } + val allFilesLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult(), + ) { + allFilesGranted = AllFilesAccess.isGranted() + } + LifecycleResumeEffect(Unit) { + allFilesGranted = AllFilesAccess.isGranted() + onPauseOrDispose { } + } + + val openIndex = viewerIndex + if (openIndex != null) { + DeviceViewerScreen( + items = state.items, + initialIndex = openIndex, + onBack = { viewerIndex = null }, + originalSourceProvider = { mediaId -> viewModel.originalSource(mediaId) }, + ) + } else { + DeviceAlbumContent( + state = state, + onBack = onBack, + onMode = viewModel::setMode, + onTarget = viewModel::setTarget, + onRestoreOriginals = viewModel::restoreOriginals, + onOpen = { index -> viewerIndex = index }, + onCreateAlbum = viewModel::createAlbum, + allFilesAccessGranted = allFilesGranted, + onRequestAllFilesAccess = { + runCatching { allFilesLauncher.launch(AllFilesAccess.settingsIntent(context)) } + }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DeviceAlbumContent( + state: DeviceAlbumViewModel.UiState, + onBack: () -> Unit, + onMode: (SyncMode) -> Unit, + onTarget: (String) -> Unit, + onRestoreOriginals: () -> Unit, + onOpen: (Int) -> Unit, + onCreateAlbum: (String) -> Unit, + allFilesAccessGranted: Boolean = true, + onRequestAllFilesAccess: () -> Unit = {}, +) { + val drawerState = rememberDrawerState(DrawerValue.Closed) + val scope = rememberCoroutineScope() + + // Dialogs: a mode change is confirmed, and enabling sync without a target + // opens the album picker (which can also create a new album). + var leaveCNext by remember { mutableStateOf(null) } + var confirmMode by remember { mutableStateOf(null) } + var pickMode by remember { mutableStateOf(null) } + + val requestMode: (SyncMode) -> Unit = { target -> + when (val action = modeAction(state.mode, state.serverAlbumId != null, target)) { + null -> Unit + is ModeAction.Apply -> onMode(action.mode) + is ModeAction.LeaveC -> leaveCNext = action.mode + is ModeAction.PickTarget -> pickMode = action.mode + is ModeAction.Confirm -> confirmMode = action.mode + } + } + + val titleOf = state.serverAlbumId?.let { id -> + state.serverAlbums.firstOrNull { it.id == id }?.title ?: id + } + + leaveCNext?.let { next -> + if (next == SyncMode.COPY) { + // Switching to copies brings the originals back from the server, + // replacing the local previews; there is no "keep previews" here. + RestoreFromServerDialog( + onRestore = { + onRestoreOriginals() + onMode(next) + leaveCNext = null + }, + onCancel = { leaveCNext = null }, + ) + } else { + LeavingModeCWarning( + onRestore = { + onRestoreOriginals() + onMode(next) + leaveCNext = null + }, + onKeepPreviews = { + onMode(next) + leaveCNext = null + }, + onCancel = { leaveCNext = null }, + ) + } + } + + pickMode?.let { mode -> + TargetPickerDialog( + albums = state.serverAlbums, + defaultName = state.name, + onPick = { id -> + onMode(mode) + onTarget(id) + pickMode = null + }, + onCreate = { name -> + onMode(mode) + onCreateAlbum(name) + pickMode = null + }, + onDismiss = { pickMode = null }, + ) + } + + confirmMode?.let { mode -> + ModeConfirmDialog( + mode = mode, + onConfirm = { + onMode(mode) + // Enabling move: the originals are replaced by offloaded versions; + // request the "all files access" once so no consent dialog appears. + if (mode == SyncMode.MOVE && !allFilesAccessGranted) onRequestAllFilesAccess() + confirmMode = null + }, + onDismiss = { confirmMode = null }, + ) + } + + ModalNavigationDrawer( + drawerState = drawerState, + drawerContent = { + ModalDrawerSheet { + DeviceAlbumSettings( + state = state, + onMode = requestMode, + ) + } + }, + ) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(state.name.ifBlank { "Альбом телефона" }) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Назад") + } + }, + actions = { + IconButton(onClick = { scope.launch { drawerState.open() } }) { + Icon(Icons.Filled.Settings, contentDescription = "Настройки синхронизации") + } + }, + ) + }, + ) { padding -> + Column(modifier = Modifier.fillMaxSize().padding(padding)) { + if (state.items.isEmpty()) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("В альбоме нет медиа") + } + } else { + LazyVerticalGrid( + columns = GridCells.Fixed(3), + contentPadding = PaddingValues(2.dp), + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + modifier = Modifier.fillMaxSize().testTag(TAG_DEVICE_GRID), + ) { + itemsIndexed(state.items, key = { _, media -> media.uri }) { index, media -> + DeviceMediaCell( + media = media, + transferred = media.id in state.transferredMediaIds, + onClick = { onOpen(index) }, + ) + } + } + } + } + } + } +} + +@Composable +private fun DeviceMediaCell(media: DeviceMedia, transferred: Boolean, onClick: () -> Unit) { + Box( + modifier = Modifier + .aspectRatio(1f) + .clickable(onClick = onClick) + .testTag(TAG_DEVICE_ITEM_PREFIX + media.id), + ) { + GaloAsyncImage( + url = media.uri, + contentDescription = media.displayName, + modifier = Modifier.fillMaxSize(), + ) + if (media.kind == MediaKind.VIDEO) { + Icon( + imageVector = Icons.Filled.PlayArrow, + contentDescription = "Видео", + tint = Color.White, + modifier = Modifier.align(Alignment.Center), + ) + } + if (transferred) { + Surface( + color = Color.Black.copy(alpha = 0.45f), + shape = MaterialTheme.shapes.small, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(4.dp) + .testTag(TAG_TRANSFERRED_PREFIX + media.id), + ) { + Icon( + imageVector = Icons.Filled.Cloud, + contentDescription = "Перенесено на сервер", + tint = Color.White, + modifier = Modifier.padding(3.dp).size(16.dp), + ) + } + } + } +} + +@Composable +fun DeviceAlbumSettings( + state: DeviceAlbumViewModel.UiState, + onMode: (SyncMode) -> Unit, +) { + LazyColumn( + modifier = Modifier.fillMaxWidth().padding(16.dp).testTag(TAG_DEVICE_SETTINGS), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + item { + Text( + text = "Синхронизация с сервером", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(bottom = 4.dp), + ) + } + items(SyncMode.entries) { candidate -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { onMode(candidate) } + .testTag(TAG_MODE_PREFIX + candidate.name) + .padding(vertical = 4.dp), + ) { + RadioButton(selected = state.mode == candidate, onClick = { onMode(candidate) }) + Text(modeLabel(candidate), style = MaterialTheme.typography.bodyMedium) + } + } + } +} + +/** + * Shown before leaving mode C. Originals are gone from the device, so the user + * chooses between returning them from the server and keeping only previews. + */ +@Composable +fun LeavingModeCWarning( + onRestore: () -> Unit, + onKeepPreviews: () -> Unit, + onCancel: () -> Unit, +) { + AlertDialog( + onDismissRequest = onCancel, + modifier = Modifier.testTag(TAG_LEAVING_C_WARNING), + title = { Text("Выйти из режима переноса?") }, + text = { + Text( + "Оригиналов на телефоне больше нет — остались только превью. " + + "Вернуть оригиналы с сервера или оставить превью?", + ) + }, + confirmButton = { TextButton(onClick = onRestore) { Text("Вернуть оригиналы") } }, + dismissButton = { + TextButton(onClick = onKeepPreviews) { Text("Оставить превью") } + }, + ) +} + +/** + * Walks the user through picking the server album: choose an existing one or + * create a new one (the field is prefilled with the device album's local name). + */ +@Composable +fun TargetPickerDialog( + albums: List, + defaultName: String, + onPick: (String) -> Unit, + onCreate: (String) -> Unit, + onDismiss: () -> Unit, +) { + var name by remember(defaultName) { mutableStateOf(defaultName) } + AlertDialog( + onDismissRequest = onDismiss, + modifier = Modifier.testTag(TAG_TARGET_PICKER), + title = { Text("Альбом на сервере") }, + text = { + Column( + modifier = Modifier + .heightIn(max = 320.dp) + .verticalScroll(rememberScrollState()), + ) { + albums.forEach { album -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { onPick(album.id) } + .testTag(TAG_TARGET_PREFIX + album.id) + .padding(vertical = 6.dp), + ) { + Text(album.title, style = MaterialTheme.typography.bodyMedium) + } + } + if (albums.isEmpty()) { + Text( + text = "Нет серверных альбомов — создайте новый.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .padding(vertical = 4.dp) + .testTag(TAG_NO_TARGETS), + ) + } + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("Новый альбом") }, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp) + .testTag(TAG_NEW_ALBUM_NAME), + ) + } + }, + confirmButton = { + TextButton( + onClick = { onCreate(name) }, + enabled = name.isNotBlank(), + modifier = Modifier.testTag(TAG_CREATE_TARGET), + ) { + Text("Создать и синхронизировать") + } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Отмена") } }, + ) +} + +/** Confirms a mode change and explains what the mode does. */ +@Composable +fun ModeConfirmDialog( + mode: SyncMode, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + val title: String + val explanation: String + val confirmLabel: String + when (mode) { + SyncMode.COPY -> { + title = "Включить «Копии оригиналов»?" + explanation = "Оригиналы останутся на телефоне, копии загрузятся в выбранный серверный альбом." + confirmLabel = "Копии" + } + + SyncMode.MOVE -> { + title = "Включить «Перенос оригиналов»?" + explanation = "Фото переносятся на сервер: после успешной загрузки каждого объекта " + + "оригинал на телефоне заменяется разгруженной версией." + confirmLabel = "Перенос" + } + + SyncMode.DISABLED -> { + title = "Выключить синхронизацию?" + explanation = "Альбом останется только на телефоне." + confirmLabel = "Выключить" + } + } + AlertDialog( + onDismissRequest = onDismiss, + modifier = Modifier.testTag(TAG_MODE_CONFIRM), + title = { Text(title) }, + text = { Text(explanation) }, + confirmButton = { + TextButton(onClick = onConfirm, modifier = Modifier.testTag(TAG_MODE_CONFIRM_OK)) { + Text(confirmLabel) + } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Отмена") } }, + ) +} + +/** Confirms leaving mode C into "copies": the originals come back from the server. */ +@Composable +fun RestoreFromServerDialog(onRestore: () -> Unit, onCancel: () -> Unit) { + AlertDialog( + onDismissRequest = onCancel, + modifier = Modifier.testTag(TAG_RESTORE_DIALOG), + title = { Text("Переключить на «Копии»?") }, + text = { + Text( + "Локальные превью будут заменены разгруженными копиями с сервера — " + + "оригиналы вернутся на телефон.", + ) + }, + confirmButton = { + TextButton(onClick = onRestore, modifier = Modifier.testTag(TAG_RESTORE_DIALOG_OK)) { + Text("Вернуть оригиналы") + } + }, + dismissButton = { TextButton(onClick = onCancel) { Text("Отмена") } }, + ) +} + +private fun modeLabel(mode: SyncMode): String = when (mode) { + SyncMode.DISABLED -> "Выключена (только на телефоне)" + SyncMode.COPY -> "Копии оригиналов на сервере" + SyncMode.MOVE -> "Перенос оригиналов на сервер" +} + +/** What to do when the user taps a synchronization mode. */ +sealed interface ModeAction { + data class Apply(val mode: SyncMode) : ModeAction + data class LeaveC(val mode: SyncMode) : ModeAction + data class PickTarget(val mode: SyncMode) : ModeAction + data class Confirm(val mode: SyncMode) : ModeAction +} + +/** + * Decides how to handle a mode change: leaving mode C warns first, turning sync + * on without a target opens the album picker, and other cross-mode switches are + * confirmed. Returns null when the mode is unchanged. + */ +internal fun modeAction(currentMode: SyncMode, hasTarget: Boolean, target: SyncMode): ModeAction? = when { + target == currentMode -> null + currentMode == SyncMode.MOVE && target != SyncMode.MOVE -> ModeAction.LeaveC(target) + target == SyncMode.DISABLED -> ModeAction.Apply(target) + !hasTarget -> ModeAction.PickTarget(target) + else -> ModeAction.Confirm(target) +} diff --git a/android/app/src/main/java/com/galo/android/ui/devicealbum/DeviceAlbumViewModel.kt b/android/app/src/main/java/com/galo/android/ui/devicealbum/DeviceAlbumViewModel.kt new file mode 100644 index 0000000..2b1e467 --- /dev/null +++ b/android/app/src/main/java/com/galo/android/ui/devicealbum/DeviceAlbumViewModel.kt @@ -0,0 +1,216 @@ +package com.galo.android.ui.devicealbum + +import android.content.IntentSender +import android.net.Uri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.galo.android.di.AppContainer +import com.galo.android.data.db.entity.DeviceAssetEntity +import com.galo.android.domain.model.Album +import com.galo.android.domain.model.DeviceMedia +import com.galo.android.domain.model.LocalAssetState +import com.galo.android.domain.model.SyncMode +import com.galo.android.data.sync.OffloadResult +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +class DeviceAlbumViewModel( + private val container: AppContainer, + private val deviceAlbumId: String, +) : ViewModel() { + + data class UiState( + val name: String = "", + val items: List = emptyList(), + val mode: SyncMode = SyncMode.DISABLED, + val serverAlbumId: String? = null, + val serverAlbums: List = emptyList(), + val awaitingFree: Int = 0, + val previewCount: Int = 0, + val serverPhotoByMediaId: Map = emptyMap(), + val transferredMediaIds: Set = emptySet(), + ) + + private data class Local( + val name: String = "", + val items: List = emptyList(), + ) + + private val local = MutableStateFlow(Local()) + + private val pendingMode = MutableStateFlow(null) + + val state: StateFlow = combine( + container.albumRepository.observeAlbums(), + container.syncRepository.observeLinks(), + container.syncRepository.observeAssets(deviceAlbumId), + local, + pendingMode, + ) { albums, links, assets, l, pending -> + val link = links.firstOrNull { it.deviceAlbumId == deviceAlbumId } + UiState( + name = l.name, + items = l.items, + mode = link?.let { SyncMode.from(it.mode) } ?: pending ?: SyncMode.DISABLED, + serverAlbumId = link?.serverAlbumId?.takeIf { it.isNotBlank() }, + serverAlbums = albums, + awaitingFree = assets.count { it.state == LocalAssetState.AWAITING_FREE.name }, + previewCount = assets.count { it.state == LocalAssetState.PREVIEW.name }, + serverPhotoByMediaId = serverOriginalsByMediaId(assets), + transferredMediaIds = serverOriginalsByMediaId(assets).keys, + ) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), UiState()) + + init { + load() + } + + fun load() { + viewModelScope.launch { + val items = runCatching { container.syncRepository.deviceItems(deviceAlbumId) } + .getOrDefault(emptyList()) + // Prefer the media bucket name; fall back to the album list so the name + // is known even for an empty album. + val name = items.firstOrNull()?.albumName?.ifBlank { null } + ?: runCatching { + container.syncRepository.deviceAlbums() + .firstOrNull { it.id == deviceAlbumId }?.name?.ifBlank { null } + }.getOrNull() + ?: "Альбом" + local.value = Local(name = name, items = items) + } + // Fetch the server albums so the target picker is populated even if the + // album list screen has not been opened yet. + viewModelScope.launch { + runCatching { container.albumRepository.refresh() } + } + } + + fun setMode(mode: SyncMode) { + pendingMode.value = mode + viewModelScope.launch { + if (mode == SyncMode.DISABLED) { + // Turning sync off clears the link to the server album. + container.syncRepository.unlink(deviceAlbumId) + return@launch + } + container.syncRepository.link( + deviceAlbumId = deviceAlbumId, + deviceAlbumName = local.value.name, + serverAlbumId = state.value.serverAlbumId ?: "", + mode = mode, + ) + if (state.value.serverAlbumId != null) reconcileNow() + } + } + + fun setTarget(serverAlbumId: String) { + viewModelScope.launch { + container.syncRepository.link( + deviceAlbumId = deviceAlbumId, + deviceAlbumName = local.value.name, + serverAlbumId = serverAlbumId, + mode = pendingMode.value ?: state.value.mode, + ) + reconcileNow() + } + } + + /** Creates a new server album and links this device album to it. */ + fun createAlbum(name: String) { + val title = name.trim() + if (title.isEmpty()) return + viewModelScope.launch { + val album = runCatching { container.api.createAlbum(title) }.getOrNull() ?: return@launch + runCatching { container.albumRepository.refresh() } + container.syncRepository.link( + deviceAlbumId = deviceAlbumId, + deviceAlbumName = local.value.name, + serverAlbumId = album.id, + mode = pendingMode.value ?: state.value.mode, + ) + reconcileNow() + } + } + + /** Runs a synchronization pass immediately (used after configuring). */ + private suspend fun reconcileNow() { + runCatching { container.syncCoordinator.reconcile(deviceAlbumId) } + } + + /** + * Frees space by offloading every asset awaiting offload. Media the app does + * not own cannot be changed silently, so a batch consent request is surfaced + * through [onNeedsConsent] and the action is retried after the user confirms. + */ + fun freeSpace(onNeedsConsent: (IntentSender) -> Unit) { + viewModelScope.launch { + val assets = container.syncRepository.assets(deviceAlbumId) + .filter { it.state == LocalAssetState.AWAITING_FREE.name } + val writeUris = mutableListOf() + val deleteUris = mutableListOf() + for (asset in assets) { + val result = runCatching { container.offloadManager.offload(asset) }.getOrNull() ?: continue + when (result) { + OffloadResult.Done, OffloadResult.Failed -> Unit + is OffloadResult.NeedsWriteConsent -> writeUris += result.uris + is OffloadResult.NeedsDeleteConsent -> deleteUris += result.uris + } + } + val sender = container.offloadManager.writeConsent(writeUris) + ?: container.offloadManager.deleteConsent(deleteUris) + if (sender != null) onNeedsConsent(sender) + } + } + + /** Returns originals to the device for every offloaded (preview-only) asset. */ + fun restoreOriginals() { + viewModelScope.launch { + val assets = container.syncRepository.assets(deviceAlbumId) + .filter { it.state == LocalAssetState.PREVIEW.name } + container.restoreManager.restore(assets) + } + } + + /** Unlinks the album. Server photos are never deleted by this action. */ + fun unlink() { + viewModelScope.launch { container.syncRepository.unlink(deviceAlbumId) } + } + + /** + * Full-resolution original for a device asset that has a server photo (for + * example a preview left by mode C): its URL and the true pixel dimensions, + * so zoom can show details and use the correct 200%-of-natural limit. + * Null when the local file already is the original. + */ + suspend fun originalSource(mediaStoreId: Long): OriginalSource? { + val photoId = state.value.serverPhotoByMediaId[mediaStoreId] ?: return null + val photo = runCatching { container.api.photo(photoId) }.getOrNull() ?: return null + val url = container.apiClient.objectUrls.resolve(photo.originalUrl ?: photo.downloadUrl) + ?: return null + return OriginalSource(url, photo.width, photo.height) + } +} + +/** Server original of an offloaded asset: its URL and natural pixel size. */ +data class OriginalSource( + val url: String, + val width: Int, + val height: Int, +) + +/** + * Server photo ids to use as the full-resolution original, keyed by device media + * id. Only assets that are preview-only locally (mode C offload) need the server + * original; when the local file is the original (modes A/B) there is nothing to + * fetch. + */ +internal fun serverOriginalsByMediaId( + assets: List, +): Map = assets + .filter { it.state == LocalAssetState.PREVIEW.name && it.serverPhotoId != null } + .associate { it.mediaStoreId to it.serverPhotoId!! } diff --git a/android/app/src/main/java/com/galo/android/ui/devicealbum/DeviceViewerScreen.kt b/android/app/src/main/java/com/galo/android/ui/devicealbum/DeviceViewerScreen.kt new file mode 100644 index 0000000..f775358 --- /dev/null +++ b/android/app/src/main/java/com/galo/android/ui/devicealbum/DeviceViewerScreen.kt @@ -0,0 +1,99 @@ +package com.galo.android.ui.devicealbum + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import com.galo.android.domain.model.DeviceMedia +import com.galo.android.domain.model.MediaKind +import com.galo.android.ui.viewer.VideoPlayer +import com.galo.android.ui.viewer.ZoomableImage + +const val TAG_DEVICE_VIEWER_PAGER = "device_viewer_pager" + +/** Full-screen viewer over the media of a device album (local URIs). */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DeviceViewerScreen( + items: List, + initialIndex: Int, + onBack: () -> Unit, + originalSourceProvider: suspend (Long) -> OriginalSource? = { null }, +) { + if (items.isEmpty()) { + onBack() + return + } + val pagerState = rememberPagerState( + initialPage = initialIndex.coerceIn(0, items.lastIndex), + ) { items.size } + val current = items.getOrNull(pagerState.currentPage) + var zoomed by remember(current?.id) { mutableStateOf(false) } + + Scaffold( + topBar = { + TopAppBar( + title = { + Text(current?.displayName ?: "Просмотр", style = MaterialTheme.typography.titleMedium) + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Назад") + } + }, + ) + }, + ) { padding -> + Surface(color = Color.Black, modifier = Modifier.fillMaxSize().padding(padding)) { + HorizontalPager( + state = pagerState, + userScrollEnabled = !zoomed, + modifier = Modifier.fillMaxSize().testTag(TAG_DEVICE_VIEWER_PAGER), + key = { index -> items[index].id }, + ) { page -> + val item = items[page] + if (item.kind == MediaKind.VIDEO) { + VideoPlayer(url = item.uri, posterUrl = null, modifier = Modifier.fillMaxSize()) + } else { + // When the local file is only a preview (mode C), zoom loads the + // full-resolution original from the server, and uses the server + // dimensions as the natural size for the zoom limit. + val mediaId = item.id + var original by remember(mediaId) { mutableStateOf(null) } + LaunchedEffect(mediaId) { original = originalSourceProvider(mediaId) } + val naturalWidth = original?.width?.takeIf { it > 0 } ?: item.width + val naturalHeight = original?.height?.takeIf { it > 0 } ?: item.height + ZoomableImage( + previewUrl = item.uri, + hiResUrl = original?.url ?: item.uri, + contentDescription = item.displayName, + naturalWidth = naturalWidth, + naturalHeight = naturalHeight, + onZoomChanged = { zoomed = it }, + active = pagerState.currentPage == page, + modifier = Modifier.fillMaxSize(), + ) + } + } + } + } +} diff --git a/android/app/src/main/java/com/galo/android/ui/viewer/ZoomableImage.kt b/android/app/src/main/java/com/galo/android/ui/viewer/ZoomableImage.kt index 0f2473c..3d2f972 100644 --- a/android/app/src/main/java/com/galo/android/ui/viewer/ZoomableImage.kt +++ b/android/app/src/main/java/com/galo/android/ui/viewer/ZoomableImage.kt @@ -51,18 +51,45 @@ internal fun zoomSource(previewUrl: String?, hiResUrl: String?, zoomed: Boolean) * differs from [previewUrl] (i.e. the original is loading on zoom) the preview * is wired as a placeholder, so the surface never flashes empty/black. */ -internal fun zoomImageRequest(context: Context, source: String?, previewUrl: String?): ImageRequest { +/** + * Target decode size for a zoomed image: twice the viewport (the max zoom) but + * capped, so a huge original cannot blow up the bitmap. Null when the viewport + * is not measured yet. + */ +internal fun zoomBitmapSize(viewport: IntSize, maxSide: Int = 2560): coil.size.Size? { + if (viewport.width <= 0 || viewport.height <= 0) return null + val target = maxOf(viewport.width, viewport.height) * 2 + val factor = if (target > maxSide) maxSide.toFloat() / target else 1f + return coil.size.Size( + (viewport.width * 2 * factor).toInt().coerceAtLeast(1), + (viewport.height * 2 * factor).toInt().coerceAtLeast(1), + ) +} + +internal fun zoomImageRequest( + context: Context, + source: String?, + previewUrl: String?, + zoomSize: coil.size.Size? = null, +): ImageRequest { + val sourceKey = ObjectCacheKey.keyFor(source) val previewKey = ObjectCacheKey.keyFor(previewUrl) + val fullResolution = zoomSize != null return ImageRequest.Builder(context) .data(source) .apply { - ObjectCacheKey.keyFor(source)?.let { - memoryCacheKey(it) - diskCacheKey(it) - } - if (source != previewUrl) { + diskCacheKey(sourceKey) + // The zoomed decode is cached under its own key so it does not collide + // with the fitted preview bitmap. + memoryCacheKey(if (fullResolution && sourceKey != null) "$sourceKey#full" else sourceKey) + // Keep the fitted preview visible while the sharper frame decodes, so + // zooming does not flash black. + if (fullResolution || source != previewUrl) { previewKey?.let { placeholderMemoryCacheKey(it) } } + if (zoomSize != null) { + size(zoomSize) + } } .crossfade(true) .build() @@ -109,6 +136,16 @@ fun ZoomableImage( var zoomed by remember { mutableStateOf(false) } var originalLoading by remember { mutableStateOf(false) } + // Adopt the natural dimensions when they become known: the viewer passes the + // local size first and the server original's size arrives a moment later. + LaunchedEffect(naturalWidth, naturalHeight) { + if (naturalWidth > 0 && naturalHeight > 0 && + (natural.width != naturalWidth || natural.height != naturalHeight) + ) { + natural = IntSize(naturalWidth, naturalHeight) + } + } + fun sync() { layerScale = zoom.layerScale panX = zoom.offsetX @@ -185,8 +222,9 @@ fun ZoomableImage( }, ) { val context = LocalContext.current - val request = remember(source, previewUrl, context) { - zoomImageRequest(context, source, previewUrl) + val zoomSize = remember(viewport) { zoomBitmapSize(viewport) } + val request = remember(source, previewUrl, zoomed, zoomSize, context) { + zoomImageRequest(context, source, previewUrl, zoomSize = if (zoomed) zoomSize else null) } AsyncImage( model = request, diff --git a/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/android/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..0686797 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..5c84730 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..5c84730 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..a5786bf --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,5 @@ + + + + #2563EB + diff --git a/android/app/src/test/java/com/galo/android/data/db/SyncDaoTest.kt b/android/app/src/test/java/com/galo/android/data/db/SyncDaoTest.kt new file mode 100644 index 0000000..bfff3ec --- /dev/null +++ b/android/app/src/test/java/com/galo/android/data/db/SyncDaoTest.kt @@ -0,0 +1,94 @@ +package com.galo.android.data.db + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import com.galo.android.data.db.dao.SyncDao +import com.galo.android.data.db.entity.DeviceAssetEntity +import com.galo.android.data.db.entity.SyncedAlbumEntity +import com.galo.android.domain.model.LocalAssetState +import com.galo.android.domain.model.SyncMode +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class SyncDaoTest { + + private lateinit var db: GaloDatabase + private lateinit var dao: SyncDao + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder(context, GaloDatabase::class.java) + .allowMainThreadQueries() + .build() + dao = db.syncDao() + } + + @After + fun tearDown() { + db.close() + } + + @Test + fun `links a device album to a server album`() = runBlocking { + dao.upsertSyncedAlbum( + SyncedAlbumEntity("bucket1", "Camera", "srv1", SyncMode.COPY.name, 1L), + ) + val link = dao.getSyncedAlbum("bucket1") + assertEquals("srv1", link?.serverAlbumId) + assertEquals(SyncMode.COPY.name, link?.mode) + assertEquals(1, dao.observeSyncedAlbums().first().size) + assertEquals(1, dao.getSyncedAlbumsForServer("srv1").size) + } + + @Test + fun `tracks provenance of an uploaded asset`() = runBlocking { + dao.upsertAssets( + listOf( + DeviceAssetEntity( + assetKey = DeviceAssetEntity.key("bucket1", 7L), + deviceAlbumId = "bucket1", + mediaStoreId = 7L, + uri = "content://media/external/images/media/7", + displayName = "IMG.jpg", + mimeType = "image/jpeg", + size = 10L, + dateModified = 5L, + contentHash = "sha256-x", + serverPhotoId = "photo1", + uploadedAt = 9L, + state = LocalAssetState.PREVIEW.name, + ), + ), + ) + assertEquals("photo1", dao.getAssetByHash("sha256-x")?.serverPhotoId) + assertEquals("bucket1", dao.getAssetsByServerPhoto("photo1").single().deviceAlbumId) + assertEquals(1, dao.observeAssets("bucket1").first().size) + } + + @Test + fun `removing an asset clears its index entry`() = runBlocking { + val key = DeviceAssetEntity.key("bucket1", 7L) + dao.upsertAssets( + listOf( + DeviceAssetEntity( + key, "bucket1", 7L, "u", "n", null, 1L, 1L, + null, null, null, LocalAssetState.LOCAL.name, + ), + ), + ) + dao.deleteAsset(key) + assertNull(dao.getAsset(key)) + } +} diff --git a/android/app/src/test/java/com/galo/android/data/media/DeviceMediaMappingTest.kt b/android/app/src/test/java/com/galo/android/data/media/DeviceMediaMappingTest.kt new file mode 100644 index 0000000..48e4b9e --- /dev/null +++ b/android/app/src/test/java/com/galo/android/data/media/DeviceMediaMappingTest.kt @@ -0,0 +1,77 @@ +package com.galo.android.data.media + +import android.database.MatrixCursor +import android.provider.MediaStore +import com.galo.android.domain.model.MediaKind +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class DeviceMediaMappingTest { + + private val images = MediaStore.Images.Media.EXTERNAL_CONTENT_URI + + private fun cursor(vararg rows: Array): MatrixCursor = + MatrixCursor(DeviceMediaMapping.projection).apply { rows.forEach { addRow(it) } } + + private fun row( + id: Long, + name: String, + bucketId: String, + bucketName: String, + modified: Long, + taken: Long? = null, + ): Array = arrayOf( + id, name, "image/jpeg", 1000L, modified, taken, bucketId, bucketName, "DCIM/$bucketName/", 4000, 3000, + ) + + @Test + fun `maps cursor rows to media`() { + val media = DeviceMediaMapping.mediaFromCursor( + cursor(row(7, "IMG_7.jpg", "11", "Camera", 100, taken = 90)), + images, + MediaKind.IMAGE, + ) + assertEquals(1, media.size) + val m = media.first() + assertEquals(7L, m.id) + assertEquals("content://media/external/images/media/7", m.uri) + assertEquals("11", m.albumId) + assertEquals("Camera", m.albumName) + assertEquals("IMG_7.jpg", m.displayName) + assertEquals(90L, m.dateTaken) + assertEquals(MediaKind.IMAGE, m.kind) + } + + @Test + fun `groups media into albums sorted by name with latest cover`() { + val media = DeviceMediaMapping.mediaFromCursor( + cursor( + row(1, "a.jpg", "b2", "Screenshots", 10), + row(2, "b.jpg", "b1", "Camera", 20), + row(3, "c.jpg", "b1", "Camera", 30), + ), + images, + MediaKind.IMAGE, + ) + val albums = DeviceMediaMapping.albums(media) + assertEquals(listOf("Camera", "Screenshots"), albums.map { it.name }) + assertEquals(listOf(2, 1), albums.map { it.itemCount }) + // Cover is the most recently modified item of the bucket. + assertEquals("content://media/external/images/media/3", albums.first().coverUri) + } + + @Test + fun `album without a name falls back`() { + val media = DeviceMediaMapping.mediaFromCursor( + cursor(row(1, "a.jpg", "b1", "", 10)), + images, + MediaKind.IMAGE, + ) + assertEquals("Без названия", DeviceMediaMapping.albums(media).single().name) + } +} diff --git a/android/app/src/test/java/com/galo/android/data/media/MediaAccessCheckerTest.kt b/android/app/src/test/java/com/galo/android/data/media/MediaAccessCheckerTest.kt new file mode 100644 index 0000000..924a7fe --- /dev/null +++ b/android/app/src/test/java/com/galo/android/data/media/MediaAccessCheckerTest.kt @@ -0,0 +1,47 @@ +package com.galo.android.data.media + +import android.app.Application +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.galo.android.domain.media.MediaPermissions +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class MediaAccessCheckerTest { + + private val context: Context = ApplicationProvider.getApplicationContext() + + private fun grant(vararg permissions: String) { + shadowOf(context as Application).grantPermissions(*permissions) + } + + private fun deny(vararg permissions: String) { + shadowOf(context as Application).denyPermissions(*permissions) + } + + @Test + fun `no access when permissions are missing`() { + deny(MediaPermissions.READ_MEDIA_IMAGES, MediaPermissions.READ_MEDIA_VIDEO) + assertFalse(MediaAccessChecker(context).hasAccess()) + } + + @Test + fun `partial access is treated as no access`() { + grant(MediaPermissions.READ_MEDIA_IMAGES) + deny(MediaPermissions.READ_MEDIA_VIDEO) + assertFalse(MediaAccessChecker(context).hasAccess()) + } + + @Test + fun `access when every required permission is granted`() { + grant(MediaPermissions.READ_MEDIA_IMAGES, MediaPermissions.READ_MEDIA_VIDEO) + assertTrue(MediaAccessChecker(context).hasAccess()) + } +} diff --git a/android/app/src/test/java/com/galo/android/data/media/PreviewGeneratorTest.kt b/android/app/src/test/java/com/galo/android/data/media/PreviewGeneratorTest.kt new file mode 100644 index 0000000..75728fb --- /dev/null +++ b/android/app/src/test/java/com/galo/android/data/media/PreviewGeneratorTest.kt @@ -0,0 +1,58 @@ +package com.galo.android.data.media + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import androidx.test.core.app.ApplicationProvider +import com.galo.android.domain.model.MediaKind +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.File + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class PreviewGeneratorTest { + + @Test + fun `caps the long side`() { + assertEquals(1600 to 800, PreviewGenerator.scaledSize(3200, 1600)) + assertEquals(800 to 1600, PreviewGenerator.scaledSize(1600, 3200)) + } + + @Test + fun `keeps smaller images unchanged`() { + assertEquals(800 to 400, PreviewGenerator.scaledSize(800, 400)) + } + + @Test + fun `empty source has no target size`() { + assertEquals(0 to 0, PreviewGenerator.scaledSize(0, 10)) + } + + @Test + fun `writes a jpeg preview into the cache`() { + val context = ApplicationProvider.getApplicationContext() + val source = File(context.cacheDir, "source.png") + val bitmap = Bitmap.createBitmap(800, 600, Bitmap.Config.ARGB_8888) + source.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) } + + val out = PreviewGenerator.previewFile(context, "bucket/7") + assertTrue(PreviewGenerator.generate(context, Uri.fromFile(source), MediaKind.IMAGE, out)) + + assertTrue(out.exists()) + val decoded = BitmapFactory.decodeFile(out.absolutePath) + assertNotNull(decoded) + assertEquals(800, decoded.width) + assertEquals(600, decoded.height) + // JPEG magic bytes. + val header = out.readBytes().take(2) + assertEquals(0xFF.toByte(), header[0]) + assertEquals(0xD8.toByte(), header[1]) + } +} diff --git a/android/app/src/test/java/com/galo/android/data/repo/SyncRepositoryTest.kt b/android/app/src/test/java/com/galo/android/data/repo/SyncRepositoryTest.kt new file mode 100644 index 0000000..8ffa331 --- /dev/null +++ b/android/app/src/test/java/com/galo/android/data/repo/SyncRepositoryTest.kt @@ -0,0 +1,71 @@ +package com.galo.android.data.repo + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import com.galo.android.data.db.GaloDatabase +import com.galo.android.data.db.entity.DeviceAssetEntity +import com.galo.android.data.media.MediaStoreDeviceSource +import com.galo.android.domain.model.LocalAssetState +import com.galo.android.domain.model.SyncMode +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class SyncRepositoryTest { + + private lateinit var db: GaloDatabase + private lateinit var repo: SyncRepository + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder(context, GaloDatabase::class.java) + .allowMainThreadQueries() + .build() + repo = SyncRepository(db.syncDao(), MediaStoreDeviceSource(context)) + } + + @After + fun tearDown() { + db.close() + } + + @Test + fun `links and relinks across modes`() = runBlocking { + repo.link("b1", "Camera", "srv1", SyncMode.COPY) + assertEquals(SyncMode.COPY, SyncMode.from(repo.currentLink("b1")?.mode)) + + repo.link("b1", "Camera", "srv1", SyncMode.MOVE) + assertEquals(SyncMode.MOVE, SyncMode.from(repo.currentLink("b1")?.mode)) + assertEquals(1, repo.observeLinks().first().size) + } + + @Test + fun `unlink removes the link but keeps provenance and never deletes photos`() = runBlocking { + repo.link("b1", "Camera", "srv1", SyncMode.MOVE) + db.syncDao().upsertAssets( + listOf( + DeviceAssetEntity( + "b1/1", "b1", 1L, "u", "a.jpg", "image/jpeg", 1L, 1L, + "h", "p1", 1L, LocalAssetState.UPLOADED.name, + ), + ), + ) + + repo.unlink("b1") + + assertNull(repo.currentLink("b1")) + // Provenance survives, and no server photo is touched by unlink. + assertEquals("p1", db.syncDao().getAsset("b1/1")?.serverPhotoId) + } +} diff --git a/android/app/src/test/java/com/galo/android/data/sync/ChangeDetectionTest.kt b/android/app/src/test/java/com/galo/android/data/sync/ChangeDetectionTest.kt new file mode 100644 index 0000000..2262f2b --- /dev/null +++ b/android/app/src/test/java/com/galo/android/data/sync/ChangeDetectionTest.kt @@ -0,0 +1,37 @@ +package com.galo.android.data.sync + +import android.app.Application +import androidx.test.core.app.ApplicationProvider +import androidx.work.NetworkType +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class ChangeDetectionTest { + + @Test + fun `unknown generation is not a change`() { + assertEquals(false, ChangeDetector.changed(0L, 5L)) + } + + @Test + fun `a new generation is a change`() { + assertEquals(true, ChangeDetector.changed(5L, 6L)) + } + + @Test + fun `the same generation is not a change`() { + assertEquals(false, ChangeDetector.changed(5L, 5L)) + } + + @Test + fun `media store generation is readable`() { + val context = ApplicationProvider.getApplicationContext() + // Must not throw; the value itself is provider-defined. + MediaStoreGeneration.current(context) + } +} diff --git a/android/app/src/test/java/com/galo/android/data/sync/ContentHashTest.kt b/android/app/src/test/java/com/galo/android/data/sync/ContentHashTest.kt new file mode 100644 index 0000000..e6b6388 --- /dev/null +++ b/android/app/src/test/java/com/galo/android/data/sync/ContentHashTest.kt @@ -0,0 +1,25 @@ +package com.galo.android.data.sync + +import org.junit.Assert.assertEquals +import org.junit.Test +import java.io.ByteArrayInputStream + +class ContentHashTest { + + @Test + fun `hashes bytes as lowercase hex sha256`() { + assertEquals( + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + ContentHash.sha256("abc".toByteArray()), + ) + } + + @Test + fun `streaming hash matches the byte hash`() { + val payload = ByteArray(200_000) { (it % 251).toByte() } + assertEquals( + ContentHash.sha256(payload), + ContentHash.sha256(ByteArrayInputStream(payload)), + ) + } +} diff --git a/android/app/src/test/java/com/galo/android/data/sync/OffloadManagerTest.kt b/android/app/src/test/java/com/galo/android/data/sync/OffloadManagerTest.kt new file mode 100644 index 0000000..f9bf4a3 --- /dev/null +++ b/android/app/src/test/java/com/galo/android/data/sync/OffloadManagerTest.kt @@ -0,0 +1,63 @@ +package com.galo.android.data.sync + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import com.galo.android.data.db.GaloDatabase +import com.galo.android.data.db.entity.DeviceAssetEntity +import com.galo.android.domain.model.LocalAssetState +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class OffloadManagerTest { + + private lateinit var db: GaloDatabase + private lateinit var manager: OffloadManager + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder(context, GaloDatabase::class.java) + .allowMainThreadQueries() + .build() + manager = OffloadManager(context, db.syncDao()) + } + + @After + fun tearDown() { + db.close() + } + + @Test + fun `offload of an unreadable asset returns a result instead of throwing`() = runBlocking { + val asset = DeviceAssetEntity( + assetKey = "b/1", + deviceAlbumId = "b", + mediaStoreId = 1L, + uri = "content://does/not/exist", + displayName = "n.jpg", + mimeType = "image/jpeg", + size = 0L, + dateModified = 0L, + contentHash = null, + serverPhotoId = null, + uploadedAt = null, + state = LocalAssetState.UPLOADED.name, + ) + + val result = manager.offload(asset) + + assertTrue( + "expected Failed or a consent request, got $result", + result == OffloadResult.Failed || result is OffloadResult.NeedsWriteConsent, + ) + } +} diff --git a/android/app/src/test/java/com/galo/android/data/sync/RestoreManagerTest.kt b/android/app/src/test/java/com/galo/android/data/sync/RestoreManagerTest.kt new file mode 100644 index 0000000..234ea4d --- /dev/null +++ b/android/app/src/test/java/com/galo/android/data/sync/RestoreManagerTest.kt @@ -0,0 +1,19 @@ +package com.galo.android.data.sync + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class RestoreManagerTest { + + @Test + fun `restoring fits when there is enough space`() { + assertTrue(RestoreManager.canRestore(requiredBytes = 100, availableBytes = 100)) + assertTrue(RestoreManager.canRestore(requiredBytes = 100, availableBytes = 500)) + } + + @Test + fun `restoring is deferred when space is short`() { + assertFalse(RestoreManager.canRestore(requiredBytes = 500, availableBytes = 100)) + } +} diff --git a/android/app/src/test/java/com/galo/android/data/sync/SyncCoordinatorTest.kt b/android/app/src/test/java/com/galo/android/data/sync/SyncCoordinatorTest.kt new file mode 100644 index 0000000..ad29482 --- /dev/null +++ b/android/app/src/test/java/com/galo/android/data/sync/SyncCoordinatorTest.kt @@ -0,0 +1,275 @@ +package com.galo.android.data.sync + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import com.galo.android.data.db.GaloDatabase +import com.galo.android.data.db.entity.DeviceAssetEntity +import com.galo.android.data.db.entity.SyncedAlbumEntity +import com.galo.android.domain.model.LocalAssetState +import com.galo.android.domain.model.MediaKind +import com.galo.android.domain.model.SyncMode +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.ByteArrayInputStream +import java.io.InputStream + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class SyncCoordinatorTest { + + private class FakeRemote : SyncRemote { + var checksumCalls = 0 + val renames = mutableListOf>() + val deletes = mutableListOf() + val moves = mutableListOf, String>>() + var serverMap: Map = emptyMap() + + override suspend fun serverChecksums(serverAlbumId: String): Map { + checksumCalls++ + return serverMap + } + + override suspend fun renamePhoto(photoId: String, newName: String) { + renames += photoId to newName + } + + override suspend fun deletePhoto(photoId: String) { + deletes += photoId + } + + override suspend fun movePhotos(photoIds: List, serverAlbumId: String) { + moves += photoIds to serverAlbumId + } + } + + private class FakeQueue : SyncUploadQueue { + val enqueued = mutableListOf>() + override suspend fun enqueue( + deviceAlbumId: String, + serverAlbumId: String, + asset: LocalAsset, + mode: SyncMode, + ) { + enqueued += Triple(deviceAlbumId, serverAlbumId, asset) + } + } + + private class FakeProvider : LocalAssetProvider { + var items: List = emptyList() + var outside: Set = emptySet() + override suspend fun assets(deviceAlbumId: String): List = items + override suspend fun hashesOutside(deviceAlbumId: String): Set = outside + override fun openStream(uri: String): InputStream = ByteArrayInputStream(ByteArray(4)) + } + + private class FakeOffloader : AssetOffloader { + val offloaded = mutableListOf() + var result: OffloadResult = OffloadResult.Done + override suspend fun offload(asset: DeviceAssetEntity): OffloadResult { + offloaded += asset.assetKey + return result + } + } + + private lateinit var db: GaloDatabase + private lateinit var remote: FakeRemote + private lateinit var provider: FakeProvider + private lateinit var queue: FakeQueue + private lateinit var offloader: FakeOffloader + private lateinit var coordinator: SyncCoordinator + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder(context, GaloDatabase::class.java) + .allowMainThreadQueries() + .build() + remote = FakeRemote() + provider = FakeProvider() + queue = FakeQueue() + offloader = FakeOffloader() + coordinator = SyncCoordinator(db.syncDao(), provider, remote, queue, offloader) + } + + @After + fun tearDown() { + db.close() + } + + private suspend fun link(mode: SyncMode) = db.syncDao().upsertSyncedAlbum( + SyncedAlbumEntity("b1", "Camera", "srv1", mode.name, 1L), + ) + + private fun asset(key: String, name: String = "a.jpg", hash: String? = "h") = LocalAsset( + assetKey = key, + mediaStoreId = 1L, + displayName = name, + mimeType = "image/jpeg", + kind = MediaKind.IMAGE, + contentHash = hash, + uri = "content://media/external/images/media/1", + ) + + @Test + fun `mode A makes no server requests`() = runBlocking { + link(SyncMode.DISABLED) + assertEquals(false, coordinator.reconcile("b1")) + assertEquals(0, remote.checksumCalls) + assertTrue(queue.enqueued.isEmpty()) + } + + @Test + fun `an unlinked album never contacts the server`() = runBlocking { + assertEquals(false, coordinator.reconcile("missing")) + assertEquals(0, remote.checksumCalls) + } + + @Test + fun `new content is handed to the upload queue`() = runBlocking { + link(SyncMode.COPY) + provider.items = listOf(asset("b1/1", hash = "h2")) + assertEquals(true, coordinator.reconcile("b1")) + assertEquals(1, queue.enqueued.size) + assertEquals("srv1", queue.enqueued.single().second) + assertEquals("b1/1", queue.enqueued.single().third.assetKey) + } + + @Test + fun `content already on the server is linked, not uploaded`() = runBlocking { + link(SyncMode.COPY) + provider.items = listOf(asset("b1/1", hash = "h1")) + remote.serverMap = mapOf("h1" to "p1") + coordinator.reconcile("b1") + assertTrue(queue.enqueued.isEmpty()) + assertEquals("p1", db.syncDao().getAssets("b1").single().serverPhotoId) + } + + @Test + fun `our upload is deleted when it leaves the device`() = runBlocking { + link(SyncMode.COPY) + remote.serverMap = mapOf("h" to "p1") + db.syncDao().upsertAssets( + listOf( + DeviceAssetEntity( + "b1/1", "b1", 1L, "u", "a.jpg", "image/jpeg", 1L, 1L, + "h", "p1", 1L, LocalAssetState.UPLOADED.name, + ), + ), + ) + coordinator.reconcile("b1") + assertEquals(listOf("p1"), remote.deletes) + assertNull(db.syncDao().getAsset("b1/1")) + } + + @Test + fun `content still present elsewhere is not deleted`() = runBlocking { + link(SyncMode.COPY) + db.syncDao().upsertAssets( + listOf( + DeviceAssetEntity( + "b1/1", "b1", 1L, "u", "a.jpg", "image/jpeg", 1L, 1L, + "h", "p1", 1L, LocalAssetState.UPLOADED.name, + ), + ), + ) + provider.outside = setOf("h") + coordinator.reconcile("b1") + assertTrue(remote.deletes.isEmpty()) + } + + @Test + fun `a rename is propagated`() = runBlocking { + link(SyncMode.COPY) + provider.items = listOf(asset("b1/1", name = "b.jpg", hash = "h")) + db.syncDao().upsertAssets( + listOf( + DeviceAssetEntity( + "b1/1", "b1", 1L, "u", "a.jpg", "image/jpeg", 1L, 1L, + "h", "p1", 1L, LocalAssetState.UPLOADED.name, + ), + ), + ) + coordinator.reconcile("b1") + assertEquals(listOf("p1" to "b.jpg"), remote.renames) + } + + @Test + fun `a photo already gone from the server is only reflected locally`() = runBlocking { + link(SyncMode.COPY) + db.syncDao().upsertAssets( + listOf( + DeviceAssetEntity( + "b1/1", "b1", 1L, "u", "a.jpg", "image/jpeg", 1L, 1L, + "h", "p1", 1L, LocalAssetState.UPLOADED.name, + ), + ), + ) + coordinator.reconcile("b1") + assertTrue(remote.deletes.isEmpty()) + assertNull(db.syncDao().getAsset("b1/1")) + } + + @Test + fun `a mode chosen before a target does not contact the server`() = runBlocking { + db.syncDao().upsertSyncedAlbum( + SyncedAlbumEntity("b1", "Camera", "", SyncMode.COPY.name, 1L), + ) + assertEquals(false, coordinator.reconcile("b1")) + assertEquals(0, remote.checksumCalls) + } + + @Test + fun `switching to move offloads an already uploaded asset`() = runBlocking { + link(SyncMode.MOVE) + remote.serverMap = mapOf("h" to "p1") + db.syncDao().upsertAssets( + listOf( + DeviceAssetEntity( + "b1/1", "b1", 1L, "u", "a.jpg", "image/jpeg", 1L, 1L, + "h", "p1", 1L, LocalAssetState.UPLOADED.name, + ), + ), + ) + provider.items = listOf(asset("b1/1", hash = "h")) + + coordinator.reconcile("b1") + + assertEquals(listOf("b1/1"), offloader.offloaded) + assertTrue(queue.enqueued.isEmpty()) + } + + @Test + fun `offload needing consent marks the asset as awaiting free`() = runBlocking { + link(SyncMode.MOVE) + offloader.result = OffloadResult.NeedsWriteConsent(emptyList()) + remote.serverMap = mapOf("h" to "p1") + db.syncDao().upsertAssets( + listOf( + DeviceAssetEntity( + "b1/1", "b1", 1L, "u", "a.jpg", "image/jpeg", 1L, 1L, + "h", "p1", 1L, LocalAssetState.UPLOADED.name, + ), + ), + ) + provider.items = listOf(asset("b1/1", hash = "h")) + + coordinator.reconcile("b1") + + assertEquals(LocalAssetState.AWAITING_FREE.name, db.syncDao().getAsset("b1/1")?.state) + } + + @Test + fun `move delegates to the server`() = runBlocking { + coordinator.moveToServerAlbum(listOf("p1", "p2"), "srv2") + assertEquals(listOf(listOf("p1", "p2") to "srv2"), remote.moves) + } +} diff --git a/android/app/src/test/java/com/galo/android/data/sync/SyncPlannerTest.kt b/android/app/src/test/java/com/galo/android/data/sync/SyncPlannerTest.kt new file mode 100644 index 0000000..4f456bf --- /dev/null +++ b/android/app/src/test/java/com/galo/android/data/sync/SyncPlannerTest.kt @@ -0,0 +1,121 @@ +package com.galo.android.data.sync + +import com.galo.android.domain.model.LocalAssetState +import com.galo.android.domain.model.MediaKind +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class SyncPlannerTest { + + private fun local( + key: String, + name: String = "a.jpg", + hash: String? = "h1", + ) = LocalAsset( + assetKey = key, + mediaStoreId = 1L, + displayName = name, + mimeType = "image/jpeg", + kind = MediaKind.IMAGE, + contentHash = hash, + ) + + @Test + fun `content already on the server is linked, not uploaded`() { + val asset = local("b1/1", hash = "h1") + val actions = SyncPlanner.plan( + local = listOf(asset), + index = emptyList(), + serverChecksums = mapOf("h1" to "p1"), + hashesPresentElsewhere = emptySet(), + ) + assertEquals(listOf(SyncAction.Link(asset, "p1")), actions) + } + + @Test + fun `new content is uploaded`() { + val asset = local("b1/2", hash = "h2") + val actions = SyncPlanner.plan( + local = listOf(asset), + index = emptyList(), + serverChecksums = mapOf("h1" to "p1"), + hashesPresentElsewhere = emptySet(), + ) + assertEquals(listOf(SyncAction.Upload(asset)), actions) + } + + @Test + fun `our upload is deleted when it leaves the device`() { + val actions = SyncPlanner.plan( + local = emptyList(), + index = listOf(IndexedAsset("b1/1", "p1", "h1", "a.jpg")), + serverChecksums = emptyMap(), + hashesPresentElsewhere = emptySet(), + ) + assertEquals(listOf(SyncAction.DeleteOnServer("p1", "b1/1")), actions) + } + + @Test + fun `content still present in another linked album is not deleted`() { + val actions = SyncPlanner.plan( + local = emptyList(), + index = listOf(IndexedAsset("b1/1", "p1", "h1", "a.jpg")), + serverChecksums = emptyMap(), + hashesPresentElsewhere = setOf("h1"), + ) + assertTrue(actions.isEmpty()) + } + + @Test + fun `photos we did not upload are never deleted`() { + val actions = SyncPlanner.plan( + local = emptyList(), + index = listOf(IndexedAsset("b1/1", null, "h1", "a.jpg")), + serverChecksums = emptyMap(), + hashesPresentElsewhere = emptySet(), + ) + assertTrue(actions.isEmpty()) + } + + @Test + fun `a rename is propagated`() { + val asset = local("b1/1", name = "b.jpg", hash = "h1") + val actions = SyncPlanner.plan( + local = listOf(asset), + index = listOf(IndexedAsset("b1/1", "p1", "h1", "a.jpg")), + serverChecksums = emptyMap(), + hashesPresentElsewhere = emptySet(), + ) + assertEquals(listOf(SyncAction.RenameOnServer("p1", "b.jpg")), actions) + } + + @Test + fun `an offloaded preview is not re-uploaded`() { + val asset = local("b1/1", hash = "preview-hash") + val actions = SyncPlanner.plan( + local = listOf(asset), + index = listOf( + IndexedAsset("b1/1", "p1", "original-hash", "a.jpg", LocalAssetState.PREVIEW.name), + ), + serverChecksums = mapOf("original-hash" to "p1"), + hashesPresentElsewhere = emptySet(), + ) + assertTrue("an offloaded asset must not be uploaded again", actions.isEmpty()) + } + + @Test + fun `edited content is re-uploaded and the old upload removed`() { + val asset = local("b1/1", name = "a.jpg", hash = "h2") + val actions = SyncPlanner.plan( + local = listOf(asset), + index = listOf(IndexedAsset("b1/1", "p1", "h1", "a.jpg")), + serverChecksums = emptyMap(), + hashesPresentElsewhere = emptySet(), + ) + assertEquals( + listOf(SyncAction.Upload(asset), SyncAction.DeleteOnServer("p1", "b1/1")), + actions, + ) + } +} diff --git a/android/app/src/test/java/com/galo/android/data/sync/SyncSchedulerTest.kt b/android/app/src/test/java/com/galo/android/data/sync/SyncSchedulerTest.kt new file mode 100644 index 0000000..1b1f114 --- /dev/null +++ b/android/app/src/test/java/com/galo/android/data/sync/SyncSchedulerTest.kt @@ -0,0 +1,29 @@ +package com.galo.android.data.sync + +import androidx.work.NetworkType +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class SyncSchedulerTest { + + @Test + fun `wifi only constrains the periodic sync to unmetered networks`() { + assertEquals( + NetworkType.UNMETERED, + SyncScheduler.buildPeriodicRequest(wifiOnly = true).workSpec.constraints.requiredNetworkType, + ) + } + + @Test + fun `without the limit any connection is allowed`() { + assertEquals( + NetworkType.CONNECTED, + SyncScheduler.buildPeriodicRequest(wifiOnly = false).workSpec.constraints.requiredNetworkType, + ) + } +} diff --git a/android/app/src/test/java/com/galo/android/domain/media/MediaPermissionsTest.kt b/android/app/src/test/java/com/galo/android/domain/media/MediaPermissionsTest.kt new file mode 100644 index 0000000..cccf779 --- /dev/null +++ b/android/app/src/test/java/com/galo/android/domain/media/MediaPermissionsTest.kt @@ -0,0 +1,25 @@ +package com.galo.android.domain.media + +import org.junit.Assert.assertEquals +import org.junit.Test + +class MediaPermissionsTest { + + @Test + fun `pre-13 needs the legacy external storage read`() { + assertEquals(listOf(MediaPermissions.READ_EXTERNAL_STORAGE), MediaPermissions.required(28)) + assertEquals(listOf(MediaPermissions.READ_EXTERNAL_STORAGE), MediaPermissions.required(32)) + } + + @Test + fun `android 13 and newer need per-type media permissions`() { + assertEquals( + listOf(MediaPermissions.READ_MEDIA_IMAGES, MediaPermissions.READ_MEDIA_VIDEO), + MediaPermissions.required(33), + ) + assertEquals( + listOf(MediaPermissions.READ_MEDIA_IMAGES, MediaPermissions.READ_MEDIA_VIDEO), + MediaPermissions.required(35), + ) + } +} diff --git a/android/app/src/test/java/com/galo/android/ui/albums/AlbumListBuilderTest.kt b/android/app/src/test/java/com/galo/android/ui/albums/AlbumListBuilderTest.kt new file mode 100644 index 0000000..851b757 --- /dev/null +++ b/android/app/src/test/java/com/galo/android/ui/albums/AlbumListBuilderTest.kt @@ -0,0 +1,68 @@ +package com.galo.android.ui.albums + +import com.galo.android.domain.model.Album +import com.galo.android.domain.model.DeviceAlbum +import com.galo.android.domain.model.SyncMode +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class AlbumListBuilderTest { + + private fun album(id: String, title: String) = Album( + id = id, + title = title, + description = "", + ownerId = "u", + coverPhotoId = null, + coverThumbUrl = null, + coverMediumUrl = null, + photosCount = 0, + membersCount = 1, + updatedAt = null, + ) + + private fun device(id: String, name: String) = DeviceAlbum( + id = id, + name = name, + itemCount = 3, + coverUri = null, + relativePath = "DCIM/$name/", + ) + + @Test + fun `a linked server album is collapsed into the device card`() { + val items = AlbumListBuilder.build( + albums = listOf(album("s1", "Отпуск")), + links = listOf(AlbumLink("b1", "Camera", "s1", SyncMode.COPY)), + devices = listOf(device("b1", "Camera")), + ) + assertEquals(1, items.size) + val deviceItem = items.single() as AlbumListItem.DeviceAlbumItem + assertEquals("Отпуск", deviceItem.linkedServerTitle) + assertEquals(SyncMode.COPY, deviceItem.mode) + } + + @Test + fun `an independent server album is still listed`() { + val items = AlbumListBuilder.build( + albums = listOf(album("s1", "Отпуск"), album("s2", "Работа")), + links = listOf(AlbumLink("b1", "Camera", "s1", SyncMode.COPY)), + devices = listOf(device("b1", "Camera")), + ) + assertTrue(items.any { it is AlbumListItem.ServerAlbum && it.album.id == "s2" }) + assertTrue(items.none { it is AlbumListItem.ServerAlbum && it.album.id == "s1" }) + } + + @Test + fun `a mode chosen without a target keeps a blank link title`() { + val items = AlbumListBuilder.build( + albums = emptyList(), + links = listOf(AlbumLink("b1", "Camera", "", SyncMode.COPY)), + devices = listOf(device("b1", "Camera")), + ) + val deviceItem = items.single() as AlbumListItem.DeviceAlbumItem + assertEquals(null, deviceItem.linkedServerTitle) + assertEquals(SyncMode.COPY, deviceItem.mode) + } +} diff --git a/android/app/src/test/java/com/galo/android/ui/albums/AlbumsContentTest.kt b/android/app/src/test/java/com/galo/android/ui/albums/AlbumsContentTest.kt index 534cf24..9dce557 100644 --- a/android/app/src/test/java/com/galo/android/ui/albums/AlbumsContentTest.kt +++ b/android/app/src/test/java/com/galo/android/ui/albums/AlbumsContentTest.kt @@ -1,10 +1,13 @@ package com.galo.android.ui.albums +import androidx.compose.runtime.Composable import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import com.galo.android.domain.model.Album +import com.galo.android.domain.model.DeviceAlbum +import com.galo.android.domain.model.SyncMode import com.galo.android.ui.common.TAG_USER_AVATAR_IMAGE import com.galo.android.ui.common.TAG_USER_AVATAR_PLACEHOLDER import com.galo.android.ui.theme.GaloTheme @@ -34,19 +37,37 @@ class AlbumsContentTest { updatedAt = null, ) + private fun device(id: String, name: String, count: Int = 3) = DeviceAlbum( + id = id, + name = name, + itemCount = count, + coverUri = null, + relativePath = "DCIM/$name/", + ) + + @Composable + private fun screen(state: AlbumsViewModel.UiState) { + GaloTheme(dynamicColor = false) { + AlbumsContent( + state = state, + onOpenAlbum = {}, + onOpenDeviceAlbum = {}, + onOpenUploads = {}, + onOpenProfile = {}, + onRefresh = {}, + onLogout = {}, + ) + } + } + @Test - fun `album grid shows cards with counters`() { + fun `server album grid shows cards with counters`() { compose.setContent { - GaloTheme(dynamicColor = false) { - AlbumsContent( - state = AlbumsViewModel.UiState(albums = listOf(album("a1", "Summer"))), - onOpenAlbum = {}, - onOpenUploads = {}, - onOpenProfile = {}, - onRefresh = {}, - onLogout = {}, - ) - } + screen( + AlbumsViewModel.UiState( + items = listOf(AlbumListItem.ServerAlbum(album("a1", "Summer"), emptyList())), + ), + ) } compose.onNodeWithTag(TAG_ALBUMS_GRID).assertIsDisplayed() compose.onNodeWithTag(TAG_ALBUM_CARD_PREFIX + "a1").assertIsDisplayed() @@ -54,58 +75,48 @@ class AlbumsContentTest { compose.onNodeWithText("4 фото · 2 участн.").assertIsDisplayed() } + @Test + fun `mixed list shows device album with source and link`() { + val items = AlbumListBuilder.build( + albums = listOf(album("a1", "Отпуск")), + links = listOf(AlbumLink("b1", "Camera", "a1", SyncMode.COPY)), + devices = listOf(device("b1", "Camera", 9)), + ) + compose.setContent { + screen(AlbumsViewModel.UiState(items = items, deviceAccess = true)) + } + compose.onNodeWithTag(TAG_DEVICE_ALBUM_CARD_PREFIX + "b1").assertIsDisplayed() + // The linked server album is collapsed into the device card, not shown twice. + compose.onNodeWithTag(TAG_ALBUM_CARD_PREFIX + "a1").assertDoesNotExist() + compose.onNodeWithText("Телефон").assertIsDisplayed() + compose.onNodeWithText("9 эл.").assertIsDisplayed() + compose.onNodeWithText("→ Отпуск").assertIsDisplayed() + compose.onNodeWithText("копии").assertIsDisplayed() + } + @Test fun `offline banner is shown when offline`() { compose.setContent { - GaloTheme(dynamicColor = false) { - AlbumsContent( - state = AlbumsViewModel.UiState( - albums = listOf(album("a1", "Summer")), - offline = true, - error = "Нет сети — показаны кэшированные данные", - ), - onOpenAlbum = {}, - onOpenUploads = {}, - onOpenProfile = {}, - onRefresh = {}, - onLogout = {}, - ) - } + screen( + AlbumsViewModel.UiState( + items = listOf(AlbumListItem.ServerAlbum(album("a1", "Summer"), emptyList())), + offline = true, + error = "Нет сети — показаны кэшированные данные", + ), + ) } compose.onNodeWithTag(TAG_ALBUMS_OFFLINE).assertIsDisplayed() } @Test fun `empty state is shown without albums`() { - compose.setContent { - GaloTheme(dynamicColor = false) { - AlbumsContent( - state = AlbumsViewModel.UiState(), - onOpenAlbum = {}, - onOpenUploads = {}, - onOpenProfile = {}, - onRefresh = {}, - onLogout = {}, - ) - } - } + compose.setContent { screen(AlbumsViewModel.UiState()) } compose.onNodeWithText("Пока нет альбомов").assertIsDisplayed() } @Test fun `header shows the silhouette placeholder without an avatar`() { - compose.setContent { - GaloTheme(dynamicColor = false) { - AlbumsContent( - state = AlbumsViewModel.UiState(), - onOpenAlbum = {}, - onOpenUploads = {}, - onOpenProfile = {}, - onRefresh = {}, - onLogout = {}, - ) - } - } + compose.setContent { screen(AlbumsViewModel.UiState()) } compose.onNodeWithTag(TAG_ALBUMS_AVATAR).assertExists() compose.onNodeWithTag(TAG_USER_AVATAR_PLACEHOLDER, useUnmergedTree = true).assertIsDisplayed() } @@ -113,16 +124,7 @@ class AlbumsContentTest { @Test fun `header shows the avatar image when set`() { compose.setContent { - GaloTheme(dynamicColor = false) { - AlbumsContent( - state = AlbumsViewModel.UiState(avatarUrl = "http://host/api/avatars/u1/v1"), - onOpenAlbum = {}, - onOpenUploads = {}, - onOpenProfile = {}, - onRefresh = {}, - onLogout = {}, - ) - } + screen(AlbumsViewModel.UiState(avatarUrl = "http://host/api/avatars/u1/v1")) } compose.onNodeWithTag(TAG_USER_AVATAR_IMAGE, useUnmergedTree = true).assertIsDisplayed() } diff --git a/android/app/src/test/java/com/galo/android/ui/devicealbum/DeviceAlbumContentTest.kt b/android/app/src/test/java/com/galo/android/ui/devicealbum/DeviceAlbumContentTest.kt new file mode 100644 index 0000000..2f4a939 --- /dev/null +++ b/android/app/src/test/java/com/galo/android/ui/devicealbum/DeviceAlbumContentTest.kt @@ -0,0 +1,278 @@ +package com.galo.android.ui.devicealbum + +import androidx.compose.runtime.Composable +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertTextContains +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollToNode +import com.galo.android.domain.model.Album +import com.galo.android.domain.model.DeviceMedia +import com.galo.android.domain.model.MediaKind +import com.galo.android.domain.model.SyncMode +import com.galo.android.ui.theme.GaloTheme +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class DeviceAlbumContentTest { + + @get:Rule + val compose = createComposeRule() + + private fun media(id: Long, kind: MediaKind = MediaKind.IMAGE) = DeviceMedia( + id = id, + uri = "content://media/external/images/media/$id", + albumId = "b1", + albumName = "Camera", + displayName = "IMG_$id.jpg", + mimeType = "image/jpeg", + size = 100L, + width = 4000, + height = 3000, + dateModified = 5L, + dateTaken = null, + relativePath = "DCIM/Camera/", + kind = kind, + ) + + private fun album(id: String, title: String) = Album( + id = id, + title = title, + description = "", + ownerId = "u1", + coverPhotoId = null, + coverThumbUrl = null, + coverMediumUrl = null, + photosCount = 0, + membersCount = 1, + updatedAt = null, + ) + + @Composable + private fun settings( + state: DeviceAlbumViewModel.UiState, + onMode: (SyncMode) -> Unit = {}, + ) { + GaloTheme(dynamicColor = false) { + DeviceAlbumSettings( + state = state, + onMode = onMode, + ) + } + } + + @Composable + private fun content( + state: DeviceAlbumViewModel.UiState, + onMode: (SyncMode) -> Unit = {}, + onTarget: (String) -> Unit = {}, + onCreateAlbum: (String) -> Unit = {}, + ) { + GaloTheme(dynamicColor = false) { + DeviceAlbumContent( + state = state, + onBack = {}, + onMode = onMode, + onTarget = onTarget, + onRestoreOriginals = {}, + onOpen = {}, + onCreateAlbum = onCreateAlbum, + ) + } + } + + private fun openSettings() { + compose.onNodeWithContentDescription("Настройки синхронизации").performClick() + compose.waitForIdle() + } + + + @Test + fun `device album shows its media grid`() { + compose.setContent { + content(DeviceAlbumViewModel.UiState(name = "Camera", items = listOf(media(1), media(2)))) + } + compose.onNodeWithTag(TAG_DEVICE_GRID).assertIsDisplayed() + compose.onNodeWithTag(TAG_DEVICE_ITEM_PREFIX + 1).assertIsDisplayed() + compose.onNodeWithTag(TAG_DEVICE_ITEM_PREFIX + 2).assertIsDisplayed() + } + + @Test + fun `settings show only the mode choice`() { + compose.setContent { + settings( + DeviceAlbumViewModel.UiState( + name = "Camera", + mode = SyncMode.COPY, + serverAlbumId = "s1", + serverAlbums = listOf(album("s1", "Отпуск"), album("s2", "Работа")), + ), + ) + } + compose.onNodeWithTag(TAG_DEVICE_SETTINGS).assertIsDisplayed() + compose.onNodeWithTag(TAG_MODE_PREFIX + SyncMode.COPY.name).assertIsDisplayed() + // No album list and no extra controls in the settings. + compose.onNodeWithTag(TAG_TARGET_PREFIX + "s2").assertDoesNotExist() + compose.onNodeWithTag(TAG_LINKED_ALBUM).assertDoesNotExist() + } + + @Test + fun `mode action from off without a target opens the picker`() { + assertEquals( + ModeAction.PickTarget(SyncMode.COPY), + modeAction(SyncMode.DISABLED, hasTarget = false, target = SyncMode.COPY), + ) + } + + @Test + fun `mode action between copy and move confirms`() { + assertEquals( + ModeAction.Confirm(SyncMode.MOVE), + modeAction(SyncMode.COPY, hasTarget = true, target = SyncMode.MOVE), + ) + // Leaving mode C always warns first, whatever the next mode. + assertEquals( + ModeAction.LeaveC(SyncMode.COPY), + modeAction(SyncMode.MOVE, hasTarget = true, target = SyncMode.COPY), + ) + } + + @Test + fun `mode action leaving mode C warns first`() { + assertEquals( + ModeAction.LeaveC(SyncMode.DISABLED), + modeAction(SyncMode.MOVE, hasTarget = true, target = SyncMode.DISABLED), + ) + } + + @Test + fun `mode action to off applies directly`() { + assertEquals( + ModeAction.Apply(SyncMode.DISABLED), + modeAction(SyncMode.COPY, hasTarget = true, target = SyncMode.DISABLED), + ) + } + + @Test + fun `mode action for the same mode does nothing`() { + assertEquals(null, modeAction(SyncMode.COPY, hasTarget = true, target = SyncMode.COPY)) + } + @Test + fun `target picker lists albums and picks one`() { + var picked: String? = null + compose.setContent { + GaloTheme(dynamicColor = false) { + TargetPickerDialog( + albums = listOf(album("s1", "Отпуск")), + defaultName = "Камера", + onPick = { picked = it }, + onCreate = {}, + onDismiss = {}, + ) + } + } + compose.onNodeWithTag(TAG_TARGET_PREFIX + "s1").performClick() + assertEquals("s1", picked) + } + + @Test + fun `picker prefills the local album name and creates`() { + var created: String? = null + compose.setContent { + GaloTheme(dynamicColor = false) { + TargetPickerDialog( + albums = emptyList(), + defaultName = "Камера", + onPick = {}, + onCreate = { created = it }, + onDismiss = {}, + ) + } + } + compose.onNodeWithTag(TAG_NEW_ALBUM_NAME).assertTextContains("Камера") + compose.onNodeWithTag(TAG_CREATE_TARGET).performClick() + assertEquals("Камера", created) + } + + @Test + fun `mode confirm dialog confirms`() { + var confirmed = false + compose.setContent { + GaloTheme(dynamicColor = false) { + ModeConfirmDialog(mode = SyncMode.MOVE, onConfirm = { confirmed = true }, onDismiss = {}) + } + } + compose.onNodeWithTag(TAG_MODE_CONFIRM).assertIsDisplayed() + compose.onNodeWithText("Перенос").performClick() + assertTrue(confirmed) + } + + @Test + fun `restore dialog returns originals`() { + var restored = false + compose.setContent { + GaloTheme(dynamicColor = false) { + RestoreFromServerDialog(onRestore = { restored = true }, onCancel = {}) + } + } + compose.onNodeWithTag(TAG_RESTORE_DIALOG).assertIsDisplayed() + compose.onNodeWithText("Вернуть оригиналы").performClick() + assertTrue(restored) + } + + @Test + fun `leaving mode C warns`() { + var kept = false + compose.setContent { + GaloTheme(dynamicColor = false) { + LeavingModeCWarning(onRestore = {}, onKeepPreviews = { kept = true }, onCancel = {}) + } + } + compose.onNodeWithTag(TAG_LEAVING_C_WARNING).assertIsDisplayed() + compose.onNodeWithText("Оставить превью").performClick() + assertTrue(kept) + } + + @Test + fun `transferred media shows a cloud badge`() { + compose.setContent { + content( + DeviceAlbumViewModel.UiState( + name = "Camera", + items = listOf(media(1), media(2)), + transferredMediaIds = setOf(2L), + ), + ) + } + compose.onNodeWithTag(TAG_DEVICE_ITEM_PREFIX + 1).assertIsDisplayed() + compose.onNodeWithTag(TAG_TRANSFERRED_PREFIX + 2, useUnmergedTree = true).assertExists() + compose.onNodeWithTag(TAG_TRANSFERRED_PREFIX + 1, useUnmergedTree = true).assertDoesNotExist() + } + + @Test + fun `settings panel offers the three sync modes`() { + compose.setContent { settings(DeviceAlbumViewModel.UiState(name = "Camera")) } + compose.onNodeWithTag(TAG_MODE_PREFIX + SyncMode.DISABLED.name).assertIsDisplayed() + compose.onNodeWithText("Копии оригиналов на сервере").assertIsDisplayed() + compose.onNodeWithText("Перенос оригиналов на сервер").assertIsDisplayed() + } + + @Test + fun `choosing a mode reports it`() { + var chosen: SyncMode? = null + compose.setContent { settings(DeviceAlbumViewModel.UiState(name = "Camera"), onMode = { chosen = it }) } + compose.onNodeWithTag(TAG_MODE_PREFIX + SyncMode.MOVE.name).performClick() + assertEquals(SyncMode.MOVE, chosen) + } +} diff --git a/android/app/src/test/java/com/galo/android/ui/devicealbum/DeviceAlbumOriginalsTest.kt b/android/app/src/test/java/com/galo/android/ui/devicealbum/DeviceAlbumOriginalsTest.kt new file mode 100644 index 0000000..72a1427 --- /dev/null +++ b/android/app/src/test/java/com/galo/android/ui/devicealbum/DeviceAlbumOriginalsTest.kt @@ -0,0 +1,49 @@ +package com.galo.android.ui.devicealbum + +import com.galo.android.data.db.entity.DeviceAssetEntity +import com.galo.android.domain.model.LocalAssetState +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class DeviceAlbumOriginalsTest { + + private fun asset(id: Long, state: LocalAssetState, photoId: String?) = DeviceAssetEntity( + assetKey = "b/$id", + deviceAlbumId = "b", + mediaStoreId = id, + uri = "content://media/external/images/media/$id", + displayName = "IMG_$id.jpg", + mimeType = "image/jpeg", + size = 0L, + dateModified = 0L, + contentHash = null, + serverPhotoId = photoId, + uploadedAt = null, + state = state.name, + ) + + @Test + fun `copies mode keeps the local original`() { + val map = serverOriginalsByMediaId(listOf(asset(1, LocalAssetState.UPLOADED, "p1"))) + assertTrue("mode B must not fetch the server original", map.isEmpty()) + } + + @Test + fun `awaiting free keeps the local original`() { + val map = serverOriginalsByMediaId(listOf(asset(1, LocalAssetState.AWAITING_FREE, "p1"))) + assertTrue(map.isEmpty()) + } + + @Test + fun `preview-only asset uses the server original`() { + val map = serverOriginalsByMediaId(listOf(asset(1, LocalAssetState.PREVIEW, "p1"))) + assertEquals("p1", map[1L]) + } + + @Test + fun `asset without a server photo is ignored`() { + val map = serverOriginalsByMediaId(listOf(asset(1, LocalAssetState.PREVIEW, null))) + assertTrue(map.isEmpty()) + } +} diff --git a/android/app/src/test/java/com/galo/android/ui/viewer/ZoomImageRequestTest.kt b/android/app/src/test/java/com/galo/android/ui/viewer/ZoomImageRequestTest.kt index 9e2b160..8b265e1 100644 --- a/android/app/src/test/java/com/galo/android/ui/viewer/ZoomImageRequestTest.kt +++ b/android/app/src/test/java/com/galo/android/ui/viewer/ZoomImageRequestTest.kt @@ -4,6 +4,7 @@ import androidx.test.core.app.ApplicationProvider import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @@ -35,4 +36,28 @@ class ZoomImageRequestTest { assertNull(request.placeholderMemoryCacheKey) } + + @Test + fun `zooming a local original keeps the fitted preview as a placeholder`() { + val request = zoomImageRequest( + context, + preview, + preview, + zoomSize = coil.size.Size(100, 100), + ) + + assertNotNull( + "zooming must not flash black", + request.placeholderMemoryCacheKey, + ) + assertEquals("https://galo.test/api/objects/preview.jpg", request.placeholderMemoryCacheKey?.key) + assertEquals("https://galo.test/api/objects/preview.jpg#full", request.memoryCacheKey?.key) + } + + @Test + fun `the zoom decode size is capped`() { + assertNull("unmeasured viewport has no target", zoomBitmapSize(androidx.compose.ui.unit.IntSize(0, 0))) + assertNotNull(zoomBitmapSize(androidx.compose.ui.unit.IntSize(1080, 2340))) + assertNotNull(zoomBitmapSize(androidx.compose.ui.unit.IntSize(400, 800))) + } } diff --git a/backend/internal/app/photo_checksum_test.go b/backend/internal/app/photo_checksum_test.go new file mode 100644 index 0000000..8b8bfb6 --- /dev/null +++ b/backend/internal/app/photo_checksum_test.go @@ -0,0 +1,72 @@ +package app + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "galo/internal/auth" + "galo/internal/models" + "galo/internal/photos" +) + +// The original object's content checksum is exposed on photo data so clients can +// match a local file against a server photo and avoid re-uploading it. +func TestPhotoResponsesExposeOriginalChecksum(t *testing.T) { + ctx := context.Background() + r, token, uid, repos := newAvatarServer(t) + + album := &models.Album{ID: auth.NewID(), OwnerID: uid, Title: "A"} + if err := repos.Albums.Create(ctx, album); err != nil { + t.Fatalf("create album: %v", err) + } + if err := repos.Members.Add(ctx, album.ID, uid, "editor", nil); err != nil { + t.Fatalf("add owner member: %v", err) + } + + obj := &photos.Object{ + ID: auth.NewID(), + StorageKey: "objects/" + auth.NewID(), + MimeType: "image/jpeg", + Checksum: "sha256-photo-a", + State: photos.ObjectPending, + } + p := &models.Photo{ID: auth.NewID(), AlbumID: album.ID, UploaderID: uid, OriginalFilename: "c.jpg"} + if err := repos.Photos.CreateWithOriginal(ctx, p, obj); err != nil { + t.Fatalf("create photo: %v", err) + } + if err := repos.Photos.ActivateObject(ctx, obj.ID); err != nil { + t.Fatalf("activate photo object: %v", err) + } + + // Single photo. + rec := doAuthed(r, http.MethodGet, "/api/photos/"+p.ID, nil, "", token) + if rec.Code != http.StatusOK { + t.Fatalf("get photo = %d body=%s", rec.Code, rec.Body.String()) + } + var one struct { + Photo models.Photo `json:"photo"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &one); err != nil { + t.Fatalf("decode photo: %v", err) + } + if one.Photo.OriginalChecksum != "sha256-photo-a" { + t.Fatalf("photo original_checksum = %q, want sha256-photo-a", one.Photo.OriginalChecksum) + } + + // Album listing. + rec = doAuthed(r, http.MethodGet, "/api/albums/"+album.ID+"/photos", nil, "", token) + if rec.Code != http.StatusOK { + t.Fatalf("list photos = %d body=%s", rec.Code, rec.Body.String()) + } + var list struct { + Photos []models.Photo `json:"photos"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &list); err != nil { + t.Fatalf("decode list: %v", err) + } + if len(list.Photos) != 1 || list.Photos[0].OriginalChecksum != "sha256-photo-a" { + t.Fatalf("list original_checksum = %+v, want one photo with sha256-photo-a", list.Photos) + } +} diff --git a/backend/internal/models/models.go b/backend/internal/models/models.go index 76d2352..f2cc85d 100644 --- a/backend/internal/models/models.go +++ b/backend/internal/models/models.go @@ -45,19 +45,19 @@ func (u *User) Self() SelfUser { } type Album struct { - ID string `json:"id"` - OwnerID string `json:"owner_id"` - Title string `json:"title"` - Description string `json:"description"` - CoverPhotoID *string `json:"cover_photo_id,omitempty"` - CoverThumbKey string `json:"-"` - CoverThumbURL string `json:"cover_thumb_url,omitempty"` - CoverMediumKey string `json:"-"` - CoverMediumURL string `json:"cover_medium_url,omitempty"` - PhotosCount int `json:"photos_count"` - MembersCount int `json:"members_count"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id"` + OwnerID string `json:"owner_id"` + Title string `json:"title"` + Description string `json:"description"` + CoverPhotoID *string `json:"cover_photo_id,omitempty"` + CoverThumbKey string `json:"-"` + CoverThumbURL string `json:"cover_thumb_url,omitempty"` + CoverMediumKey string `json:"-"` + CoverMediumURL string `json:"cover_medium_url,omitempty"` + PhotosCount int `json:"photos_count"` + MembersCount int `json:"members_count"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type AlbumMember struct { @@ -82,40 +82,41 @@ type Invite struct { } type Photo struct { - ID string `json:"id"` - AlbumID string `json:"album_id"` - UploaderID string `json:"uploader_id"` - StorageKey string `json:"-"` - ThumbKey string `json:"-"` - MediumKey string `json:"-"` - PreviewKey string `json:"-"` - PlaybackKey string `json:"-"` - OriginalFilename string `json:"original_filename"` - Size int64 `json:"size"` - Width int `json:"width"` - Height int `json:"height"` - Kind string `json:"kind"` - DurationMS int64 `json:"duration_ms,omitempty"` - MimeType string `json:"mime_type"` - TakenAt *time.Time `json:"taken_at,omitempty"` - Latitude *float64 `json:"latitude,omitempty"` - Longitude *float64 `json:"longitude,omitempty"` + ID string `json:"id"` + AlbumID string `json:"album_id"` + UploaderID string `json:"uploader_id"` + StorageKey string `json:"-"` + ThumbKey string `json:"-"` + MediumKey string `json:"-"` + PreviewKey string `json:"-"` + PlaybackKey string `json:"-"` + OriginalFilename string `json:"original_filename"` + Size int64 `json:"size"` + Width int `json:"width"` + Height int `json:"height"` + Kind string `json:"kind"` + DurationMS int64 `json:"duration_ms,omitempty"` + MimeType string `json:"mime_type"` + OriginalChecksum string `json:"original_checksum,omitempty"` + TakenAt *time.Time `json:"taken_at,omitempty"` + Latitude *float64 `json:"latitude,omitempty"` + Longitude *float64 `json:"longitude,omitempty"` Caption string `json:"caption"` EXIF map[string]any `json:"exif,omitempty"` Tags []Tag `json:"tags,omitempty"` DeletedAt *time.Time `json:"deleted_at,omitempty"` AlbumTitle string `json:"album_title,omitempty"` CreatedAt time.Time `json:"created_at"` - Uploader PublicUser `json:"uploader"` - LikesCount int `json:"likes_count"` - CommentsCount int `json:"comments_count"` - LikedByMe bool `json:"liked_by_me"` - ThumbURL string `json:"thumb_url,omitempty"` - MediumURL string `json:"medium_url,omitempty"` - PreviewURL string `json:"preview_url,omitempty"` - PlaybackURL string `json:"playback_url,omitempty"` - OriginalURL string `json:"original_url,omitempty"` - DownloadURL string `json:"download_url,omitempty"` + Uploader PublicUser `json:"uploader"` + LikesCount int `json:"likes_count"` + CommentsCount int `json:"comments_count"` + LikedByMe bool `json:"liked_by_me"` + ThumbURL string `json:"thumb_url,omitempty"` + MediumURL string `json:"medium_url,omitempty"` + PreviewURL string `json:"preview_url,omitempty"` + PlaybackURL string `json:"playback_url,omitempty"` + OriginalURL string `json:"original_url,omitempty"` + DownloadURL string `json:"download_url,omitempty"` } type Tag struct { diff --git a/backend/internal/photos/sqlite_store.go b/backend/internal/photos/sqlite_store.go index c3b549b..58bbbdf 100644 --- a/backend/internal/photos/sqlite_store.go +++ b/backend/internal/photos/sqlite_store.go @@ -25,7 +25,7 @@ const sqlitePhotoColumns = `p.id, COALESCE(p.album_id, ''), p.uploader_id, COALESCE(o_m.storage_key, ''), COALESCE(o_p.storage_key, ''), COALESCE(o_pb.storage_key, ''), - p.original_filename, COALESCE(o_o.size, 0), COALESCE(o_o.width, 0), COALESCE(o_o.height, 0), COALESCE(o_o.mime_type, ''), + p.original_filename, COALESCE(o_o.size, 0), COALESCE(o_o.width, 0), COALESCE(o_o.height, 0), COALESCE(o_o.mime_type, ''), COALESCE(o_o.checksum, ''), p.kind, COALESCE(p.duration_ms, 0), p.taken_at, p.latitude, p.longitude, COALESCE(p.caption, ''), p.created_at, u.id, u.name, COALESCE(u.avatar_url, ''), @@ -58,7 +58,7 @@ func scanSQLitePhoto(scan func(...any) error) (*models.Photo, error) { var liked int if err := scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey, &p.ThumbKey, &p.MediumKey, &p.PreviewKey, &p.PlaybackKey, - &p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType, + &p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType, &p.OriginalChecksum, &p.Kind, &p.DurationMS, &takenAt, &lat, &long, &p.Caption, &createdAt, &p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL, @@ -283,7 +283,7 @@ func (s *SQLiteStore) ListTrash(ctx context.Context, userID string) ([]models.Ph var liked int if err := rows.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey, &p.ThumbKey, &p.MediumKey, &p.PreviewKey, &p.PlaybackKey, - &p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType, + &p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType, &p.OriginalChecksum, &p.Kind, &p.DurationMS, &takenAt, &lat, &long, &p.Caption, &createdAt, &p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL, diff --git a/backend/internal/photos/store.go b/backend/internal/photos/store.go index e6ab2b2..7cde847 100644 --- a/backend/internal/photos/store.go +++ b/backend/internal/photos/store.go @@ -32,7 +32,7 @@ const photoColumns = `p.id, COALESCE(p.album_id, ''), p.uploader_id, COALESCE(o_m.storage_key, ''), COALESCE(o_p.storage_key, ''), COALESCE(o_pb.storage_key, ''), - p.original_filename, COALESCE(o_o.size, 0), COALESCE(o_o.width, 0), COALESCE(o_o.height, 0), COALESCE(o_o.mime_type, ''), + p.original_filename, COALESCE(o_o.size, 0), COALESCE(o_o.width, 0), COALESCE(o_o.height, 0), COALESCE(o_o.mime_type, ''), COALESCE(o_o.checksum, ''), p.kind, COALESCE(p.duration_ms, 0), p.taken_at, p.latitude, p.longitude, COALESCE(p.caption, ''), p.created_at, u.id, u.name, COALESCE(u.avatar_url, ''), @@ -61,7 +61,7 @@ func scanPhoto(row pgx.Row) (*models.Photo, error) { var exifText, tagsText string err := row.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey, &p.ThumbKey, &p.MediumKey, &p.PreviewKey, &p.PlaybackKey, - &p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType, + &p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType, &p.OriginalChecksum, &p.Kind, &p.DurationMS, &p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt, &p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL, @@ -197,7 +197,7 @@ func (s *Store) List(ctx context.Context, in ListParams) ([]models.Photo, error) var exifText, tagsText string if err := rows.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey, &p.ThumbKey, &p.MediumKey, &p.PreviewKey, &p.PlaybackKey, - &p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType, + &p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType, &p.OriginalChecksum, &p.Kind, &p.DurationMS, &p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt, &p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL, @@ -312,7 +312,7 @@ func (s *Store) ListTrash(ctx context.Context, userID string) ([]models.Photo, e var exifText, tagsText string if err := rows.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey, &p.ThumbKey, &p.MediumKey, &p.PreviewKey, &p.PlaybackKey, - &p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType, + &p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType, &p.OriginalChecksum, &p.Kind, &p.DurationMS, &p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt, &p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL, diff --git a/backend/internal/repo/contract/contract.go b/backend/internal/repo/contract/contract.go index ca0cf29..3d947e6 100644 --- a/backend/internal/repo/contract/contract.go +++ b/backend/internal/repo/contract/contract.go @@ -494,6 +494,43 @@ func testPhotos(t *testing.T, ctx context.Context, r Repos) { t.Fatalf("album should exist") } + // The original object's content checksum is exposed so clients can match a + // local file against a server photo. Identical content yields identical + // checksums. + csObjA := &photos.Object{ID: newID(), StorageKey: "objects/" + newID(), MimeType: "image/jpeg", Checksum: "sha256-same", State: photos.ObjectPending} + csA := &models.Photo{ID: newID(), AlbumID: a.ID, UploaderID: owner.ID, OriginalFilename: "checksum-a.jpg"} + if err := r.Photos.CreateWithOriginal(ctx, csA, csObjA); err != nil { + t.Fatalf("create checksum photo: %v", err) + } + if err := r.Photos.ActivateObject(ctx, csObjA.ID); err != nil { + t.Fatalf("activate checksum object: %v", err) + } + csObjB := &photos.Object{ID: newID(), StorageKey: "objects/" + newID(), MimeType: "image/jpeg", Checksum: "sha256-same", State: photos.ObjectPending} + csB := &models.Photo{ID: newID(), AlbumID: a.ID, UploaderID: owner.ID, OriginalFilename: "checksum-b.jpg"} + if err := r.Photos.CreateWithOriginal(ctx, csB, csObjB); err != nil { + t.Fatalf("create checksum photo 2: %v", err) + } + if err := r.Photos.ActivateObject(ctx, csObjB.ID); err != nil { + t.Fatalf("activate checksum object 2: %v", err) + } + checked, err := r.Photos.GetByID(ctx, csA.ID, "") + if err != nil || checked.OriginalChecksum != "sha256-same" { + t.Fatalf("original checksum via get = %q err=%v", checked.OriginalChecksum, err) + } + byList, err := r.Photos.List(ctx, photos.ListParams{AlbumID: a.ID, Limit: 10}) + if err != nil { + t.Fatalf("list with checksums: %v", err) + } + same := 0 + for _, ph := range byList { + if ph.OriginalChecksum == "sha256-same" { + same++ + } + } + if same != 2 { + t.Fatalf("photos with identical content checksum in list = %d, want 2", same) + } + if err := r.Photos.HardDelete(ctx, p.ID); err != nil { t.Fatalf("hard delete: %v", err) }