feat(android): обновлять список альбомов в реальном времени
Приложение подписывается на персональный поток GET /api/sse/me и применяет album.created/updated/deleted к кэшу Room, поэтому список и открытый экран следуют изменениям с другого устройства или из веба без ручного обновления. - PersonalSseClient и парсер событий жизненного цикла; подписка живёт вместе с сессией (AlbumsViewModel), закрывается на выходе - AlbumRepository.apply: upsert альбома или удаление его вместе с фото; при переподключении список догоняется серверным снимком - потеря доступа (удаление альбома или исключение участника) убирает альбом из списка, а открытый экран возвращается к списку - README: раздел Live updates и ограничение с истечением токена
This commit is contained in:
+19
-3
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user