diff --git a/android/README.md b/android/README.md index 4d2cdeb..8cfbb50 100644 --- a/android/README.md +++ b/android/README.md @@ -96,9 +96,25 @@ signature rotates. Albums and photo grids open from cache when offline. ## Live updates -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. +While the app is signed in the client holds the personal stream +`GET /api/sse/me` (authorized by the bearer header, not a query token) and applies +album lifecycle events (`album.created`, `album.updated`, `album.deleted`) straight +to the local cache, so the album list and an open album screen follow changes made +elsewhere without a manual refresh. After a reconnect the list is re-synced over +REST, which also drops albums deleted while the stream was down. When an album is +deleted or the user is removed from it, the album leaves the list and the open +album screen returns to it. + +While an album screen is active the client also holds `GET /api/sse/albums/:id` and +backfills missed photo changes via `GET /api/albums/:id/photos?since=` after a +reconnect. + +The streaming client does not refresh tokens by itself: it is built without the +authenticator that ordinary API calls use, and the authorization interceptor only +reads the current access token. A reconnect after expiry therefore restarts with +whatever token is stored, and recovers once any other request has refreshed it; a +client that idles with only the stream open keeps retrying with the stale token +until the next API call renews it. ## Attributes and actions diff --git a/android/app/src/main/java/com/galo/android/data/network/GaloApi.kt b/android/app/src/main/java/com/galo/android/data/network/GaloApi.kt index 22fd94e..3c6aee6 100644 --- a/android/app/src/main/java/com/galo/android/data/network/GaloApi.kt +++ b/android/app/src/main/java/com/galo/android/data/network/GaloApi.kt @@ -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()) diff --git a/android/app/src/main/java/com/galo/android/data/network/PersonalEvent.kt b/android/app/src/main/java/com/galo/android/data/network/PersonalEvent.kt new file mode 100644 index 0000000..184bc6b --- /dev/null +++ b/android/app/src/main/java/com/galo/android/data/network/PersonalEvent.kt @@ -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(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() +} diff --git a/android/app/src/main/java/com/galo/android/data/network/PersonalSseClient.kt b/android/app/src/main/java/com/galo/android/data/network/PersonalSseClient.kt new file mode 100644 index 0000000..152b5d0 --- /dev/null +++ b/android/app/src/main/java/com/galo/android/data/network/PersonalSseClient.kt @@ -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 = channelFlow { + var backoff = INITIAL_BACKOFF_MS + + while (true) { + val request = runCatching { api.sseMeRequest() }.getOrNull() ?: break + val closed = CompletableDeferred() + 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 + } +} diff --git a/android/app/src/main/java/com/galo/android/data/repo/AlbumLiveSync.kt b/android/app/src/main/java/com/galo/android/data/repo/AlbumLiveSync.kt new file mode 100644 index 0000000..b4dfe47 --- /dev/null +++ b/android/app/src/main/java/com/galo/android/data/repo/AlbumLiveSync.kt @@ -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, +) : LiveAlbumSource { + + /** Collects the stream until cancelled, applying every event to the cache. */ + override suspend fun run() { + events.collect { albums.apply(it) } + } +} diff --git a/android/app/src/main/java/com/galo/android/data/repo/AlbumRepository.kt b/android/app/src/main/java/com/galo/android/data/repo/AlbumRepository.kt index fc281e6..14691d7 100644 --- a/android/app/src/main/java/com/galo/android/data/repo/AlbumRepository.kt +++ b/android/app/src/main/java/com/galo/android/data/repo/AlbumRepository.kt @@ -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() + } + } } diff --git a/android/app/src/main/java/com/galo/android/data/repo/Sources.kt b/android/app/src/main/java/com/galo/android/data/repo/Sources.kt index 89ef96b..fc36393 100644 --- a/android/app/src/main/java/com/galo/android/data/repo/Sources.kt +++ b/android/app/src/main/java/com/galo/android/data/repo/Sources.kt @@ -19,6 +19,7 @@ interface AlbumLocalSource { fun observeEntities(): Flow> suspend fun replaceAll(albums: List) suspend fun upsert(albums: List) + suspend fun deleteByIds(ids: List) suspend fun count(): Int } @@ -32,6 +33,7 @@ interface PhotoLocalSource { fun observeForAlbum(albumId: String): Flow> suspend fun upsertAll(photos: List) 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> = dao.observeAll() override suspend fun replaceAll(albums: List) = dao.replaceAll(albums) override suspend fun upsert(albums: List) = dao.upsertAll(albums) + override suspend fun deleteByIds(ids: List) = 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) } diff --git a/android/app/src/main/java/com/galo/android/di/AppContainer.kt b/android/app/src/main/java/com/galo/android/di/AppContainer.kt index 4406027..cd39a0e 100644 --- a/android/app/src/main/java/com/galo/android/di/AppContainer.kt +++ b/android/app/src/main/java/com/galo/android/di/AppContainer.kt @@ -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) diff --git a/android/app/src/main/java/com/galo/android/ui/album/AlbumAccessTracker.kt b/android/app/src/main/java/com/galo/android/ui/album/AlbumAccessTracker.kt new file mode 100644 index 0000000..1bc3fff --- /dev/null +++ b/android/app/src/main/java/com/galo/android/ui/album/AlbumAccessTracker.kt @@ -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 = lost.asStateFlow() + + fun onAlbum(album: Album?) { + if (album != null) { + seen = true + } else if (seen) { + lost.value = true + } + } +} diff --git a/android/app/src/main/java/com/galo/android/ui/album/AlbumScreen.kt b/android/app/src/main/java/com/galo/android/ui/album/AlbumScreen.kt index 108d6a5..5fdf221 100644 --- a/android/app/src/main/java/com/galo/android/ui/album/AlbumScreen.kt +++ b/android/app/src/main/java/com/galo/android/ui/album/AlbumScreen.kt @@ -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) } diff --git a/android/app/src/main/java/com/galo/android/ui/album/AlbumViewModel.kt b/android/app/src/main/java/com/galo/android/ui/album/AlbumViewModel.kt index 4c3cd37..d854940 100644 --- a/android/app/src/main/java/com/galo/android/ui/album/AlbumViewModel.kt +++ b/android/app/src/main/java/com/galo/android/ui/album/AlbumViewModel.kt @@ -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 { diff --git a/android/app/src/main/java/com/galo/android/ui/album/LeaveOnAccessLost.kt b/android/app/src/main/java/com/galo/android/ui/album/LeaveOnAccessLost.kt new file mode 100644 index 0000000..8c7eb72 --- /dev/null +++ b/android/app/src/main/java/com/galo/android/ui/album/LeaveOnAccessLost.kt @@ -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() + } +} diff --git a/android/app/src/main/java/com/galo/android/ui/albums/AlbumsViewModel.kt b/android/app/src/main/java/com/galo/android/ui/albums/AlbumsViewModel.kt index f4b35ab..bf8f384 100644 --- a/android/app/src/main/java/com/galo/android/ui/albums/AlbumsViewModel.kt +++ b/android/app/src/main/java/com/galo/android/ui/albums/AlbumsViewModel.kt @@ -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 = 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() { diff --git a/android/app/src/test/java/com/galo/android/data/db/RoomLocalSourceTest.kt b/android/app/src/test/java/com/galo/android/data/db/RoomLocalSourceTest.kt new file mode 100644 index 0000000..4a55188 --- /dev/null +++ b/android/app/src/test/java/com/galo/android/data/db/RoomLocalSourceTest.kt @@ -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() + 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 }) + } +} diff --git a/android/app/src/test/java/com/galo/android/data/network/GaloApiTest.kt b/android/app/src/test/java/com/galo/android/data/network/GaloApiTest.kt index a8ce1ff..41013b4 100644 --- a/android/app/src/test/java/com/galo/android/data/network/GaloApiTest.kt +++ b/android/app/src/test/java/com/galo/android/data/network/GaloApiTest.kt @@ -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")) + } } diff --git a/android/app/src/test/java/com/galo/android/data/network/PersonalEventParserTest.kt b/android/app/src/test/java/com/galo/android/data/network/PersonalEventParserTest.kt new file mode 100644 index 0000000..4f07c19 --- /dev/null +++ b/android/app/src/test/java/com/galo/android/data/network/PersonalEventParserTest.kt @@ -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", "{}")) + } +} diff --git a/android/app/src/test/java/com/galo/android/data/network/PersonalSseClientTest.kt b/android/app/src/test/java/com/galo/android/data/network/PersonalSseClientTest.kt new file mode 100644 index 0000000..aa716fd --- /dev/null +++ b/android/app/src/test/java/com/galo/android/data/network/PersonalSseClientTest.kt @@ -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() + 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) + } + } +} diff --git a/android/app/src/test/java/com/galo/android/data/repo/AlbumLiveSyncTest.kt b/android/app/src/test/java/com/galo/android/data/repo/AlbumLiveSyncTest.kt new file mode 100644 index 0000000..1ea32ea --- /dev/null +++ b/android/app/src/test/java/com/galo/android/data/repo/AlbumLiveSyncTest.kt @@ -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>(emptyList()) + override fun observeEntities(): Flow> = state + override suspend fun replaceAll(albums: List) { + state.value = albums + } + + override suspend fun upsert(albums: List) { + 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) { + 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> = flowOf(emptyList()) + override suspend fun upsertAll(photos: List) = 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 = 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(), 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()) + } +} diff --git a/android/app/src/test/java/com/galo/android/data/repo/AlbumRepositoryTest.kt b/android/app/src/test/java/com/galo/android/data/repo/AlbumRepositoryTest.kt index efb2552..343d71a 100644 --- a/android/app/src/test/java/com/galo/android/data/repo/AlbumRepositoryTest.kt +++ b/android/app/src/test/java/com/galo/android/data/repo/AlbumRepositoryTest.kt @@ -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) { + state.value = state.value.filterNot { it.id in ids } + } + override suspend fun count(): Int = state.value.size } + private class FakePhotos : PhotoLocalSource { + val state = MutableStateFlow>(emptyList()) + override fun observeForAlbum(albumId: String): Flow> = + MutableStateFlow(state.value.filter { it.albumId == albumId }) + + override suspend fun upsertAll(photos: List) { + 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) : AlbumRemoteSource { override suspend fun fetchAlbums(): List = 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 }) + } } diff --git a/android/app/src/test/java/com/galo/android/data/repo/PhotoRepositoryTest.kt b/android/app/src/test/java/com/galo/android/data/repo/PhotoRepositoryTest.kt index beccadc..10b88dc 100644 --- a/android/app/src/test/java/com/galo/android/data/repo/PhotoRepositoryTest.kt +++ b/android/app/src/test/java/com/galo/android/data/repo/PhotoRepositoryTest.kt @@ -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 } diff --git a/android/app/src/test/java/com/galo/android/ui/album/AlbumAccessTrackerTest.kt b/android/app/src/test/java/com/galo/android/ui/album/AlbumAccessTrackerTest.kt new file mode 100644 index 0000000..9135ef3 --- /dev/null +++ b/android/app/src/test/java/com/galo/android/ui/album/AlbumAccessTrackerTest.kt @@ -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) + } +} diff --git a/android/app/src/test/java/com/galo/android/ui/album/LeaveOnAccessLostTest.kt b/android/app/src/test/java/com/galo/android/ui/album/LeaveOnAccessLostTest.kt new file mode 100644 index 0000000..b71f48b --- /dev/null +++ b/android/app/src/test/java/com/galo/android/ui/album/LeaveOnAccessLostTest.kt @@ -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) + } +} diff --git a/android/app/src/test/java/com/galo/android/ui/albums/AlbumsViewModelTest.kt b/android/app/src/test/java/com/galo/android/ui/albums/AlbumsViewModelTest.kt new file mode 100644 index 0000000..6e1e842 --- /dev/null +++ b/android/app/src/test/java/com/galo/android/ui/albums/AlbumsViewModelTest.kt @@ -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()) + val live = CountingLive() + + val viewModel = AlbumsViewModel(container, live) + viewModel.state.value + viewModel.state.value + viewModel.refresh() + + assertEquals(1, live.runs) + } +}