Merge commit '467a42a' into develop
This commit is contained in:
+3
-1
@@ -15,7 +15,9 @@ __pycache__/
|
|||||||
# Local data backends (defaults)
|
# Local data backends (defaults)
|
||||||
galo.db
|
galo.db
|
||||||
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 build output and local toolchain config
|
||||||
android/.gradle/
|
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/sse/albums/:id?token=` and backfills missed changes via
|
||||||
`GET /api/albums/:id/photos?since=` after a reconnect.
|
`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
|
## Testing
|
||||||
|
|
||||||
```sh
|
```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 {
|
fun createAlbum(token: String, title: String): String { val payload = """{"title":"$title"}""".toRequestBody("application/json".toMediaType())
|
||||||
val payload = """{"title":"$title"}""".toRequestBody("application/json".toMediaType())
|
|
||||||
val request = Request.Builder()
|
val request = Request.Builder()
|
||||||
.url("$BASE_URL/api/albums")
|
.url("$BASE_URL/api/albums")
|
||||||
.header("Authorization", "Bearer $token")
|
.header("Authorization", "Bearer $token")
|
||||||
@@ -79,8 +78,7 @@ object E2E {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun upload(token: String, albumId: String, filename: String, bytes: ByteArray, mime: String) {
|
fun upload(token: String, albumId: String, filename: String, bytes: ByteArray, mime: String) { val multipart = MultipartBody.Builder()
|
||||||
val multipart = MultipartBody.Builder()
|
|
||||||
.setType(MultipartBody.FORM)
|
.setType(MultipartBody.FORM)
|
||||||
.addFormDataPart("files", filename, bytes.toRequestBody(mime.toMediaType()))
|
.addFormDataPart("files", filename, bytes.toRequestBody(mime.toMediaType()))
|
||||||
.build()
|
.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 {
|
fun awaitPhotoWithPlayback(token: String, albumId: String, timeoutMs: Long): PhotoDto {
|
||||||
val deadline = System.currentTimeMillis() + timeoutMs
|
val deadline = System.currentTimeMillis() + timeoutMs
|
||||||
while (System.currentTimeMillis() < deadline) {
|
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_ALBUM_CARD_PREFIX
|
||||||
import com.galo.android.ui.albums.TAG_ALBUMS_GRID
|
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_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_NAME
|
||||||
import com.galo.android.ui.auth.TAG_AUTH_PASSWORD
|
import com.galo.android.ui.auth.TAG_AUTH_PASSWORD
|
||||||
import com.galo.android.ui.auth.TAG_AUTH_SUBMIT
|
import com.galo.android.ui.auth.TAG_AUTH_SUBMIT
|
||||||
@@ -70,7 +71,7 @@ class E2EFlowTest {
|
|||||||
|
|
||||||
// Registration.
|
// Registration.
|
||||||
compose.waitUntil(TIMEOUT) {
|
compose.waitUntil(TIMEOUT) {
|
||||||
compose.onAllNodesWithTag(TAG_AUTH_EMAIL).fetchSemanticsNodes().isNotEmpty()
|
compose.onAllNodesWithTag(TAG_AUTH_IDENTIFIER).fetchSemanticsNodes().isNotEmpty()
|
||||||
}
|
}
|
||||||
compose.onNodeWithText("Создать аккаунт").performClick()
|
compose.onNodeWithText("Создать аккаунт").performClick()
|
||||||
compose.waitUntil(TIMEOUT) {
|
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.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<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.POST_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
@@ -17,6 +31,8 @@
|
|||||||
<application
|
<application
|
||||||
android:name=".GaloApplication"
|
android:name=".GaloApplication"
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:usesCleartextTraffic="true"
|
android:usesCleartextTraffic="true"
|
||||||
|
|||||||
@@ -5,9 +5,12 @@ import android.app.NotificationChannel
|
|||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import coil.ImageLoader
|
||||||
|
import coil.ImageLoaderFactory
|
||||||
|
import coil.decode.VideoFrameDecoder
|
||||||
import com.galo.android.di.AppContainer
|
import com.galo.android.di.AppContainer
|
||||||
|
|
||||||
class GaloApplication : Application() {
|
class GaloApplication : Application(), ImageLoaderFactory {
|
||||||
|
|
||||||
lateinit var container: AppContainer
|
lateinit var container: AppContainer
|
||||||
private set
|
private set
|
||||||
@@ -18,6 +21,16 @@ class GaloApplication : Application() {
|
|||||||
createNotificationChannels()
|
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() {
|
private fun createNotificationChannels() {
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||||
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
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.SecureTokenStore
|
||||||
import com.galo.android.data.auth.TokenStore
|
import com.galo.android.data.auth.TokenStore
|
||||||
import com.galo.android.data.db.GaloDatabase
|
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.AlbumSseClient
|
||||||
import com.galo.android.data.network.ApiClient
|
import com.galo.android.data.network.ApiClient
|
||||||
import com.galo.android.data.network.GaloApi
|
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.PhotoRepository
|
||||||
import com.galo.android.data.repo.RoomAlbumLocalSource
|
import com.galo.android.data.repo.RoomAlbumLocalSource
|
||||||
import com.galo.android.data.repo.RoomPhotoLocalSource
|
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 com.galo.android.data.upload.UploadRepository
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -63,13 +78,49 @@ class AppContainer(context: Context) {
|
|||||||
|
|
||||||
val uploadRepository = UploadRepository(appContext, database.uploadDao())
|
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() {
|
fun start() {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
settingsRepository.flow.collect { settingsState = it }
|
settingsRepository.flow.collect { settings ->
|
||||||
|
settingsState = settings
|
||||||
|
runCatching { SyncScheduler.ensurePeriodic(appContext, settings.wifiOnlyUploads) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
scope.launch {
|
scope.launch {
|
||||||
runCatching { uploadRepository.requeuePending() }
|
runCatching { uploadRepository.requeuePending() }
|
||||||
}
|
}
|
||||||
|
runCatching { SyncScheduler.runNow(appContext) }
|
||||||
|
runCatching {
|
||||||
|
DeviceMediaObserver(appContext) { runCatching { SyncScheduler.runNow(appContext) } }.start()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun currentSettings(): SettingsState? = settingsState
|
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.album.AlbumScreen
|
||||||
import com.galo.android.ui.albums.AlbumsScreen
|
import com.galo.android.ui.albums.AlbumsScreen
|
||||||
import com.galo.android.ui.auth.AuthScreen
|
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.profile.ProfileScreen
|
||||||
import com.galo.android.ui.setup.SetupScreen
|
import com.galo.android.ui.setup.SetupScreen
|
||||||
import com.galo.android.ui.upload.UploadScreen
|
import com.galo.android.ui.upload.UploadScreen
|
||||||
@@ -30,10 +31,13 @@ object Routes {
|
|||||||
const val UPLOADS = "uploads"
|
const val UPLOADS = "uploads"
|
||||||
const val PROFILE = "profile"
|
const val PROFILE = "profile"
|
||||||
const val ALBUM = "album/{albumId}"
|
const val ALBUM = "album/{albumId}"
|
||||||
|
const val DEVICE_ALBUM = "deviceAlbum/{deviceAlbumId}"
|
||||||
const val VIEWER = "album/{albumId}/viewer?index={index}"
|
const val VIEWER = "album/{albumId}/viewer?index={index}"
|
||||||
|
|
||||||
fun album(albumId: String) = "album/$albumId"
|
fun album(albumId: String) = "album/$albumId"
|
||||||
|
|
||||||
|
fun deviceAlbum(deviceAlbumId: String) = "deviceAlbum/$deviceAlbumId"
|
||||||
|
|
||||||
fun viewer(albumId: String, index: Int) = "album/$albumId/viewer?index=$index"
|
fun viewer(albumId: String, index: Int) = "album/$albumId/viewer?index=$index"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,6 +87,7 @@ fun GaloNavHost(app: GaloApplication) {
|
|||||||
AlbumsScreen(
|
AlbumsScreen(
|
||||||
factory = factory,
|
factory = factory,
|
||||||
onOpenAlbum = { id -> navController.navigate(Routes.album(id)) },
|
onOpenAlbum = { id -> navController.navigate(Routes.album(id)) },
|
||||||
|
onOpenDeviceAlbum = { id -> navController.navigate(Routes.deviceAlbum(id)) },
|
||||||
onOpenUploads = { navController.navigate(Routes.UPLOADS) },
|
onOpenUploads = { navController.navigate(Routes.UPLOADS) },
|
||||||
onOpenProfile = { navController.navigate(Routes.PROFILE) },
|
onOpenProfile = { navController.navigate(Routes.PROFILE) },
|
||||||
onLoggedOut = {
|
onLoggedOut = {
|
||||||
@@ -123,6 +128,17 @@ fun GaloNavHost(app: GaloApplication) {
|
|||||||
onBack = { navController.popBackStack() },
|
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(
|
composable(
|
||||||
route = Routes.VIEWER,
|
route = Routes.VIEWER,
|
||||||
arguments = listOf(
|
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.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
@@ -37,29 +38,40 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.galo.android.domain.model.Album
|
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.GaloViewModelFactory
|
||||||
import com.galo.android.ui.common.GaloAsyncImage
|
import com.galo.android.ui.common.GaloAsyncImage
|
||||||
|
import com.galo.android.ui.common.RequestMediaAccess
|
||||||
import com.galo.android.ui.common.UserAvatar
|
import com.galo.android.ui.common.UserAvatar
|
||||||
|
|
||||||
const val TAG_ALBUMS_GRID = "albums_grid"
|
const val TAG_ALBUMS_GRID = "albums_grid"
|
||||||
const val TAG_ALBUMS_OFFLINE = "albums_offline"
|
const val TAG_ALBUMS_OFFLINE = "albums_offline"
|
||||||
const val TAG_ALBUMS_AVATAR = "albums_avatar"
|
const val TAG_ALBUMS_AVATAR = "albums_avatar"
|
||||||
const val TAG_ALBUM_CARD_PREFIX = "album_card_"
|
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)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun AlbumsScreen(
|
fun AlbumsScreen(
|
||||||
factory: GaloViewModelFactory,
|
factory: GaloViewModelFactory,
|
||||||
onOpenAlbum: (String) -> Unit,
|
onOpenAlbum: (String) -> Unit,
|
||||||
|
onOpenDeviceAlbum: (String) -> Unit,
|
||||||
onOpenUploads: () -> Unit,
|
onOpenUploads: () -> Unit,
|
||||||
onOpenProfile: () -> Unit,
|
onOpenProfile: () -> Unit,
|
||||||
onLoggedOut: () -> Unit,
|
onLoggedOut: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val viewModel: AlbumsViewModel = viewModel(factory = factory)
|
val viewModel: AlbumsViewModel = viewModel(factory = factory)
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
// Ask for media access so device albums can be listed; reload once answered.
|
||||||
|
RequestMediaAccess { viewModel.refresh() }
|
||||||
AlbumsContent(
|
AlbumsContent(
|
||||||
state = state,
|
state = state,
|
||||||
onOpenAlbum = onOpenAlbum,
|
onOpenAlbum = onOpenAlbum,
|
||||||
|
onOpenDeviceAlbum = onOpenDeviceAlbum,
|
||||||
onOpenUploads = onOpenUploads,
|
onOpenUploads = onOpenUploads,
|
||||||
onOpenProfile = onOpenProfile,
|
onOpenProfile = onOpenProfile,
|
||||||
onRefresh = viewModel::refresh,
|
onRefresh = viewModel::refresh,
|
||||||
@@ -72,6 +84,7 @@ fun AlbumsScreen(
|
|||||||
fun AlbumsContent(
|
fun AlbumsContent(
|
||||||
state: AlbumsViewModel.UiState,
|
state: AlbumsViewModel.UiState,
|
||||||
onOpenAlbum: (String) -> Unit,
|
onOpenAlbum: (String) -> Unit,
|
||||||
|
onOpenDeviceAlbum: (String) -> Unit,
|
||||||
onOpenUploads: () -> Unit,
|
onOpenUploads: () -> Unit,
|
||||||
onOpenProfile: () -> Unit,
|
onOpenProfile: () -> Unit,
|
||||||
onRefresh: () -> Unit,
|
onRefresh: () -> Unit,
|
||||||
@@ -113,13 +126,13 @@ fun AlbumsContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
when {
|
when {
|
||||||
state.loading && state.albums.isEmpty() -> {
|
state.loading && state.items.isEmpty() -> {
|
||||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
CircularProgressIndicator()
|
CircularProgressIndicator()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
state.albums.isEmpty() -> {
|
state.items.isEmpty() -> {
|
||||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
Text("Пока нет альбомов")
|
Text("Пока нет альбомов")
|
||||||
}
|
}
|
||||||
@@ -133,8 +146,18 @@ fun AlbumsContent(
|
|||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
modifier = Modifier.fillMaxSize().testTag(TAG_ALBUMS_GRID),
|
modifier = Modifier.fillMaxSize().testTag(TAG_ALBUMS_GRID),
|
||||||
) {
|
) {
|
||||||
items(state.albums, key = { it.id }) { album ->
|
items(state.items, key = { it.key }) { item ->
|
||||||
AlbumCard(album = album, onClick = { onOpenAlbum(album.id) })
|
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,13 +167,75 @@ fun AlbumsContent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AlbumCard(album: Album, onClick: () -> Unit) {
|
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,
|
||||||
|
)
|
||||||
|
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 = link,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
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(
|
Card(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.testTag(TAG_ALBUM_CARD_PREFIX + album.id)
|
.testTag(key)
|
||||||
.clickable(onClick = onClick),
|
.clickable(onClick = onClick),
|
||||||
) {
|
) {
|
||||||
|
Column {
|
||||||
GaloAsyncImage(
|
GaloAsyncImage(
|
||||||
url = album.coverThumbUrl ?: album.coverMediumUrl,
|
url = album.coverThumbUrl ?: album.coverMediumUrl,
|
||||||
contentDescription = album.title,
|
contentDescription = album.title,
|
||||||
@@ -159,18 +244,51 @@ private fun AlbumCard(album: Album, onClick: () -> Unit) {
|
|||||||
.aspectRatio(1f)
|
.aspectRatio(1f)
|
||||||
.clip(MaterialTheme.shapes.medium),
|
.clip(MaterialTheme.shapes.medium),
|
||||||
)
|
)
|
||||||
Column(modifier = Modifier.padding(12.dp)) {
|
AlbumCardBody(
|
||||||
Text(
|
title = album.title,
|
||||||
text = album.title,
|
counter = "${album.photosCount} фото · ${album.membersCount} участн.",
|
||||||
style = MaterialTheme.typography.titleSmall,
|
source = "Сервер",
|
||||||
maxLines = 1,
|
link = item.linkedDeviceNames.takeIf { it.isNotEmpty() }
|
||||||
overflow = TextOverflow.Ellipsis,
|
?.joinToString(prefix = "источник: "),
|
||||||
)
|
key = key,
|
||||||
Text(
|
|
||||||
text = "${album.photosCount} фото · ${album.membersCount} участн.",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.galo.android.di.AppContainer
|
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.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -15,11 +16,12 @@ import kotlinx.coroutines.launch
|
|||||||
class AlbumsViewModel(private val container: AppContainer) : ViewModel() {
|
class AlbumsViewModel(private val container: AppContainer) : ViewModel() {
|
||||||
|
|
||||||
data class UiState(
|
data class UiState(
|
||||||
val albums: List<Album> = emptyList(),
|
val items: List<AlbumListItem> = emptyList(),
|
||||||
val loading: Boolean = false,
|
val loading: Boolean = false,
|
||||||
val offline: Boolean = false,
|
val offline: Boolean = false,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
val avatarUrl: String? = null,
|
val avatarUrl: String? = null,
|
||||||
|
val deviceAccess: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
private data class Status(
|
private data class Status(
|
||||||
@@ -32,12 +34,38 @@ class AlbumsViewModel(private val container: AppContainer) : ViewModel() {
|
|||||||
|
|
||||||
private val avatar = MutableStateFlow<String?>(null)
|
private val avatar = MutableStateFlow<String?>(null)
|
||||||
|
|
||||||
|
private val deviceAlbums = MutableStateFlow<List<DeviceAlbum>>(emptyList())
|
||||||
|
|
||||||
|
private val deviceAccess = MutableStateFlow(false)
|
||||||
|
|
||||||
val state: StateFlow<UiState> = combine(
|
val state: StateFlow<UiState> = combine(
|
||||||
container.albumRepository.observeAlbums(),
|
container.albumRepository.observeAlbums(),
|
||||||
|
container.syncRepository.observeLinks(),
|
||||||
|
combine(deviceAlbums, deviceAccess) { devices, access -> devices to access },
|
||||||
status,
|
status,
|
||||||
avatar,
|
avatar,
|
||||||
) { albums, s, a ->
|
) { albums, links, device, s, a ->
|
||||||
UiState(albums = albums, loading = s.loading, offline = s.offline, error = s.error, avatarUrl = 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))
|
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), UiState(loading = true))
|
||||||
|
|
||||||
init {
|
init {
|
||||||
@@ -47,6 +75,11 @@ class AlbumsViewModel(private val container: AppContainer) : ViewModel() {
|
|||||||
fun refresh() {
|
fun refresh() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
status.update { it.copy(loading = true, error = null) }
|
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()
|
val online = container.albumRepository.refresh()
|
||||||
status.update {
|
status.update {
|
||||||
it.copy(
|
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
|
* 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.
|
* 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 previewKey = ObjectCacheKey.keyFor(previewUrl)
|
||||||
|
val fullResolution = zoomSize != null
|
||||||
return ImageRequest.Builder(context)
|
return ImageRequest.Builder(context)
|
||||||
.data(source)
|
.data(source)
|
||||||
.apply {
|
.apply {
|
||||||
ObjectCacheKey.keyFor(source)?.let {
|
diskCacheKey(sourceKey)
|
||||||
memoryCacheKey(it)
|
// The zoomed decode is cached under its own key so it does not collide
|
||||||
diskCacheKey(it)
|
// with the fitted preview bitmap.
|
||||||
}
|
memoryCacheKey(if (fullResolution && sourceKey != null) "$sourceKey#full" else sourceKey)
|
||||||
if (source != previewUrl) {
|
// Keep the fitted preview visible while the sharper frame decodes, so
|
||||||
|
// zooming does not flash black.
|
||||||
|
if (fullResolution || source != previewUrl) {
|
||||||
previewKey?.let { placeholderMemoryCacheKey(it) }
|
previewKey?.let { placeholderMemoryCacheKey(it) }
|
||||||
}
|
}
|
||||||
|
if (zoomSize != null) {
|
||||||
|
size(zoomSize)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.crossfade(true)
|
.crossfade(true)
|
||||||
.build()
|
.build()
|
||||||
@@ -109,6 +136,16 @@ fun ZoomableImage(
|
|||||||
var zoomed by remember { mutableStateOf(false) }
|
var zoomed by remember { mutableStateOf(false) }
|
||||||
var originalLoading 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() {
|
fun sync() {
|
||||||
layerScale = zoom.layerScale
|
layerScale = zoom.layerScale
|
||||||
panX = zoom.offsetX
|
panX = zoom.offsetX
|
||||||
@@ -185,8 +222,9 @@ fun ZoomableImage(
|
|||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val request = remember(source, previewUrl, context) {
|
val zoomSize = remember(viewport) { zoomBitmapSize(viewport) }
|
||||||
zoomImageRequest(context, source, previewUrl)
|
val request = remember(source, previewUrl, zoomed, zoomSize, context) {
|
||||||
|
zoomImageRequest(context, source, previewUrl, zoomSize = if (zoomed) zoomSize else null)
|
||||||
}
|
}
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = request,
|
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
|
package com.galo.android.ui.albums
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.test.assertIsDisplayed
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
import androidx.compose.ui.test.junit4.createComposeRule
|
import androidx.compose.ui.test.junit4.createComposeRule
|
||||||
import androidx.compose.ui.test.onNodeWithTag
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
import com.galo.android.domain.model.Album
|
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_IMAGE
|
||||||
import com.galo.android.ui.common.TAG_USER_AVATAR_PLACEHOLDER
|
import com.galo.android.ui.common.TAG_USER_AVATAR_PLACEHOLDER
|
||||||
import com.galo.android.ui.theme.GaloTheme
|
import com.galo.android.ui.theme.GaloTheme
|
||||||
@@ -34,13 +37,21 @@ class AlbumsContentTest {
|
|||||||
updatedAt = null,
|
updatedAt = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Test
|
private fun device(id: String, name: String, count: Int = 3) = DeviceAlbum(
|
||||||
fun `album grid shows cards with counters`() {
|
id = id,
|
||||||
compose.setContent {
|
name = name,
|
||||||
|
itemCount = count,
|
||||||
|
coverUri = null,
|
||||||
|
relativePath = "DCIM/$name/",
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun screen(state: AlbumsViewModel.UiState) {
|
||||||
GaloTheme(dynamicColor = false) {
|
GaloTheme(dynamicColor = false) {
|
||||||
AlbumsContent(
|
AlbumsContent(
|
||||||
state = AlbumsViewModel.UiState(albums = listOf(album("a1", "Summer"))),
|
state = state,
|
||||||
onOpenAlbum = {},
|
onOpenAlbum = {},
|
||||||
|
onOpenDeviceAlbum = {},
|
||||||
onOpenUploads = {},
|
onOpenUploads = {},
|
||||||
onOpenProfile = {},
|
onOpenProfile = {},
|
||||||
onRefresh = {},
|
onRefresh = {},
|
||||||
@@ -48,64 +59,64 @@ class AlbumsContentTest {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `server album grid shows cards with counters`() {
|
||||||
|
compose.setContent {
|
||||||
|
screen(
|
||||||
|
AlbumsViewModel.UiState(
|
||||||
|
items = listOf(AlbumListItem.ServerAlbum(album("a1", "Summer"), emptyList())),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
compose.onNodeWithTag(TAG_ALBUMS_GRID).assertIsDisplayed()
|
compose.onNodeWithTag(TAG_ALBUMS_GRID).assertIsDisplayed()
|
||||||
compose.onNodeWithTag(TAG_ALBUM_CARD_PREFIX + "a1").assertIsDisplayed()
|
compose.onNodeWithTag(TAG_ALBUM_CARD_PREFIX + "a1").assertIsDisplayed()
|
||||||
compose.onNodeWithText("Summer").assertIsDisplayed()
|
compose.onNodeWithText("Summer").assertIsDisplayed()
|
||||||
compose.onNodeWithText("4 фото · 2 участн.").assertIsDisplayed()
|
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
|
@Test
|
||||||
fun `offline banner is shown when offline`() {
|
fun `offline banner is shown when offline`() {
|
||||||
compose.setContent {
|
compose.setContent {
|
||||||
GaloTheme(dynamicColor = false) {
|
screen(
|
||||||
AlbumsContent(
|
AlbumsViewModel.UiState(
|
||||||
state = AlbumsViewModel.UiState(
|
items = listOf(AlbumListItem.ServerAlbum(album("a1", "Summer"), emptyList())),
|
||||||
albums = listOf(album("a1", "Summer")),
|
|
||||||
offline = true,
|
offline = true,
|
||||||
error = "Нет сети — показаны кэшированные данные",
|
error = "Нет сети — показаны кэшированные данные",
|
||||||
),
|
),
|
||||||
onOpenAlbum = {},
|
|
||||||
onOpenUploads = {},
|
|
||||||
onOpenProfile = {},
|
|
||||||
onRefresh = {},
|
|
||||||
onLogout = {},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
compose.onNodeWithTag(TAG_ALBUMS_OFFLINE).assertIsDisplayed()
|
compose.onNodeWithTag(TAG_ALBUMS_OFFLINE).assertIsDisplayed()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `empty state is shown without albums`() {
|
fun `empty state is shown without albums`() {
|
||||||
compose.setContent {
|
compose.setContent { screen(AlbumsViewModel.UiState()) }
|
||||||
GaloTheme(dynamicColor = false) {
|
|
||||||
AlbumsContent(
|
|
||||||
state = AlbumsViewModel.UiState(),
|
|
||||||
onOpenAlbum = {},
|
|
||||||
onOpenUploads = {},
|
|
||||||
onOpenProfile = {},
|
|
||||||
onRefresh = {},
|
|
||||||
onLogout = {},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
compose.onNodeWithText("Пока нет альбомов").assertIsDisplayed()
|
compose.onNodeWithText("Пока нет альбомов").assertIsDisplayed()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `header shows the silhouette placeholder without an avatar`() {
|
fun `header shows the silhouette placeholder without an avatar`() {
|
||||||
compose.setContent {
|
compose.setContent { screen(AlbumsViewModel.UiState()) }
|
||||||
GaloTheme(dynamicColor = false) {
|
|
||||||
AlbumsContent(
|
|
||||||
state = AlbumsViewModel.UiState(),
|
|
||||||
onOpenAlbum = {},
|
|
||||||
onOpenUploads = {},
|
|
||||||
onOpenProfile = {},
|
|
||||||
onRefresh = {},
|
|
||||||
onLogout = {},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
compose.onNodeWithTag(TAG_ALBUMS_AVATAR).assertExists()
|
compose.onNodeWithTag(TAG_ALBUMS_AVATAR).assertExists()
|
||||||
compose.onNodeWithTag(TAG_USER_AVATAR_PLACEHOLDER, useUnmergedTree = true).assertIsDisplayed()
|
compose.onNodeWithTag(TAG_USER_AVATAR_PLACEHOLDER, useUnmergedTree = true).assertIsDisplayed()
|
||||||
}
|
}
|
||||||
@@ -113,16 +124,7 @@ class AlbumsContentTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `header shows the avatar image when set`() {
|
fun `header shows the avatar image when set`() {
|
||||||
compose.setContent {
|
compose.setContent {
|
||||||
GaloTheme(dynamicColor = false) {
|
screen(AlbumsViewModel.UiState(avatarUrl = "http://host/api/avatars/u1/v1"))
|
||||||
AlbumsContent(
|
|
||||||
state = AlbumsViewModel.UiState(avatarUrl = "http://host/api/avatars/u1/v1"),
|
|
||||||
onOpenAlbum = {},
|
|
||||||
onOpenUploads = {},
|
|
||||||
onOpenProfile = {},
|
|
||||||
onRefresh = {},
|
|
||||||
onLogout = {},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
compose.onNodeWithTag(TAG_USER_AVATAR_IMAGE, useUnmergedTree = true).assertIsDisplayed()
|
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.assertEquals
|
||||||
import org.junit.Assert.assertNotNull
|
import org.junit.Assert.assertNotNull
|
||||||
import org.junit.Assert.assertNull
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
import org.robolectric.RobolectricTestRunner
|
import org.robolectric.RobolectricTestRunner
|
||||||
@@ -35,4 +36,28 @@ class ZoomImageRequestTest {
|
|||||||
|
|
||||||
assertNull(request.placeholderMemoryCacheKey)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -97,6 +97,7 @@ type Photo struct {
|
|||||||
Kind string `json:"kind"`
|
Kind string `json:"kind"`
|
||||||
DurationMS int64 `json:"duration_ms,omitempty"`
|
DurationMS int64 `json:"duration_ms,omitempty"`
|
||||||
MimeType string `json:"mime_type"`
|
MimeType string `json:"mime_type"`
|
||||||
|
OriginalChecksum string `json:"original_checksum,omitempty"`
|
||||||
TakenAt *time.Time `json:"taken_at,omitempty"`
|
TakenAt *time.Time `json:"taken_at,omitempty"`
|
||||||
Latitude *float64 `json:"latitude,omitempty"`
|
Latitude *float64 `json:"latitude,omitempty"`
|
||||||
Longitude *float64 `json:"longitude,omitempty"`
|
Longitude *float64 `json:"longitude,omitempty"`
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const sqlitePhotoColumns = `p.id, COALESCE(p.album_id, ''), p.uploader_id,
|
|||||||
COALESCE(o_m.storage_key, ''),
|
COALESCE(o_m.storage_key, ''),
|
||||||
COALESCE(o_p.storage_key, ''),
|
COALESCE(o_p.storage_key, ''),
|
||||||
COALESCE(o_pb.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.kind, COALESCE(p.duration_ms, 0),
|
||||||
p.taken_at, p.latitude, p.longitude, COALESCE(p.caption, ''), p.created_at,
|
p.taken_at, p.latitude, p.longitude, COALESCE(p.caption, ''), p.created_at,
|
||||||
u.id, u.name, COALESCE(u.avatar_url, ''),
|
u.id, u.name, COALESCE(u.avatar_url, ''),
|
||||||
@@ -58,7 +58,7 @@ func scanSQLitePhoto(scan func(...any) error) (*models.Photo, error) {
|
|||||||
var liked int
|
var liked int
|
||||||
if err := scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
|
if err := scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
|
||||||
&p.ThumbKey, &p.MediumKey, &p.PreviewKey, &p.PlaybackKey,
|
&p.ThumbKey, &p.MediumKey, &p.PreviewKey, &p.PlaybackKey,
|
||||||
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
|
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType, &p.OriginalChecksum,
|
||||||
&p.Kind, &p.DurationMS,
|
&p.Kind, &p.DurationMS,
|
||||||
&takenAt, &lat, &long, &p.Caption, &createdAt,
|
&takenAt, &lat, &long, &p.Caption, &createdAt,
|
||||||
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
|
&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
|
var liked int
|
||||||
if err := rows.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
|
if err := rows.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
|
||||||
&p.ThumbKey, &p.MediumKey, &p.PreviewKey, &p.PlaybackKey,
|
&p.ThumbKey, &p.MediumKey, &p.PreviewKey, &p.PlaybackKey,
|
||||||
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
|
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType, &p.OriginalChecksum,
|
||||||
&p.Kind, &p.DurationMS,
|
&p.Kind, &p.DurationMS,
|
||||||
&takenAt, &lat, &long, &p.Caption, &createdAt,
|
&takenAt, &lat, &long, &p.Caption, &createdAt,
|
||||||
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
|
&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