feat(android): обновлять список альбомов в реальном времени

Приложение подписывается на персональный поток GET /api/sse/me и применяет
album.created/updated/deleted к кэшу Room, поэтому список и открытый экран
следуют изменениям с другого устройства или из веба без ручного обновления.

- PersonalSseClient и парсер событий жизненного цикла; подписка живёт вместе с
  сессией (AlbumsViewModel), закрывается на выходе
- AlbumRepository.apply: upsert альбома или удаление его вместе с фото;
  при переподключении список догоняется серверным снимком
- потеря доступа (удаление альбома или исключение участника) убирает альбом из
  списка, а открытый экран возвращается к списку
- README: раздел Live updates и ограничение с истечением токена
This commit is contained in:
iu
2026-10-16 12:47:53 +03:00
parent 44cb7c7d68
commit 9061f7fc44
23 changed files with 906 additions and 13 deletions
@@ -300,6 +300,17 @@ class GaloApi(
.header("Accept", "text/event-stream")
.build()
/**
* Raw authenticated request for the personal stream (album lifecycle events
* for the signed-in user). Authorization travels in the header, so unlike the
* web client no `token` query parameter is needed.
*/
fun sseMeRequest(): Request =
Request.Builder()
.url(api.endpoint("api/sse/me"))
.header("Accept", "text/event-stream")
.build()
private suspend fun get(path: String): HttpResult =
execute(Request.Builder().url(api.endpoint(path)).get().build())
@@ -0,0 +1,42 @@
package com.galo.android.data.network
import com.galo.android.data.network.dto.AlbumDto
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
/** Album lifecycle events delivered on the personal stream (`GET /api/sse/me`). */
sealed interface PersonalEvent {
data class AlbumUpserted(val album: AlbumDto) : PersonalEvent
data class AlbumDeleted(val albumId: String) : PersonalEvent
/** Emitted after a dropped connection so the caller can catch up over REST. */
data object Reconnected : PersonalEvent
}
/**
* Maps the personal stream's `event:`/`data:` pairs to typed [PersonalEvent]s.
*
* Only album lifecycle events affect the local album list. Notifications carry an
* unrelated `type` inside their payload and are ignored, as are unknown event
* types. An event whose payload yields no album id is treated as malformed.
*/
object PersonalEventParser {
fun parse(type: String, data: String): PersonalEvent? = when (type) {
"album.created", "album.updated" ->
runCatching { GaloJson.decodeFromString<AlbumDto>(data) }
.getOrNull()
?.takeIf { it.id.isNotBlank() }
?.let { PersonalEvent.AlbumUpserted(it) }
"album.deleted" -> stringField(data, "id")
?.takeIf { it.isNotBlank() }
?.let { PersonalEvent.AlbumDeleted(it) }
else -> null
}
private fun stringField(data: String, field: String): String? = runCatching {
GaloJson.parseToJsonElement(data).jsonObject[field]?.jsonPrimitive?.content
}.getOrNull()
}
@@ -0,0 +1,76 @@
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
/**
* Album lifecycle events over the personal SSE stream (`GET /api/sse/me`).
* Reconnects with exponential backoff and emits [PersonalEvent.Reconnected] after
* each drop so the consumer can catch up over REST.
*/
class PersonalSseClient(
private val api: GaloApi,
private val client: OkHttpClient,
private val factory: EventSource.Factory = EventSources.createFactory(client),
) {
fun events(): Flow<PersonalEvent> = channelFlow {
var backoff = INITIAL_BACKOFF_MS
while (true) {
val request = runCatching { api.sseMeRequest() }.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
PersonalEventParser.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)
try {
closed.await()
} finally {
// Runs on a drop and, just as importantly, when the collector is
// cancelled: the connection must not outlive its subscription.
source.cancel()
}
if (isClosedForSend) break
trySend(PersonalEvent.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,28 @@
package com.galo.android.data.repo
import com.galo.android.data.network.PersonalEvent
import kotlinx.coroutines.flow.Flow
/**
* Applies the personal album stream to the cache. Started once per session and
* cancelled with the scope that runs it, so its lifetime follows the session.
*/
interface LiveAlbumSource {
suspend fun run()
}
/**
* Bridges the personal SSE stream to the album cache: every event is applied to
* [AlbumRepository]. The container builds one of these, and the UI only starts
* and stops collecting it.
*/
class AlbumLiveSync(
private val albums: AlbumRepository,
private val events: Flow<PersonalEvent>,
) : LiveAlbumSource {
/** Collects the stream until cancelled, applying every event to the cache. */
override suspend fun run() {
events.collect { albums.apply(it) }
}
}
@@ -3,6 +3,7 @@ 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.PersonalEvent
import com.galo.android.domain.model.Album
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
@@ -15,6 +16,7 @@ import kotlinx.coroutines.flow.map
class AlbumRepository(
private val remote: AlbumRemoteSource,
private val local: AlbumLocalSource,
private val photos: PhotoLocalSource,
private val resolver: ObjectUrlResolver,
) {
@@ -40,4 +42,24 @@ class AlbumRepository(
} catch (_: Exception) {
false
}
/**
* Applies an album lifecycle event from the personal stream to the cache.
* A created or updated album replaces the cached one from the payload without
* a list request; a deleted album is dropped together with its cached photos
* (there is no foreign key to cascade). After a reconnect the list is re-synced
* so changes missed while the stream was down are reflected.
*/
suspend fun apply(event: PersonalEvent) {
when (event) {
is PersonalEvent.AlbumUpserted -> local.upsert(listOf(event.album.toEntity()))
is PersonalEvent.AlbumDeleted -> {
local.deleteByIds(listOf(event.albumId))
photos.clearAlbum(event.albumId)
}
PersonalEvent.Reconnected -> refresh()
}
}
}
@@ -19,6 +19,7 @@ interface AlbumLocalSource {
fun observeEntities(): Flow<List<AlbumEntity>>
suspend fun replaceAll(albums: List<AlbumEntity>)
suspend fun upsert(albums: List<AlbumEntity>)
suspend fun deleteByIds(ids: List<String>)
suspend fun count(): Int
}
@@ -32,6 +33,7 @@ interface PhotoLocalSource {
fun observeForAlbum(albumId: String): Flow<List<PhotoEntity>>
suspend fun upsertAll(photos: List<PhotoEntity>)
suspend fun deleteById(id: String)
suspend fun clearAlbum(albumId: String)
suspend fun countForAlbum(albumId: String): Int
}
@@ -44,6 +46,7 @@ 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 deleteByIds(ids: List<String>) = dao.deleteByIds(ids)
override suspend fun count(): Int = dao.count()
}
@@ -65,5 +68,7 @@ class RoomPhotoLocalSource(private val dao: PhotoDao) : PhotoLocalSource {
override suspend fun deleteById(id: String) = dao.deleteById(id)
override suspend fun clearAlbum(albumId: String) = dao.clearAlbum(albumId)
override suspend fun countForAlbum(albumId: String): Int = dao.countForAlbum(albumId)
}
@@ -11,8 +11,10 @@ import com.galo.android.data.network.AlbumSseClient
import com.galo.android.data.network.ApiClient
import com.galo.android.data.network.GaloApi
import com.galo.android.data.network.HealthChecker
import com.galo.android.data.network.PersonalSseClient
import com.galo.android.data.prefs.SettingsRepository
import com.galo.android.data.prefs.SettingsState
import com.galo.android.data.repo.AlbumLiveSync
import com.galo.android.data.repo.AlbumRepository
import com.galo.android.data.repo.ApiAlbumRemoteSource
import com.galo.android.data.repo.ApiPhotoRemoteSource
@@ -62,21 +64,28 @@ class AppContainer(context: Context) {
val api = GaloApi(apiClient)
val healthChecker = HealthChecker(apiClient.client)
val albumSseClient = AlbumSseClient(api, apiClient.sseClient)
val personalSseClient = PersonalSseClient(api, apiClient.sseClient)
private val database: GaloDatabase = GaloDatabase.create(appContext)
private val photoLocal = RoomPhotoLocalSource(database.photoDao())
val albumRepository = AlbumRepository(
remote = ApiAlbumRemoteSource(api),
local = RoomAlbumLocalSource(database.albumDao()),
photos = photoLocal,
resolver = apiClient.objectUrls,
)
val photoRepository = PhotoRepository(
remote = ApiPhotoRemoteSource(api),
local = RoomPhotoLocalSource(database.photoDao()),
local = photoLocal,
resolver = apiClient.objectUrls,
)
/** Personal stream applied to the album cache; collected while the session lives. */
val albumLiveSync = AlbumLiveSync(albumRepository, personalSseClient.events())
val uploadRepository = UploadRepository(appContext, database.uploadDao())
val mediaAccess: MediaAccess = MediaAccessChecker(appContext)
@@ -0,0 +1,31 @@
package com.galo.android.ui.album
import com.galo.android.domain.model.Album
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Detects loss of access to an album from its cached value: the album was present
* and then disappeared, which happens when the owner deletes it or the user is
* removed from it.
*
* A cold start without a cached album is not access loss - the album may simply
* not be on the device yet - so the flag only flips after the album was seen.
*/
class AlbumAccessTracker {
private var seen = false
private val lost = MutableStateFlow(false)
val accessLost: StateFlow<Boolean> = lost.asStateFlow()
fun onAlbum(album: Album?) {
if (album != null) {
seen = true
} else if (seen) {
lost.value = true
}
}
}
@@ -82,6 +82,9 @@ fun AlbumScreen(
val viewModel: AlbumViewModel = viewModel(factory = factory)
val state by viewModel.state.collectAsStateWithLifecycle()
// A deleted album (or a lost membership) takes the screen back to the list.
LeaveOnAccessLost(accessLost = state.accessLost, onLeave = onBack)
var panelOpen by remember { mutableStateOf(false) }
var renameOpen by remember { mutableStateOf(false) }
var deleteOpen by remember { mutableStateOf(false) }
@@ -29,6 +29,7 @@ class AlbumViewModel(
val offline: Boolean = false,
val endReached: Boolean = false,
val error: String? = null,
val accessLost: Boolean = false,
)
private data class Status(
@@ -43,6 +44,9 @@ class AlbumViewModel(
private val status = MutableStateFlow(Status())
private val role = MutableStateFlow("")
/** Flags a lost album (deleted or the user removed) so the screen can leave. */
private val access = AlbumAccessTracker()
/** Watermark for `?since=` backfill; advanced as events are applied. */
private var lastSync: String = Instant.now().toString()
@@ -51,7 +55,8 @@ class AlbumViewModel(
container.photoRepository.observePhotos(albumId),
status,
role,
) { album, photos, s, r ->
access.accessLost,
) { album, photos, s, r, accessLost ->
UiState(
album = album,
role = r,
@@ -61,6 +66,7 @@ class AlbumViewModel(
offline = s.offline,
endReached = s.endReached,
error = s.error,
accessLost = accessLost,
)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), UiState())
@@ -68,6 +74,7 @@ class AlbumViewModel(
viewModelScope.launch { loadAlbum() }
loadNextPage()
observeEvents()
observeAccess()
}
private suspend fun loadAlbum() {
@@ -122,6 +129,13 @@ class AlbumViewModel(
}
}
/** Flags loss of access when the cached album disappears (deleted or revoked). */
private fun observeAccess() {
viewModelScope.launch {
container.albumRepository.observeAlbum(albumId).collect { access.onAlbum(it) }
}
}
/** Live updates while the screen is alive; reconnect backfills missed changes. */
private fun observeEvents() {
viewModelScope.launch {
@@ -0,0 +1,15 @@
package com.galo.android.ui.album
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
/**
* Leaves the album screen as soon as access to the album is lost (deleted by its
* owner, or the current user removed from it).
*/
@Composable
fun LeaveOnAccessLost(accessLost: Boolean, onLeave: () -> Unit) {
LaunchedEffect(accessLost) {
if (accessLost) onLeave()
}
}
@@ -3,6 +3,7 @@ package com.galo.android.ui.albums
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.galo.android.di.AppContainer
import com.galo.android.data.repo.LiveAlbumSource
import com.galo.android.domain.model.DeviceAlbum
import com.galo.android.domain.model.SyncMode
import kotlinx.coroutines.flow.MutableStateFlow
@@ -13,7 +14,10 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class AlbumsViewModel(private val container: AppContainer) : ViewModel() {
class AlbumsViewModel(
private val container: AppContainer,
private val live: LiveAlbumSource = container.albumLiveSync,
) : ViewModel() {
data class UiState(
val items: List<AlbumListItem> = emptyList(),
@@ -80,6 +84,9 @@ class AlbumsViewModel(private val container: AppContainer) : ViewModel() {
init {
refresh()
// The personal stream lives as long as the session: it is started once
// here and cancelled with the ViewModel scope on logout.
viewModelScope.launch { live.run() }
}
fun refresh() {
@@ -0,0 +1,97 @@
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.entity.AlbumEntity
import com.galo.android.data.db.entity.PhotoEntity
import com.galo.android.data.repo.RoomAlbumLocalSource
import com.galo.android.data.repo.RoomPhotoLocalSource
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
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
/** Local-source deletion used when a personal-stream event removes an album. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33])
class RoomLocalSourceTest {
private lateinit var db: GaloDatabase
@Before
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
db = Room.inMemoryDatabaseBuilder(context, GaloDatabase::class.java)
.allowMainThreadQueries()
.build()
}
@After
fun tearDown() {
db.close()
}
private fun album(id: String) = AlbumEntity(
id = id,
title = id,
description = "",
ownerId = "u1",
coverPhotoId = null,
coverThumbUrl = null,
coverMediumUrl = null,
photosCount = 0,
membersCount = 1,
updatedAt = null,
)
private fun photo(id: String, albumId: String) = PhotoEntity(
id = id,
albumId = albumId,
originalFilename = "$id.jpg",
kind = "image",
mimeType = null,
width = 0,
height = 0,
durationMs = 0,
size = 0,
takenAt = null,
caption = "",
isPanorama = false,
thumbUrl = null,
mediumUrl = null,
previewUrl = null,
playbackUrl = null,
originalUrl = null,
downloadUrl = null,
createdAt = null,
)
@Test
fun `albums are deleted by id`() = runBlocking {
val local = RoomAlbumLocalSource(db.albumDao())
local.upsert(listOf(album("a1"), album("a2")))
local.deleteByIds(listOf("a1"))
val remaining = local.observeEntities().first().map { it.id }
assertEquals(listOf("a2"), remaining)
}
@Test
fun `clearing an album removes only its photos`() = runBlocking {
val local = RoomPhotoLocalSource(db.photoDao())
local.upsertAll(listOf(photo("p1", "a1"), photo("p2", "a1"), photo("p3", "a2")))
local.clearAlbum("a1")
assertTrue(local.observeForAlbum("a1").first().isEmpty())
assertEquals(listOf("p3"), local.observeForAlbum("a2").first().map { it.id })
}
}
@@ -165,4 +165,12 @@ class GaloApiTest {
assertEquals("sunset", api.tags("sun").single().name)
assertEquals("/api/tags?q=sun", server.takeRequest().path)
}
@Test
fun `personal stream request targets the personal endpoint and asks for events`() {
val request = api.sseMeRequest()
assertEquals(server.url("/api/sse/me").toString(), request.url.toString())
assertEquals("text/event-stream", request.header("Accept"))
}
}
@@ -0,0 +1,52 @@
package com.galo.android.data.network
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class PersonalEventParserTest {
@Test
fun `album created maps to an upsert with the album object`() {
val event = PersonalEventParser.parse(
"album.created",
"""{"id":"a1","title":"Summer","photos_count":3,"members_count":2}""",
)
assertTrue(event is PersonalEvent.AlbumUpserted)
val album = (event as PersonalEvent.AlbumUpserted).album
assertEquals("a1", album.id)
assertEquals("Summer", album.title)
assertEquals(3, album.photosCount)
assertEquals(2, album.membersCount)
}
@Test
fun `album updated maps to an upsert with the album object`() {
val event = PersonalEventParser.parse("album.updated", """{"id":"a2","title":"Work"}""")
assertEquals("a2", (event as PersonalEvent.AlbumUpserted).album.id)
}
@Test
fun `album deleted maps to the album id`() {
val event = PersonalEventParser.parse("album.deleted", """{"id":"a9"}""")
assertEquals(PersonalEvent.AlbumDeleted("a9"), event)
}
@Test
fun `notifications and unknown types are ignored`() {
assertNull(PersonalEventParser.parse("notification", """{"type":"member.joined"}"""))
assertNull(PersonalEventParser.parse("member.joined", """{"album_id":"a1"}"""))
assertNull(PersonalEventParser.parse("photo.created", """{"id":"p1"}"""))
}
@Test
fun `malformed payloads are ignored`() {
assertNull(PersonalEventParser.parse("album.created", "not json"))
assertNull(PersonalEventParser.parse("album.updated", """{"title":"no id"}"""))
assertNull(PersonalEventParser.parse("album.deleted", "{}"))
}
}
@@ -0,0 +1,132 @@
package com.galo.android.data.network
import com.galo.android.data.auth.InMemoryTokenStore
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import okhttp3.Request
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.sse.EventSource
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.util.concurrent.atomic.AtomicBoolean
class PersonalSseClientTest {
private lateinit var server: MockWebServer
private lateinit var api: GaloApi
private lateinit var apiClient: ApiClient
@Before
fun setUp() {
server = MockWebServer()
server.start()
val base = server.url("/").toString().trimEnd('/')
apiClient = ApiClient(
tokenStore = InMemoryTokenStore(),
baseUrlProvider = { base },
insecureAllowedProvider = { true },
)
api = GaloApi(apiClient)
}
@After
fun tearDown() {
server.shutdown()
}
@Test
fun `streams lifecycle events in order and signals reconnection when the stream drops`() = runBlocking {
val body = buildString {
append("event: album.created\n")
append("data: {\"id\":\"a1\",\"title\":\"Summer\"}\n\n")
append("event: album.deleted\n")
append("data: {\"id\":\"a2\"}\n\n")
}
server.enqueue(
MockResponse()
.setHeader("Content-Type", "text/event-stream")
.setBody(body),
)
val events = withTimeout(10_000) {
PersonalSseClient(api, apiClient.sseClient).events().take(3).toList()
}
assertEquals("a1", (events[0] as PersonalEvent.AlbumUpserted).album.id)
assertEquals(PersonalEvent.AlbumDeleted("a2"), events[1])
assertEquals(PersonalEvent.Reconnected, events[2])
}
@Test
fun `streaming request targets the personal endpoint`() = runBlocking {
server.enqueue(
MockResponse()
.setHeader("Content-Type", "text/event-stream")
.setBody("event: notification\ndata: {}\n\n"),
)
withTimeout(10_000) {
PersonalSseClient(api, apiClient.sseClient).events().take(1).toList()
}
assertEquals("/api/sse/me", server.takeRequest().path)
}
@Test
fun `each connection carries the current access token`() = runBlocking {
val store = InMemoryTokenStore().apply { save("access-1", "refresh-1") }
val client = ApiClient(
tokenStore = store,
baseUrlProvider = { server.url("/").toString().trimEnd('/') },
insecureAllowedProvider = { true },
)
server.enqueue(
MockResponse()
.setHeader("Content-Type", "text/event-stream")
.setBody("event: notification\ndata: {}\n\n"),
)
withTimeout(10_000) {
PersonalSseClient(GaloApi(client), client.sseClient).events().take(1).toList()
}
assertEquals("Bearer access-1", server.takeRequest().getHeader("Authorization"))
}
@Test
fun `cancelling the collector closes the connection`() = runBlocking {
val opened = CompletableDeferred<Unit>()
var connection: FakeSource? = null
val factory = EventSource.Factory { request, _ ->
FakeSource(request).also {
connection = it
opened.complete(Unit)
}
}
val client = PersonalSseClient(api, apiClient.sseClient, factory)
val job = launch { client.events().collect { } }
withTimeout(5_000) { opened.await() }
job.cancelAndJoin()
assertTrue("connection outlived the cancelled collector", connection!!.cancelled.get())
}
/** Minimal [EventSource] that records whether it was closed. */
private class FakeSource(private val req: Request) : EventSource {
val cancelled = AtomicBoolean(false)
override fun request(): Request = req
override fun cancel() {
cancelled.set(true)
}
}
}
@@ -0,0 +1,86 @@
package com.galo.android.data.repo
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.PersonalEvent
import com.galo.android.data.network.dto.AlbumDto
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Test
/** The stream-to-cache bridge: `AlbumLiveSync` applies what the personal stream emits. */
class AlbumLiveSyncTest {
private class FakeLocal : AlbumLocalSource {
val state = MutableStateFlow<List<AlbumEntity>>(emptyList())
override fun observeEntities(): Flow<List<AlbumEntity>> = state
override suspend fun replaceAll(albums: List<AlbumEntity>) {
state.value = albums
}
override suspend fun upsert(albums: List<AlbumEntity>) {
val byId = state.value.associateBy { it.id }.toMutableMap()
albums.forEach { byId[it.id] = it }
state.value = byId.values.toList()
}
override suspend fun deleteByIds(ids: List<String>) {
state.value = state.value.filterNot { it.id in ids }
}
override suspend fun count(): Int = state.value.size
}
private class NoPhotos : PhotoLocalSource {
override fun observeForAlbum(albumId: String): Flow<List<PhotoEntity>> = flowOf(emptyList())
override suspend fun upsertAll(photos: List<PhotoEntity>) = Unit
override suspend fun deleteById(id: String) = Unit
override suspend fun clearAlbum(albumId: String) = Unit
override suspend fun countForAlbum(albumId: String): Int = 0
}
private val local = FakeLocal()
private val repo = AlbumRepository(
remote = object : AlbumRemoteSource {
override suspend fun fetchAlbums(): List<AlbumDto> = emptyList()
override suspend fun fetchAlbum(albumId: String): AlbumDto = AlbumDto(id = albumId)
},
local = local,
photos = NoPhotos(),
resolver = ObjectUrlResolver { "https://photos.example.com" },
)
@Test
fun `a streamed create event changes what observeAlbums returns`() = runTest {
val sync = AlbumLiveSync(repo, flowOf(PersonalEvent.AlbumUpserted(AlbumDto(id = "a1", title = "Summer"))))
sync.run()
assertEquals(listOf("a1"), repo.observeAlbums().first().map { it.id })
}
@Test
fun `a streamed delete event removes the album from the cache`() = runTest {
repo.apply(PersonalEvent.AlbumUpserted(AlbumDto(id = "a1", title = "Summer")))
val sync = AlbumLiveSync(repo, flowOf(PersonalEvent.AlbumDeleted("a1")))
sync.run()
assertEquals(emptyList<String>(), repo.observeAlbums().first().map { it.id })
}
@Test
fun `a personal delete also empties the open album's flow`() = runTest {
repo.apply(PersonalEvent.AlbumUpserted(AlbumDto(id = "a1", title = "Summer")))
val sync = AlbumLiveSync(repo, flowOf(PersonalEvent.AlbumDeleted("a1")))
sync.run()
assertEquals(null, repo.observeAlbum("a1").first())
}
}
@@ -1,7 +1,9 @@
package com.galo.android.data.repo
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.PersonalEvent
import com.galo.android.data.network.dto.AlbumDto
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
@@ -27,9 +29,34 @@ class AlbumRepositoryTest {
state.value = byId.values.toList()
}
override suspend fun deleteByIds(ids: List<String>) {
state.value = state.value.filterNot { it.id in ids }
}
override suspend fun count(): Int = state.value.size
}
private class FakePhotos : PhotoLocalSource {
val state = MutableStateFlow<List<PhotoEntity>>(emptyList())
override fun observeForAlbum(albumId: String): Flow<List<PhotoEntity>> =
MutableStateFlow(state.value.filter { it.albumId == albumId })
override suspend fun upsertAll(photos: List<PhotoEntity>) {
state.value = state.value + photos
}
override suspend fun deleteById(id: String) {
state.value = state.value.filterNot { it.id == id }
}
override suspend fun clearAlbum(albumId: String) {
state.value = state.value.filterNot { it.albumId == albumId }
}
override suspend fun countForAlbum(albumId: String): Int =
state.value.count { it.albumId == albumId }
}
private class FakeRemote(var result: () -> List<AlbumDto>) : AlbumRemoteSource {
override suspend fun fetchAlbums(): List<AlbumDto> = result()
override suspend fun fetchAlbum(albumId: String): AlbumDto = result().first()
@@ -37,17 +64,55 @@ class AlbumRepositoryTest {
private val resolver = ObjectUrlResolver { "https://photos.example.com" }
private val local = FakeLocal()
private val photos = FakePhotos()
private fun entity(id: String, title: String) = AlbumEntity(
id = id,
title = title,
description = "",
ownerId = "u",
coverPhotoId = null,
coverThumbUrl = null,
coverMediumUrl = null,
photosCount = 0,
membersCount = 1,
updatedAt = null,
)
private fun photo(id: String, albumId: String) = PhotoEntity(
id = id,
albumId = albumId,
originalFilename = "$id.jpg",
kind = "image",
mimeType = null,
width = 0,
height = 0,
durationMs = 0,
size = 0,
takenAt = null,
caption = "",
isPanorama = false,
thumbUrl = null,
mediumUrl = null,
previewUrl = null,
playbackUrl = null,
originalUrl = null,
downloadUrl = null,
createdAt = null,
)
private fun repo(remote: AlbumRemoteSource = FakeRemote { emptyList() }) =
AlbumRepository(remote, local, photos, resolver)
@Test
fun `online refresh populates cache and resolves urls`() = runTest {
val remote = FakeRemote {
listOf(AlbumDto(id = "a1", title = "Summer", coverThumbUrl = "/api/objects/a1/thumb.webp?e=1&s=x"))
}
val repo = AlbumRepository(remote, local, resolver)
assertTrue(repo.refresh())
assertTrue(repo(remote).refresh())
val albums = repo.observeAlbums().first()
val albums = repo(remote).observeAlbums().first()
assertEquals(1, albums.size)
assertEquals("Summer", albums[0].title)
assertEquals(
@@ -58,14 +123,48 @@ class AlbumRepositoryTest {
@Test
fun `offline refresh keeps cached albums`() = runTest {
local.state.value = listOf(AlbumEntity(id = "a1", title = "Cached", description = "", ownerId = "u", coverPhotoId = null, coverThumbUrl = null, coverMediumUrl = null, photosCount = 3, membersCount = 1, updatedAt = null))
local.state.value = listOf(entity("a1", "Cached"))
val remote = FakeRemote { throw java.io.IOException("offline") }
val repo = AlbumRepository(remote, local, resolver)
assertFalse(repo.refresh())
assertFalse(repo(remote).refresh())
val albums = repo.observeAlbums().first()
val albums = repo(remote).observeAlbums().first()
assertEquals(1, albums.size)
assertEquals("Cached", albums[0].title)
}
@Test
fun `a personal upsert event replaces the cached album from the payload`() = runTest {
val album = AlbumDto(id = "a1", title = "Summer", photosCount = 3, membersCount = 2)
repo().apply(PersonalEvent.AlbumUpserted(album))
val albums = repo().observeAlbums().first()
assertEquals(1, albums.size)
assertEquals("Summer", albums[0].title)
assertEquals(3, albums[0].photosCount)
assertEquals(2, albums[0].membersCount)
}
@Test
fun `a personal delete event drops the album and its cached photos`() = runTest {
local.state.value = listOf(entity("a1", "Summer"), entity("a2", "Work"))
photos.state.value = listOf(photo("p1", "a1"), photo("p2", "a2"))
repo().apply(PersonalEvent.AlbumDeleted("a1"))
assertEquals(listOf("a2"), repo().observeAlbums().first().map { it.id })
assertEquals(0, photos.state.value.count { it.albumId == "a1" })
assertEquals(1, photos.state.value.count { it.albumId == "a2" })
}
@Test
fun `reconnect resyncs the list and drops albums gone from the server`() = runTest {
local.state.value = listOf(entity("a1", "Deleted elsewhere"))
val remote = FakeRemote { listOf(AlbumDto(id = "a2", title = "Survivor")) }
repo(remote).apply(PersonalEvent.Reconnected)
assertEquals(listOf("a2"), repo(remote).observeAlbums().first().map { it.id })
}
}
@@ -28,6 +28,10 @@ class PhotoRepositoryTest {
state.value = state.value.filterNot { it.id == id }
}
override suspend fun clearAlbum(albumId: String) {
state.value = state.value.filterNot { it.albumId == albumId }
}
override suspend fun countForAlbum(albumId: String): Int = state.value.size
}
@@ -0,0 +1,42 @@
package com.galo.android.ui.album
import com.galo.android.domain.model.Album
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class AlbumAccessTrackerTest {
private fun album(id: String) = Album(
id = id,
title = id,
description = "",
ownerId = "u1",
coverPhotoId = null,
coverThumbUrl = null,
coverMediumUrl = null,
photosCount = 0,
membersCount = 1,
updatedAt = null,
)
@Test
fun `an album that never appeared does not count as access loss`() {
val tracker = AlbumAccessTracker()
tracker.onAlbum(null)
tracker.onAlbum(null)
assertFalse(tracker.accessLost.value)
}
@Test
fun `a seen album that disappears is flagged as access loss`() {
val tracker = AlbumAccessTracker()
tracker.onAlbum(album("a1"))
tracker.onAlbum(null)
assertTrue(tracker.accessLost.value)
}
}
@@ -0,0 +1,38 @@
package com.galo.android.ui.album
import androidx.compose.ui.test.junit4.createComposeRule
import org.junit.Assert.assertFalse
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 LeaveOnAccessLostTest {
@get:Rule
val compose = createComposeRule()
@Test
fun `the screen leaves when access is lost`() {
var left = false
compose.setContent { LeaveOnAccessLost(accessLost = true) { left = true } }
compose.waitForIdle()
assertTrue(left)
}
@Test
fun `the screen stays while the album is present`() {
var left = false
compose.setContent { LeaveOnAccessLost(accessLost = false) { left = true } }
compose.waitForIdle()
assertFalse(left)
}
}
@@ -0,0 +1,56 @@
package com.galo.android.ui.albums
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.galo.android.data.repo.LiveAlbumSource
import com.galo.android.di.AppContainer
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
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 AlbumsViewModelTest {
private val dispatcher = UnconfinedTestDispatcher()
@Before
fun setUp() {
Dispatchers.setMain(dispatcher)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
/** Counts how many times the session stream is started. */
private class CountingLive : LiveAlbumSource {
var runs = 0
override suspend fun run() {
runs++
}
}
@Test
fun `the personal stream starts once and is not restarted by reading state`() {
val container = AppContainer(ApplicationProvider.getApplicationContext<Context>())
val live = CountingLive()
val viewModel = AlbumsViewModel(container, live)
viewModel.state.value
viewModel.state.value
viewModel.refresh()
assertEquals(1, live.runs)
}
}