Merge commit '467a42a' into develop
This commit is contained in:
+3
-1
@@ -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/
|
||||
|
||||
@@ -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 `→ <server album>`, the
|
||||
server album shows `источник: <device albums>`.
|
||||
|
||||
- **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
|
||||
|
||||
@@ -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<Uri>()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
context = ApplicationProvider.getApplicationContext()
|
||||
resolver = context.contentResolver
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
created.forEach { runCatching { resolver.delete(it, null, null) } }
|
||||
}
|
||||
|
||||
private fun insertImage(): Pair<Uri, String> {
|
||||
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<GaloApplication>()
|
||||
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<GaloApplication>()
|
||||
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<GaloApplication>()
|
||||
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<GaloApplication>()
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -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<PhotoDto> {
|
||||
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<com.galo.android.data.network.dto.PhotoListDto>(body).photos
|
||||
}
|
||||
}
|
||||
|
||||
fun awaitPhotoWithPlayback(token: String, albumId: String, timeoutMs: Long): PhotoDto {
|
||||
val deadline = System.currentTimeMillis() + timeoutMs
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<GaloApplication>()
|
||||
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<GaloApplication>()
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Uri>()
|
||||
|
||||
@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<Int, Int> =
|
||||
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<Uri, String> {
|
||||
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<Uri, String> {
|
||||
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<String>()
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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<GaloApplication>()
|
||||
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<GaloApplication>()
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,20 @@
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<!-- Device albums (MediaStore): read images/videos to enumerate albums and
|
||||
read files. Legacy permission covers Android 12 and lower. -->
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
|
||||
<uses-permission
|
||||
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="32" />
|
||||
|
||||
<!-- Lets mode C replace originals in the gallery without the per-batch OS
|
||||
write-consent dialog. Granted by the user in system settings. -->
|
||||
<uses-permission
|
||||
android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
|
||||
tools:ignore="ScopedStorage" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
@@ -17,6 +31,8 @@
|
||||
<application
|
||||
android:name=".GaloApplication"
|
||||
android:allowBackup="false"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:usesCleartextTraffic="true"
|
||||
|
||||
@@ -5,9 +5,12 @@ import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import coil.ImageLoader
|
||||
import coil.ImageLoaderFactory
|
||||
import coil.decode.VideoFrameDecoder
|
||||
import com.galo.android.di.AppContainer
|
||||
|
||||
class GaloApplication : Application() {
|
||||
class GaloApplication : Application(), ImageLoaderFactory {
|
||||
|
||||
lateinit var container: AppContainer
|
||||
private set
|
||||
@@ -18,6 +21,16 @@ class GaloApplication : Application() {
|
||||
createNotificationChannels()
|
||||
}
|
||||
|
||||
/**
|
||||
* Coil loader that can also render video frames, so device-album covers and
|
||||
* grid thumbnails work for albums that contain only videos.
|
||||
*/
|
||||
override fun newImageLoader(): ImageLoader =
|
||||
ImageLoader.Builder(this)
|
||||
.components { add(VideoFrameDecoder.Factory()) }
|
||||
.crossfade(true)
|
||||
.build()
|
||||
|
||||
private fun createNotificationChannels() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.galo.android.data.auth
|
||||
|
||||
import android.content.Context
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKeys
|
||||
|
||||
/**
|
||||
* Persists tokens in Keystore-backed [EncryptedSharedPreferences] so they never
|
||||
* land in plain SharedPreferences.
|
||||
*/
|
||||
class SecureTokenStore(context: Context) : TokenStore {
|
||||
|
||||
private val prefs by lazy {
|
||||
val masterKey = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC)
|
||||
EncryptedSharedPreferences.create(
|
||||
FILE_NAME,
|
||||
masterKey,
|
||||
context,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
}
|
||||
|
||||
override fun accessToken(): String? = prefs.getString(KEY_ACCESS, null)
|
||||
|
||||
override fun refreshToken(): String? = prefs.getString(KEY_REFRESH, null)
|
||||
|
||||
override fun save(accessToken: String, refreshToken: String) {
|
||||
prefs.edit()
|
||||
.putString(KEY_ACCESS, accessToken)
|
||||
.putString(KEY_REFRESH, refreshToken)
|
||||
.apply()
|
||||
}
|
||||
|
||||
override fun clear() {
|
||||
prefs.edit().clear().apply()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val FILE_NAME = "galo_session"
|
||||
const val KEY_ACCESS = "access_token"
|
||||
const val KEY_REFRESH = "refresh_token"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.galo.android.data.auth
|
||||
|
||||
/** Token persistence abstraction; the production implementation is Keystore-backed. */
|
||||
interface TokenStore {
|
||||
fun accessToken(): String?
|
||||
fun refreshToken(): String?
|
||||
fun save(accessToken: String, refreshToken: String)
|
||||
fun clear()
|
||||
}
|
||||
|
||||
class InMemoryTokenStore : TokenStore {
|
||||
@Volatile
|
||||
private var access: String? = null
|
||||
|
||||
@Volatile
|
||||
private var refresh: String? = null
|
||||
|
||||
override fun accessToken(): String? = access
|
||||
override fun refreshToken(): String? = refresh
|
||||
|
||||
override fun save(accessToken: String, refreshToken: String) {
|
||||
access = accessToken
|
||||
refresh = refreshToken
|
||||
}
|
||||
|
||||
override fun clear() {
|
||||
access = null
|
||||
refresh = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.galo.android.data.db
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Database
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import com.galo.android.data.db.dao.AlbumDao
|
||||
import com.galo.android.data.db.dao.PhotoDao
|
||||
import com.galo.android.data.db.dao.SyncDao
|
||||
import com.galo.android.data.db.dao.UploadDao
|
||||
import com.galo.android.data.db.entity.AlbumEntity
|
||||
import com.galo.android.data.db.entity.DeviceAssetEntity
|
||||
import com.galo.android.data.db.entity.PhotoEntity
|
||||
import com.galo.android.data.db.entity.SyncedAlbumEntity
|
||||
import com.galo.android.data.db.entity.UploadJobEntity
|
||||
|
||||
@Database(
|
||||
entities = [
|
||||
AlbumEntity::class,
|
||||
PhotoEntity::class,
|
||||
UploadJobEntity::class,
|
||||
SyncedAlbumEntity::class,
|
||||
DeviceAssetEntity::class,
|
||||
],
|
||||
version = 4,
|
||||
exportSchema = false,
|
||||
)
|
||||
abstract class GaloDatabase : RoomDatabase() {
|
||||
|
||||
abstract fun albumDao(): AlbumDao
|
||||
|
||||
abstract fun photoDao(): PhotoDao
|
||||
|
||||
abstract fun uploadDao(): UploadDao
|
||||
|
||||
abstract fun syncDao(): SyncDao
|
||||
|
||||
companion object {
|
||||
private const val NAME = "galo.db"
|
||||
|
||||
fun create(context: Context): GaloDatabase =
|
||||
Room.databaseBuilder(context.applicationContext, GaloDatabase::class.java, NAME)
|
||||
.fallbackToDestructiveMigration()
|
||||
.build()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.galo.android.data.db.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import androidx.room.Upsert
|
||||
import com.galo.android.data.db.entity.AlbumEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface AlbumDao {
|
||||
|
||||
@Query("SELECT * FROM albums ORDER BY updatedAt DESC, title ASC")
|
||||
fun observeAll(): Flow<List<AlbumEntity>>
|
||||
|
||||
@Query("SELECT * FROM albums ORDER BY updatedAt DESC, title ASC")
|
||||
suspend fun getAll(): List<AlbumEntity>
|
||||
|
||||
@Upsert
|
||||
suspend fun upsertAll(albums: List<AlbumEntity>)
|
||||
|
||||
@Query("DELETE FROM albums WHERE id IN (:ids)")
|
||||
suspend fun deleteByIds(ids: List<String>)
|
||||
|
||||
@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<AlbumEntity>) {
|
||||
clear()
|
||||
upsertAll(albums)
|
||||
}
|
||||
}
|
||||
@@ -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<List<PhotoEntity>>
|
||||
|
||||
@Query("SELECT * FROM photos WHERE albumId = :albumId ORDER BY createdAt DESC, id DESC")
|
||||
suspend fun getForAlbum(albumId: String): List<PhotoEntity>
|
||||
|
||||
@Query("SELECT * FROM photos WHERE id = :id")
|
||||
suspend fun getById(id: String): PhotoEntity?
|
||||
|
||||
@Upsert
|
||||
suspend fun upsertAll(photos: List<PhotoEntity>)
|
||||
|
||||
@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
|
||||
}
|
||||
@@ -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<List<SyncedAlbumEntity>>
|
||||
|
||||
@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<SyncedAlbumEntity>
|
||||
|
||||
@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<List<DeviceAssetEntity>>
|
||||
|
||||
@Query("SELECT * FROM device_assets WHERE deviceAlbumId = :deviceAlbumId")
|
||||
suspend fun getAssets(deviceAlbumId: String): List<DeviceAssetEntity>
|
||||
|
||||
@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<DeviceAssetEntity>
|
||||
|
||||
@Query(
|
||||
"SELECT DISTINCT contentHash FROM device_assets " +
|
||||
"WHERE deviceAlbumId != :deviceAlbumId AND contentHash IS NOT NULL",
|
||||
)
|
||||
suspend fun hashesOutside(deviceAlbumId: String): List<String>
|
||||
|
||||
@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<DeviceAssetEntity>)
|
||||
|
||||
@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)
|
||||
}
|
||||
@@ -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<List<UploadJobEntity>>
|
||||
|
||||
@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<UploadJobEntity>
|
||||
|
||||
@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()
|
||||
}
|
||||
@@ -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?,
|
||||
)
|
||||
@@ -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?,
|
||||
)
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<DeviceMedia> {
|
||||
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<DeviceMedia>(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<DeviceMedia>): List<DeviceAlbum> =
|
||||
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<DeviceAlbum> = DeviceMediaMapping.albums(allMedia())
|
||||
|
||||
fun items(albumId: String): List<DeviceMedia> =
|
||||
allMedia().filter { it.albumId == albumId }
|
||||
|
||||
private fun allMedia(): List<DeviceMedia> =
|
||||
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<DeviceMedia> =
|
||||
runCatching {
|
||||
context.contentResolver
|
||||
.query(collection, DeviceMediaMapping.projection, null, null, null)
|
||||
?.use { cursor -> DeviceMediaMapping.mediaFromCursor(cursor, collection, kind) }
|
||||
?: emptyList()
|
||||
}.getOrDefault(emptyList())
|
||||
}
|
||||
@@ -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<Int, Int> {
|
||||
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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<PhotoDto>(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()
|
||||
}
|
||||
@@ -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<AlbumEvent> = channelFlow {
|
||||
val factory = EventSources.createFactory(client)
|
||||
var backoff = INITIAL_BACKOFF_MS
|
||||
|
||||
while (true) {
|
||||
val request = runCatching { api.sseRequest(albumId) }.getOrNull() ?: break
|
||||
val closed = CompletableDeferred<Unit>()
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<ErrorEnvelopeDto>(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"
|
||||
}
|
||||
}
|
||||
@@ -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<TokenEnvelopeDto>()
|
||||
.tokens
|
||||
|
||||
suspend fun login(identifier: String, password: String): AuthTokensDto =
|
||||
postJson("api/auth/login", json.encodeToString(LoginRequest(identifier, password)))
|
||||
.decode<TokenEnvelopeDto>()
|
||||
.tokens
|
||||
|
||||
override suspend fun me(): UserDto =
|
||||
get("api/auth/me").decode<UserEnvelopeDto>().user
|
||||
|
||||
override suspend fun updateProfile(name: String): UserDto =
|
||||
patchJson("api/auth/me", json.encodeToString(UpdateProfileRequest(name)))
|
||||
.decode<UserEnvelopeDto>()
|
||||
.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<UserEnvelopeDto>()
|
||||
.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<UserEnvelopeDto>().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<UserEnvelopeDto>()
|
||||
.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<AlbumDto> =
|
||||
get("api/albums").decode<AlbumListDto>().albums
|
||||
|
||||
suspend fun album(albumId: String): AlbumDto =
|
||||
get("api/albums/$albumId").decode<AlbumEnvelopeDto>().album
|
||||
|
||||
/** Creates a server album and returns it. */
|
||||
suspend fun createAlbum(title: String): AlbumDto =
|
||||
postJson("api/albums", json.encodeToString(CreateAlbumRequest(title)))
|
||||
.decode<AlbumEnvelopeDto>()
|
||||
.album
|
||||
|
||||
suspend fun photo(photoId: String): PhotoDto =
|
||||
get("api/photos/$photoId").decode<PhotoEnvelopeDto>().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<UploadResponseDto>().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<PhotoEnvelopeDto>()
|
||||
.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<String>, 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<String, String> {
|
||||
val out = HashMap<String, String>()
|
||||
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 <reified T> HttpResult.decode(): T {
|
||||
val text = body ?: throw ApiException(
|
||||
ApiError("empty_response", "empty response body", status),
|
||||
)
|
||||
return runCatching { json.decodeFromString<T>(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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<HealthDto>(body) }
|
||||
.getOrElse { throw IOException("malformed health response") }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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/<key>?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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<TokenEnvelopeDto>(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
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
@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<AlbumDto> = 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<TagDto> = 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<PhotoDto> = emptyList(),
|
||||
@SerialName("next_cursor") val nextCursor: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PhotoEnvelopeDto(
|
||||
val photo: PhotoDto,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class UploadResponseDto(
|
||||
val photos: List<PhotoDto> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ErrorEnvelopeDto(
|
||||
val error: ErrorBodyDto? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ErrorBodyDto(
|
||||
val code: String = "internal_error",
|
||||
val message: String = "internal server error",
|
||||
)
|
||||
@@ -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<Preferences> 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<SettingsState> = 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")
|
||||
}
|
||||
}
|
||||
@@ -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<List<Album>> =
|
||||
local.observeEntities().map { entities -> entities.map { it.toDomain(resolver) } }
|
||||
|
||||
fun observeAlbum(albumId: String): Flow<Album?> =
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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<List<Photo>> =
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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<AlbumDto>
|
||||
suspend fun fetchAlbum(albumId: String): AlbumDto
|
||||
}
|
||||
|
||||
interface AlbumLocalSource {
|
||||
fun observeEntities(): Flow<List<AlbumEntity>>
|
||||
suspend fun replaceAll(albums: List<AlbumEntity>)
|
||||
suspend fun upsert(albums: List<AlbumEntity>)
|
||||
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<List<PhotoEntity>>
|
||||
suspend fun upsertAll(photos: List<PhotoEntity>)
|
||||
suspend fun deleteById(id: String)
|
||||
suspend fun countForAlbum(albumId: String): Int
|
||||
}
|
||||
|
||||
class ApiAlbumRemoteSource(private val api: GaloApi) : AlbumRemoteSource {
|
||||
override suspend fun fetchAlbums(): List<AlbumDto> = api.albums()
|
||||
override suspend fun fetchAlbum(albumId: String): AlbumDto = api.album(albumId)
|
||||
}
|
||||
|
||||
class RoomAlbumLocalSource(private val dao: AlbumDao) : AlbumLocalSource {
|
||||
override fun observeEntities(): Flow<List<AlbumEntity>> = dao.observeAll()
|
||||
override suspend fun replaceAll(albums: List<AlbumEntity>) = dao.replaceAll(albums)
|
||||
override suspend fun upsert(albums: List<AlbumEntity>) = 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<List<PhotoEntity>> =
|
||||
dao.observeForAlbum(albumId)
|
||||
|
||||
override suspend fun upsertAll(photos: List<PhotoEntity>) = dao.upsertAll(photos)
|
||||
|
||||
override suspend fun deleteById(id: String) = dao.deleteById(id)
|
||||
|
||||
override suspend fun countForAlbum(albumId: String): Int = dao.countForAlbum(albumId)
|
||||
}
|
||||
@@ -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<List<SyncedAlbumEntity>> = dao.observeSyncedAlbums()
|
||||
|
||||
fun observeAssets(deviceAlbumId: String): Flow<List<DeviceAssetEntity>> =
|
||||
dao.observeAssets(deviceAlbumId)
|
||||
|
||||
suspend fun assets(deviceAlbumId: String): List<DeviceAssetEntity> = dao.getAssets(deviceAlbumId)
|
||||
|
||||
suspend fun getAsset(assetKey: String): DeviceAssetEntity? = dao.getAsset(assetKey)
|
||||
|
||||
suspend fun currentLink(deviceAlbumId: String): SyncedAlbumEntity? =
|
||||
dao.getSyncedAlbum(deviceAlbumId)
|
||||
|
||||
fun deviceAlbums(): List<DeviceAlbum> = 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,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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<String, String> =
|
||||
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<String>, serverAlbumId: String) {
|
||||
api.movePhotos(photoIds, serverAlbumId)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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<LocalAsset>
|
||||
|
||||
/** Content hashes still present in other albums linked to the same server. */
|
||||
suspend fun hashesOutside(deviceAlbumId: String): Set<String>
|
||||
|
||||
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<LocalAsset> = 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<String> =
|
||||
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()
|
||||
}
|
||||
@@ -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<Uri>) : OffloadResult
|
||||
data class NeedsDeleteConsent(val uris: List<Uri>) : 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<Uri>): 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<Uri>): 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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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<DeviceAssetEntity>): 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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<LocalAsset>) {
|
||||
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<String>, serverAlbumId: String) {
|
||||
if (photoIds.isEmpty()) return
|
||||
remote.movePhotos(photoIds, serverAlbumId)
|
||||
}
|
||||
|
||||
private suspend fun apply(
|
||||
deviceAlbumId: String,
|
||||
serverAlbumId: String,
|
||||
mode: SyncMode,
|
||||
action: SyncAction,
|
||||
livePhotoIds: Set<String>,
|
||||
) {
|
||||
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,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<LocalAsset>,
|
||||
index: List<IndexedAsset>,
|
||||
serverChecksums: Map<String, String>,
|
||||
hashesPresentElsewhere: Set<String>,
|
||||
): List<SyncAction> {
|
||||
val actions = mutableListOf<SyncAction>()
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<String, String>
|
||||
|
||||
suspend fun renamePhoto(photoId: String, newName: String)
|
||||
|
||||
suspend fun deletePhoto(photoId: String)
|
||||
|
||||
suspend fun movePhotos(photoIds: List<String>, serverAlbumId: String)
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<SyncWorker>(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<SyncWorker>()
|
||||
.addTag(TAG_SYNC)
|
||||
.build()
|
||||
WorkManager.getInstance(context).enqueueUniqueWork(
|
||||
UNIQUE_NOW,
|
||||
ExistingWorkPolicy.KEEP,
|
||||
request,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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<List<UploadJob>> =
|
||||
dao.observeAll().map { jobs -> jobs.map { it.toDomain() } }
|
||||
|
||||
suspend fun enqueue(albumId: String, uris: List<Uri>, 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<UploadWorker>()
|
||||
.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,
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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}"),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<String> =
|
||||
if (sdkInt >= Build.VERSION_CODES.TIRAMISU) {
|
||||
listOf(READ_MEDIA_IMAGES, READ_MEDIA_VIDEO)
|
||||
} else {
|
||||
listOf(READ_EXTERNAL_STORAGE)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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<String>,
|
||||
) : 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<Album>,
|
||||
links: List<AlbumLink>,
|
||||
devices: List<DeviceAlbum>,
|
||||
): List<AlbumListItem> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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 -> "перенос"
|
||||
}
|
||||
|
||||
@@ -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<Album> = emptyList(),
|
||||
val items: List<AlbumListItem> = 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<String?>(null)
|
||||
|
||||
private val deviceAlbums = MutableStateFlow<List<DeviceAlbum>>(emptyList())
|
||||
|
||||
private val deviceAccess = MutableStateFlow(false)
|
||||
|
||||
val state: StateFlow<UiState> = 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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<Int?>(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<SyncMode?>(null) }
|
||||
var confirmMode by remember { mutableStateOf<SyncMode?>(null) }
|
||||
var pickMode by remember { mutableStateOf<SyncMode?>(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<Album>,
|
||||
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)
|
||||
}
|
||||
@@ -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<DeviceMedia> = emptyList(),
|
||||
val mode: SyncMode = SyncMode.DISABLED,
|
||||
val serverAlbumId: String? = null,
|
||||
val serverAlbums: List<Album> = emptyList(),
|
||||
val awaitingFree: Int = 0,
|
||||
val previewCount: Int = 0,
|
||||
val serverPhotoByMediaId: Map<Long, String> = emptyMap(),
|
||||
val transferredMediaIds: Set<Long> = emptySet(),
|
||||
)
|
||||
|
||||
private data class Local(
|
||||
val name: String = "",
|
||||
val items: List<DeviceMedia> = emptyList(),
|
||||
)
|
||||
|
||||
private val local = MutableStateFlow(Local())
|
||||
|
||||
private val pendingMode = MutableStateFlow<SyncMode?>(null)
|
||||
|
||||
val state: StateFlow<UiState> = 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<Uri>()
|
||||
val deleteUris = mutableListOf<Uri>()
|
||||
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<DeviceAssetEntity>,
|
||||
): Map<Long, String> = assets
|
||||
.filter { it.state == LocalAssetState.PREVIEW.name && it.serverPhotoId != null }
|
||||
.associate { it.mediaStoreId to it.serverPhotoId!! }
|
||||
@@ -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<DeviceMedia>,
|
||||
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<OriginalSource?>(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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Halo brand mark: a sun disc with a separated outer ring, white on brand blue.
|
||||
Sized to sit inside the adaptive-icon safe zone (72dp of 108dp). -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M54,54 m-15.6,0 a15.6,15.6 0 1,0 31.2,0 a15.6,15.6 0 1,0 -31.2,0" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:strokeAlpha="0.6"
|
||||
android:strokeWidth="7.2"
|
||||
android:pathData="M54,54 m-28.8,0 a28.8,28.8 0 1,0 57.6,0 a28.8,28.8 0 1,0 -57.6,0" />
|
||||
</vector>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Brand primary (light theme) used as the adaptive icon background. -->
|
||||
<color name="ic_launcher_background">#2563EB</color>
|
||||
</resources>
|
||||
@@ -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<Context>()
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -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<Any?>): 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<Any?> = 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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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<Context>()
|
||||
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])
|
||||
}
|
||||
}
|
||||
@@ -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<Context>()
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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<Application>()
|
||||
// Must not throw; the value itself is provider-defined.
|
||||
MediaStoreGeneration.current(context)
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<Context>()
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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<Pair<String, String>>()
|
||||
val deletes = mutableListOf<String>()
|
||||
val moves = mutableListOf<Pair<List<String>, String>>()
|
||||
var serverMap: Map<String, String> = emptyMap()
|
||||
|
||||
override suspend fun serverChecksums(serverAlbumId: String): Map<String, String> {
|
||||
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<String>, serverAlbumId: String) {
|
||||
moves += photoIds to serverAlbumId
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeQueue : SyncUploadQueue {
|
||||
val enqueued = mutableListOf<Triple<String, String, LocalAsset>>()
|
||||
override suspend fun enqueue(
|
||||
deviceAlbumId: String,
|
||||
serverAlbumId: String,
|
||||
asset: LocalAsset,
|
||||
mode: SyncMode,
|
||||
) {
|
||||
enqueued += Triple(deviceAlbumId, serverAlbumId, asset)
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeProvider : LocalAssetProvider {
|
||||
var items: List<LocalAsset> = emptyList()
|
||||
var outside: Set<String> = emptySet()
|
||||
override suspend fun assets(deviceAlbumId: String): List<LocalAsset> = items
|
||||
override suspend fun hashesOutside(deviceAlbumId: String): Set<String> = outside
|
||||
override fun openStream(uri: String): InputStream = ByteArrayInputStream(ByteArray(4))
|
||||
}
|
||||
|
||||
private class FakeOffloader : AssetOffloader {
|
||||
val offloaded = mutableListOf<String>()
|
||||
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<Context>()
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user