Merge commit '467a42a' into develop

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

Some files were not shown because too many files have changed in this diff Show More