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
|
## Live updates
|
||||||
|
|
||||||
While an album screen is active the client holds
|
While the app is signed in the client holds the personal stream
|
||||||
`GET /api/sse/albums/:id?token=` and backfills missed changes via
|
`GET /api/sse/me` (authorized by the bearer header, not a query token) and applies
|
||||||
`GET /api/albums/:id/photos?since=` after a reconnect.
|
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
|
## Attributes and actions
|
||||||
|
|
||||||
|
|||||||
@@ -300,6 +300,17 @@ class GaloApi(
|
|||||||
.header("Accept", "text/event-stream")
|
.header("Accept", "text/event-stream")
|
||||||
.build()
|
.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 =
|
private suspend fun get(path: String): HttpResult =
|
||||||
execute(Request.Builder().url(api.endpoint(path)).get().build())
|
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.toDomain
|
||||||
import com.galo.android.data.mapper.toEntity
|
import com.galo.android.data.mapper.toEntity
|
||||||
import com.galo.android.data.network.ObjectUrlResolver
|
import com.galo.android.data.network.ObjectUrlResolver
|
||||||
|
import com.galo.android.data.network.PersonalEvent
|
||||||
import com.galo.android.domain.model.Album
|
import com.galo.android.domain.model.Album
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
@@ -15,6 +16,7 @@ import kotlinx.coroutines.flow.map
|
|||||||
class AlbumRepository(
|
class AlbumRepository(
|
||||||
private val remote: AlbumRemoteSource,
|
private val remote: AlbumRemoteSource,
|
||||||
private val local: AlbumLocalSource,
|
private val local: AlbumLocalSource,
|
||||||
|
private val photos: PhotoLocalSource,
|
||||||
private val resolver: ObjectUrlResolver,
|
private val resolver: ObjectUrlResolver,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@@ -40,4 +42,24 @@ class AlbumRepository(
|
|||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
false
|
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>>
|
fun observeEntities(): Flow<List<AlbumEntity>>
|
||||||
suspend fun replaceAll(albums: List<AlbumEntity>)
|
suspend fun replaceAll(albums: List<AlbumEntity>)
|
||||||
suspend fun upsert(albums: List<AlbumEntity>)
|
suspend fun upsert(albums: List<AlbumEntity>)
|
||||||
|
suspend fun deleteByIds(ids: List<String>)
|
||||||
suspend fun count(): Int
|
suspend fun count(): Int
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ interface PhotoLocalSource {
|
|||||||
fun observeForAlbum(albumId: String): Flow<List<PhotoEntity>>
|
fun observeForAlbum(albumId: String): Flow<List<PhotoEntity>>
|
||||||
suspend fun upsertAll(photos: List<PhotoEntity>)
|
suspend fun upsertAll(photos: List<PhotoEntity>)
|
||||||
suspend fun deleteById(id: String)
|
suspend fun deleteById(id: String)
|
||||||
|
suspend fun clearAlbum(albumId: String)
|
||||||
suspend fun countForAlbum(albumId: String): Int
|
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 fun observeEntities(): Flow<List<AlbumEntity>> = dao.observeAll()
|
||||||
override suspend fun replaceAll(albums: List<AlbumEntity>) = dao.replaceAll(albums)
|
override suspend fun replaceAll(albums: List<AlbumEntity>) = dao.replaceAll(albums)
|
||||||
override suspend fun upsert(albums: List<AlbumEntity>) = dao.upsertAll(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()
|
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 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)
|
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.ApiClient
|
||||||
import com.galo.android.data.network.GaloApi
|
import com.galo.android.data.network.GaloApi
|
||||||
import com.galo.android.data.network.HealthChecker
|
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.SettingsRepository
|
||||||
import com.galo.android.data.prefs.SettingsState
|
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.AlbumRepository
|
||||||
import com.galo.android.data.repo.ApiAlbumRemoteSource
|
import com.galo.android.data.repo.ApiAlbumRemoteSource
|
||||||
import com.galo.android.data.repo.ApiPhotoRemoteSource
|
import com.galo.android.data.repo.ApiPhotoRemoteSource
|
||||||
@@ -62,21 +64,28 @@ class AppContainer(context: Context) {
|
|||||||
val api = GaloApi(apiClient)
|
val api = GaloApi(apiClient)
|
||||||
val healthChecker = HealthChecker(apiClient.client)
|
val healthChecker = HealthChecker(apiClient.client)
|
||||||
val albumSseClient = AlbumSseClient(api, apiClient.sseClient)
|
val albumSseClient = AlbumSseClient(api, apiClient.sseClient)
|
||||||
|
val personalSseClient = PersonalSseClient(api, apiClient.sseClient)
|
||||||
|
|
||||||
private val database: GaloDatabase = GaloDatabase.create(appContext)
|
private val database: GaloDatabase = GaloDatabase.create(appContext)
|
||||||
|
|
||||||
|
private val photoLocal = RoomPhotoLocalSource(database.photoDao())
|
||||||
|
|
||||||
val albumRepository = AlbumRepository(
|
val albumRepository = AlbumRepository(
|
||||||
remote = ApiAlbumRemoteSource(api),
|
remote = ApiAlbumRemoteSource(api),
|
||||||
local = RoomAlbumLocalSource(database.albumDao()),
|
local = RoomAlbumLocalSource(database.albumDao()),
|
||||||
|
photos = photoLocal,
|
||||||
resolver = apiClient.objectUrls,
|
resolver = apiClient.objectUrls,
|
||||||
)
|
)
|
||||||
|
|
||||||
val photoRepository = PhotoRepository(
|
val photoRepository = PhotoRepository(
|
||||||
remote = ApiPhotoRemoteSource(api),
|
remote = ApiPhotoRemoteSource(api),
|
||||||
local = RoomPhotoLocalSource(database.photoDao()),
|
local = photoLocal,
|
||||||
resolver = apiClient.objectUrls,
|
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 uploadRepository = UploadRepository(appContext, database.uploadDao())
|
||||||
|
|
||||||
val mediaAccess: MediaAccess = MediaAccessChecker(appContext)
|
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 viewModel: AlbumViewModel = viewModel(factory = factory)
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
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 panelOpen by remember { mutableStateOf(false) }
|
||||||
var renameOpen by remember { mutableStateOf(false) }
|
var renameOpen by remember { mutableStateOf(false) }
|
||||||
var deleteOpen by remember { mutableStateOf(false) }
|
var deleteOpen by remember { mutableStateOf(false) }
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class AlbumViewModel(
|
|||||||
val offline: Boolean = false,
|
val offline: Boolean = false,
|
||||||
val endReached: Boolean = false,
|
val endReached: Boolean = false,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
|
val accessLost: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
private data class Status(
|
private data class Status(
|
||||||
@@ -43,6 +44,9 @@ class AlbumViewModel(
|
|||||||
private val status = MutableStateFlow(Status())
|
private val status = MutableStateFlow(Status())
|
||||||
private val role = MutableStateFlow("")
|
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. */
|
/** Watermark for `?since=` backfill; advanced as events are applied. */
|
||||||
private var lastSync: String = Instant.now().toString()
|
private var lastSync: String = Instant.now().toString()
|
||||||
|
|
||||||
@@ -51,7 +55,8 @@ class AlbumViewModel(
|
|||||||
container.photoRepository.observePhotos(albumId),
|
container.photoRepository.observePhotos(albumId),
|
||||||
status,
|
status,
|
||||||
role,
|
role,
|
||||||
) { album, photos, s, r ->
|
access.accessLost,
|
||||||
|
) { album, photos, s, r, accessLost ->
|
||||||
UiState(
|
UiState(
|
||||||
album = album,
|
album = album,
|
||||||
role = r,
|
role = r,
|
||||||
@@ -61,6 +66,7 @@ class AlbumViewModel(
|
|||||||
offline = s.offline,
|
offline = s.offline,
|
||||||
endReached = s.endReached,
|
endReached = s.endReached,
|
||||||
error = s.error,
|
error = s.error,
|
||||||
|
accessLost = accessLost,
|
||||||
)
|
)
|
||||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), UiState())
|
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), UiState())
|
||||||
|
|
||||||
@@ -68,6 +74,7 @@ class AlbumViewModel(
|
|||||||
viewModelScope.launch { loadAlbum() }
|
viewModelScope.launch { loadAlbum() }
|
||||||
loadNextPage()
|
loadNextPage()
|
||||||
observeEvents()
|
observeEvents()
|
||||||
|
observeAccess()
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun loadAlbum() {
|
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. */
|
/** Live updates while the screen is alive; reconnect backfills missed changes. */
|
||||||
private fun observeEvents() {
|
private fun observeEvents() {
|
||||||
viewModelScope.launch {
|
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.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.galo.android.di.AppContainer
|
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.DeviceAlbum
|
||||||
import com.galo.android.domain.model.SyncMode
|
import com.galo.android.domain.model.SyncMode
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -13,7 +14,10 @@ import kotlinx.coroutines.flow.stateIn
|
|||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
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(
|
data class UiState(
|
||||||
val items: List<AlbumListItem> = emptyList(),
|
val items: List<AlbumListItem> = emptyList(),
|
||||||
@@ -80,6 +84,9 @@ class AlbumsViewModel(private val container: AppContainer) : ViewModel() {
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
refresh()
|
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() {
|
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("sunset", api.tags("sun").single().name)
|
||||||
assertEquals("/api/tags?q=sun", server.takeRequest().path)
|
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
|
package com.galo.android.data.repo
|
||||||
|
|
||||||
import com.galo.android.data.db.entity.AlbumEntity
|
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.ObjectUrlResolver
|
||||||
|
import com.galo.android.data.network.PersonalEvent
|
||||||
import com.galo.android.data.network.dto.AlbumDto
|
import com.galo.android.data.network.dto.AlbumDto
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -27,9 +29,34 @@ class AlbumRepositoryTest {
|
|||||||
state.value = byId.values.toList()
|
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
|
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 {
|
private class FakeRemote(var result: () -> List<AlbumDto>) : AlbumRemoteSource {
|
||||||
override suspend fun fetchAlbums(): List<AlbumDto> = result()
|
override suspend fun fetchAlbums(): List<AlbumDto> = result()
|
||||||
override suspend fun fetchAlbum(albumId: String): AlbumDto = result().first()
|
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 resolver = ObjectUrlResolver { "https://photos.example.com" }
|
||||||
private val local = FakeLocal()
|
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
|
@Test
|
||||||
fun `online refresh populates cache and resolves urls`() = runTest {
|
fun `online refresh populates cache and resolves urls`() = runTest {
|
||||||
val remote = FakeRemote {
|
val remote = FakeRemote {
|
||||||
listOf(AlbumDto(id = "a1", title = "Summer", coverThumbUrl = "/api/objects/a1/thumb.webp?e=1&s=x"))
|
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(1, albums.size)
|
||||||
assertEquals("Summer", albums[0].title)
|
assertEquals("Summer", albums[0].title)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
@@ -58,14 +123,48 @@ class AlbumRepositoryTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `offline refresh keeps cached albums`() = runTest {
|
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 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(1, albums.size)
|
||||||
assertEquals("Cached", albums[0].title)
|
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 }
|
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
|
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