feat(android): sync device albums with a server album
Add phone album sync to the Android client: MediaStore source (device albums), a Room index with upload provenance, on-device previews, and a mixed server + device album list where a linked pair collapses into a single device card with a link indicator and mode label. Three modes over one pipeline: A (off), B (upload copies, originals stay local), C (upload originals and offload them locally to previews). The sync engine tracks device changes (add/remove/rename/move/content), matches assets to server photos by sha256 content hash, deletes only this client's uploads, and reflects server changes without touching local originals. Offload is automatic; the zoomed viewer pulls the server original. Backend: expose the original content checksum in photo data (Postgres and SQLite), additive with no migration. Also anchor the `.gitignore` `data/` rule to `/data/` so the Android `com.galo.android.data` package is tracked, and add the halo launcher icon.
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user