diff --git a/feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailScreenTest.kt b/feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailScreenTest.kt index a51f3bb..2b8ff1d 100644 --- a/feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailScreenTest.kt +++ b/feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailScreenTest.kt @@ -8,10 +8,12 @@ import androidx.compose.ui.test.performClick import androidx.test.ext.junit.runners.AndroidJUnit4 import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme import com.interlinedlist.android.feature.lists.domain.FieldType +import com.interlinedlist.android.feature.lists.domain.ListPresence import com.interlinedlist.android.feature.lists.domain.ListRow import com.interlinedlist.android.feature.lists.domain.ListSchema import com.interlinedlist.android.feature.lists.domain.ListSummary import com.interlinedlist.android.feature.lists.domain.SchemaField +import com.interlinedlist.android.feature.lists.ui.presence.ListPresenceTestTags import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -148,6 +150,37 @@ class ListDetailScreenTest { composeRule.onNodeWithTag(ListDetailTestTags.BREADCRUMB).assertDoesNotExist() } + @Test + fun showsWhoElseIsInTheList() { + setScreen( + ListDetailUiState( + summary = ListSummary("L1", "Reading", null, 0, null, false, null), + schema = schema, + rows = emptyList(), + isLoading = false, + presence = listOf(ListPresence("u2", displayName = "Casey", username = "casey")), + isCollaborative = true, + ), + ) + + composeRule.onNodeWithTag(ListPresenceTestTags.ROW).assertIsDisplayed() + composeRule.onNodeWithTag(ListPresenceTestTags.avatar("u2")).assertIsDisplayed() + } + + @Test + fun hidesPresence_whenNobodyElseIsHere() { + setScreen( + ListDetailUiState( + summary = ListSummary("L1", "Reading", null, 0, null, false, null), + schema = schema, + rows = emptyList(), + isLoading = false, + ), + ) + + composeRule.onNodeWithTag(ListPresenceTestTags.ROW).assertDoesNotExist() + } + @Test fun overflowOffersANewChildList() { var requested = false diff --git a/feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/presence/ListPresenceIndicatorTest.kt b/feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/presence/ListPresenceIndicatorTest.kt new file mode 100644 index 0000000..d8e8b0f --- /dev/null +++ b/feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/presence/ListPresenceIndicatorTest.kt @@ -0,0 +1,57 @@ +package com.interlinedlist.android.feature.lists.ui.presence + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.lists.domain.ListPresence +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * The "who else is here" cluster on the list detail screen: an avatar per person, + * a "+N" chip past the cap, and nothing at all when nobody else is present. + */ +@RunWith(AndroidJUnit4::class) +class ListPresenceIndicatorTest { + + @get:Rule + val composeRule = createComposeRule() + + private fun person(id: String, name: String) = ListPresence(id, displayName = name, username = name.lowercase()) + + private fun setContent(participants: List) { + composeRule.setContent { + InterlinedListTheme { ListPresenceIndicator(participants = participants) } + } + } + + @Test + fun rendersAnAvatarPerPerson() { + setContent(listOf(person("u2", "Casey"), person("u3", "Robin"))) + + composeRule.onNodeWithTag(ListPresenceTestTags.ROW).assertIsDisplayed() + composeRule.onNodeWithTag(ListPresenceTestTags.avatar("u2")).assertIsDisplayed() + composeRule.onNodeWithTag(ListPresenceTestTags.avatar("u3")).assertIsDisplayed() + composeRule.onNodeWithText("C").assertIsDisplayed() + composeRule.onNodeWithText("R").assertIsDisplayed() + } + + @Test + fun collapsesTheTailIntoAnOverflowChip() { + setContent((1..5).map { person("u$it", "Person $it") }) + + composeRule.onNodeWithTag(ListPresenceTestTags.OVERFLOW).assertIsDisplayed() + composeRule.onNodeWithText("+2").assertIsDisplayed() + } + + @Test + fun showsNothingWhenNobodyElseIsHere() { + setContent(emptyList()) + + composeRule.onNodeWithTag(ListPresenceTestTags.ROW).assertDoesNotExist() + } +} diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepository.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepository.kt index 0deb868..22afe8a 100644 --- a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepository.kt +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepository.kt @@ -20,6 +20,7 @@ import com.interlinedlist.android.feature.lists.data.remote.dto.CreateViewReques import com.interlinedlist.android.feature.lists.data.remote.dto.ListDto import com.interlinedlist.android.feature.lists.data.remote.dto.ListViewEnvelope import com.interlinedlist.android.feature.lists.data.remote.dto.RowDto +import com.interlinedlist.android.feature.lists.data.remote.dto.RowVersionsRequest import com.interlinedlist.android.feature.lists.data.remote.dto.RowWriteRequest import com.interlinedlist.android.feature.lists.data.remote.dto.UpdateFolderRequest import com.interlinedlist.android.feature.lists.data.remote.dto.UpdateListRequest @@ -33,6 +34,7 @@ import com.interlinedlist.android.feature.lists.domain.InviteRole import com.interlinedlist.android.feature.lists.domain.ListConnection import com.interlinedlist.android.feature.lists.domain.ListDetail import com.interlinedlist.android.feature.lists.domain.ListFolder +import com.interlinedlist.android.feature.lists.domain.ListFreshness import com.interlinedlist.android.feature.lists.domain.ListInvite import com.interlinedlist.android.feature.lists.domain.ListRow import com.interlinedlist.android.feature.lists.domain.ListSchema @@ -306,7 +308,7 @@ class DefaultListsRepository @Inject constructor( override suspend fun addRow(listId: String, values: Map): ApiResult = withContext(dispatchers.io) { safeApiCall(json) { api.createRow(listId, RowWriteRequest(values.toJsonData())) } - .map { it.row ?: it.data ?: RowDto(id = "", data = kotlinx.serialization.json.JsonObject(emptyMap())) } + .map { it.row ?: it.data ?: RowDto(id = "") } .map(RowMapper::fromDto) } @@ -316,7 +318,7 @@ class DefaultListsRepository @Inject constructor( values: Map, ): ApiResult = withContext(dispatchers.io) { safeApiCall(json) { api.updateRow(listId, rowId, RowWriteRequest(values.toJsonData())) } - .map { it.row ?: it.data ?: RowDto(id = rowId, data = kotlinx.serialization.json.JsonObject(emptyMap())) } + .map { it.row ?: it.data ?: RowDto(id = rowId) } .map(RowMapper::fromDto) } @@ -325,6 +327,24 @@ class DefaultListsRepository @Inject constructor( safeApiCall(json) { api.deleteRow(listId, rowId) }.map { } } + override suspend fun pollFreshness( + listId: String, + rowVersions: Map, + focusedRowId: String?, + ): ApiResult = withContext(dispatchers.io) { + val body = RowVersionsRequest( + // The server rejects more than 500 rows per request outright, so the + // oldest-held window is what gets watched rather than losing the poll. + rowVersions = if (rowVersions.size <= MAX_POLLED_ROWS) { + rowVersions + } else { + rowVersions.entries.take(MAX_POLLED_ROWS).associate { it.key to it.value } + }, + focusedRowId = focusedRowId, + ) + safeApiCall(json) { api.pollRowVersions(listId, body) }.map(FreshnessMapper::fromDto) + } + override suspend fun getFolders(): ApiResult> = withContext(dispatchers.io) { safeApiCall(json) { api.getFolders() } .map { response -> response.items.map(ListMapper::folderFromDto) } @@ -670,6 +690,9 @@ class DefaultListsRepository @Inject constructor( /** Safety net for a breadcrumb walk: deep nesting is not worth the requests. */ const val MAX_PARENT_CHAIN = 10 + /** The freshness poll's documented ceiling — beyond it the server returns 400. */ + const val MAX_POLLED_ROWS = 500 + /** Matches the server's own copy for the 403 a free owner receives. */ const val NOT_SUBSCRIBED_MESSAGE = "Subscribe to invite people to lists." diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/FreshnessMapper.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/FreshnessMapper.kt new file mode 100644 index 0000000..7208bc5 --- /dev/null +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/FreshnessMapper.kt @@ -0,0 +1,35 @@ +package com.interlinedlist.android.feature.lists.data + +import com.interlinedlist.android.feature.lists.data.remote.dto.PresentUserDto +import com.interlinedlist.android.feature.lists.data.remote.dto.RowVersionsResponse +import com.interlinedlist.android.feature.lists.domain.ListFreshness +import com.interlinedlist.android.feature.lists.domain.ListPresence + +/** + * Projects the freshness poll's response into the domain. + * + * Changed rows arrive in exactly the shape `GET /api/lists/{id}/data` returns, so + * they go through [RowMapper] untouched and a repainted row is indistinguishable + * from a fetched one. Presence entries with no resolvable user id are dropped + * rather than rendered as a blank avatar. + */ +object FreshnessMapper { + + fun fromDto(dto: RowVersionsResponse): ListFreshness = ListFreshness( + changed = dto.changed.filter { it.id.isNotBlank() }.map(RowMapper::fromDto), + deletedRowIds = dto.deletedIds, + presence = dto.users.mapNotNull(::presenceFromDto), + collaborative = dto.collaborative, + ) + + private fun presenceFromDto(dto: PresentUserDto): ListPresence? { + val userId = dto.resolvedUserId ?: return null + return ListPresence( + userId = userId, + displayName = dto.resolvedName, + username = dto.username, + focusedRowId = dto.focusedRowId, + color = dto.color, + ) + } +} diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/ListsRepository.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/ListsRepository.kt index 662c3eb..f6f814e 100644 --- a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/ListsRepository.kt +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/ListsRepository.kt @@ -6,6 +6,7 @@ import com.interlinedlist.android.feature.lists.domain.InviteRole import com.interlinedlist.android.feature.lists.domain.ListConnection import com.interlinedlist.android.feature.lists.domain.ListDetail import com.interlinedlist.android.feature.lists.domain.ListFolder +import com.interlinedlist.android.feature.lists.domain.ListFreshness import com.interlinedlist.android.feature.lists.domain.ListInvite import com.interlinedlist.android.feature.lists.domain.ListRow import com.interlinedlist.android.feature.lists.domain.ListSchema @@ -146,6 +147,22 @@ interface ListsRepository { suspend fun deleteRow(listId: String, rowId: String): ApiResult + /** + * One combined freshness poll and presence heartbeat for an open list. + * + * [rowVersions] are the versions of the rows currently held, keyed by row id; + * the server answers with only what moved, so the caller repaints those rows + * instead of refetching the table. [focusedRowId] publishes which row the user + * is on so other people see it. Rows whose version is unknown are not asked + * about — quoting a made-up version would have the server return the whole + * table on every beat. + */ + suspend fun pollFreshness( + listId: String, + rowVersions: Map, + focusedRowId: String? = null, + ): ApiResult + suspend fun getFolders(): ApiResult> suspend fun createFolder(name: String, parentId: String?): ApiResult diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/RowMapper.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/RowMapper.kt index b456a5e..adca95b 100644 --- a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/RowMapper.kt +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/RowMapper.kt @@ -20,7 +20,8 @@ object RowMapper { fun fromDto(dto: RowDto): ListRow = ListRow( id = dto.id, - values = dto.data.mapValues { (_, value) -> displayString(value) }, + values = dto.fields.mapValues { (_, value) -> displayString(value) }, + version = dto.version, ) /** Coerces any JSON value to a human-readable string. */ diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/ListsApi.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/ListsApi.kt index 5ef0189..6fd4c85 100644 --- a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/ListsApi.kt +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/ListsApi.kt @@ -21,6 +21,8 @@ import com.interlinedlist.android.feature.lists.data.remote.dto.ListViewsRespons import com.interlinedlist.android.feature.lists.data.remote.dto.ListsResponse import com.interlinedlist.android.feature.lists.data.remote.dto.RefreshResultDto import com.interlinedlist.android.feature.lists.data.remote.dto.RowEnvelope +import com.interlinedlist.android.feature.lists.data.remote.dto.RowVersionsRequest +import com.interlinedlist.android.feature.lists.data.remote.dto.RowVersionsResponse import com.interlinedlist.android.feature.lists.data.remote.dto.RowWriteRequest import com.interlinedlist.android.feature.lists.data.remote.dto.CreateShareLinkRequest import com.interlinedlist.android.feature.lists.data.remote.dto.RowsResponse @@ -175,6 +177,17 @@ interface ListsApi { @Body body: RowWriteRequest, ): RowEnvelope + /** + * Combined freshness poll and presence heartbeat for the grid. A `POST` + * because it writes the caller's heartbeat and because the row versions + * belong in a body. At most 500 rows per request. + */ + @POST("api/lists/{id}/data/versions") + suspend fun pollRowVersions( + @Path("id") id: String, + @Body body: RowVersionsRequest, + ): RowVersionsResponse + @DELETE("api/lists/{id}/data/{rowId}") suspend fun deleteRow( @Path("id") id: String, diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/dto/FreshnessDtos.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/dto/FreshnessDtos.kt new file mode 100644 index 0000000..29b946b --- /dev/null +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/dto/FreshnessDtos.kt @@ -0,0 +1,70 @@ +package com.interlinedlist.android.feature.lists.data.remote.dto + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +/** + * Body for `POST /api/lists/{id}/data/versions`: the row versions we are holding + * plus the row the user is on. Both are optional — the server accepts an empty + * body — but without [rowVersions] it has nothing to compare against, so a poll + * that sends none can only answer the presence half. + */ +@Serializable +data class RowVersionsRequest( + val rowVersions: Map = emptyMap(), + val focusedRowId: String? = null, +) + +/** + * Someone present in the list. Field names follow the documented + * `{ userId, name, username, color, focusedRowId }`; `id`/`displayName` are + * tolerated too, because `users` could not be observed live (it needs a second + * person on the list) and the user objects the same endpoint *does* return — + * `createdByUser` / `lastEditedByUser` — are keyed `{ id, username, displayName, + * avatar }`. + */ +@Serializable +data class PresentUserDto( + val userId: String? = null, + val id: String? = null, + val name: String? = null, + val displayName: String? = null, + val username: String? = null, + val avatar: String? = null, + val color: String? = null, + val focusedRowId: String? = null, +) { + val resolvedUserId: String? get() = (userId ?: id)?.takeIf { it.isNotBlank() } + val resolvedName: String? get() = displayName?.takeIf { it.isNotBlank() } ?: name?.takeIf { it.isNotBlank() } +} + +/** + * Response for the freshness poll, verified live: + * `changed` holds whole rows in the same shape `GET .../data` returns (keyed + * `rowData`, with `version`), `deleted` is a bare array of row id strings, and + * `collaborative` is the server's own flag. Only `users` could not be observed — + * it needs a second person on the list. + * + * `deleted` is still decoded as raw JSON so an array of `{ "id": … }` objects + * would be understood rather than failing the whole poll. + */ +@Serializable +data class RowVersionsResponse( + val changed: List = emptyList(), + val deleted: List = emptyList(), + val users: List = emptyList(), + val collaborative: Boolean = false, +) { + val deletedIds: List + get() = deleted.mapNotNull { element -> + when (element) { + is JsonPrimitive -> element.content.takeIf { it.isNotBlank() } + is JsonObject -> element.jsonObject["id"]?.jsonPrimitive?.content?.takeIf { it.isNotBlank() } + else -> null + } + } +} diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/dto/RowDtos.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/dto/RowDtos.kt index e5c940f..16e7c4e 100644 --- a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/dto/RowDtos.kt +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/dto/RowDtos.kt @@ -5,15 +5,28 @@ import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject /** - * A single data row. `data` is the dynamic key→value map keyed by schema field - * keys; it is kept as a [JsonObject] and projected to display strings by the - * mapper, so any schema is supported without a fixed shape. + * A single data row. The field map is a dynamic key→value object keyed by schema + * field keys; it is kept as a [JsonObject] and projected to display strings by + * the mapper, so any schema is supported without a fixed shape. + * + * The live API sends it as **`rowData`** (confirmed against `GET .../data`, the + * create/update echo and the freshness poll), while `data` is accepted as well + * since some payloads have been modelled that way; [fields] picks whichever is + * present. + * + * [version] is the row's optimistic-concurrency counter — what the grid's + * freshness poll compares against. It is absent on sources that do not version + * rows (a GitHub-backed list). */ @Serializable data class RowDto( val id: String, - val data: JsonObject = JsonObject(emptyMap()), -) + val rowData: JsonObject? = null, + val data: JsonObject? = null, + val version: Int? = null, +) { + val fields: JsonObject get() = rowData ?: data ?: JsonObject(emptyMap()) +} /** Envelope for `GET /api/lists/{id}/data`. */ @Serializable diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/ListFreshness.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/ListFreshness.kt new file mode 100644 index 0000000..2dff493 --- /dev/null +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/ListFreshness.kt @@ -0,0 +1,45 @@ +package com.interlinedlist.android.feature.lists.domain + +/** + * Somebody else currently in a list's grid, as reported by the presence half of + * `POST /api/lists/{id}/data/versions`. The server never includes the caller. + * + * Deliberately the same shape the documents editor's presence model uses — an + * avatar cluster, not live cursors — plus the one fact a grid adds: which row the + * person is on, so an edit can be shown as contended. + */ +data class ListPresence( + val userId: String, + val displayName: String? = null, + val username: String? = null, + /** The row this person currently has focused, when the server reports one. */ + val focusedRowId: String? = null, + /** Server-assigned colour (e.g. `"#3366ff"`), kept verbatim. May be absent. */ + val color: String? = null, +) { + val label: String + get() = displayName?.takeIf { it.isNotBlank() } + ?: username?.takeIf { it.isNotBlank() } + ?: userId + + val initial: String get() = label.trim().firstOrNull()?.uppercase() ?: "?" +} + +/** + * One answer from the combined freshness poll / presence heartbeat. + * + * [changed] carries whole rows whose stored version differs from the one we sent, + * so the grid repaints those rows instead of refetching the table; [deletedRowIds] + * are ids we asked about that no longer resolve. [collaborative] is the server's + * own "is anyone else involved with this list" flag — when it is false there is + * nothing to poll for and the client must stop. + */ +data class ListFreshness( + val changed: List = emptyList(), + val deletedRowIds: List = emptyList(), + val presence: List = emptyList(), + val collaborative: Boolean = false, +) { + /** Whether this poll actually brought news — decides the next poll interval. */ + val hasChanges: Boolean get() = changed.isNotEmpty() || deletedRowIds.isNotEmpty() +} diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/ListRow.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/ListRow.kt index ad2c65c..954447c 100644 --- a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/ListRow.kt +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/ListRow.kt @@ -9,6 +9,12 @@ package com.interlinedlist.android.feature.lists.domain data class ListRow( val id: String, val values: Map, + /** + * The row's server-side version counter, when the source versions rows. It is + * what the freshness poll quotes to ask "has this row moved?", so a row with a + * null version is simply left out of that question rather than guessed at. + */ + val version: Int? = null, ) { /** Value for [key], or empty string when the row omits that field. */ fun valueFor(key: String): String = values[key].orEmpty() diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailScreen.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailScreen.kt index 1bb46bf..7248752 100644 --- a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailScreen.kt +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailScreen.kt @@ -39,6 +39,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -54,10 +55,12 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.lists.domain.ListPresence import com.interlinedlist.android.feature.lists.domain.ListRow import com.interlinedlist.android.feature.lists.domain.ListSchema import com.interlinedlist.android.feature.lists.domain.ListSummary import com.interlinedlist.android.feature.lists.domain.SchemaField +import com.interlinedlist.android.feature.lists.ui.presence.ListPresenceIndicator import com.interlinedlist.android.feature.lists.ui.views.ListViewSwitcher /** Stable test tags for the list detail screen. */ @@ -106,6 +109,13 @@ fun ListDetailRoute( val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val snackbarHostState = remember { SnackbarHostState() } + // The freshness poll / presence heartbeat lives exactly as long as this screen + // is composed: started on entry, stopped the moment it leaves. + DisposableEffect(Unit) { + viewModel.startHeartbeat() + onDispose { viewModel.stopHeartbeat() } + } + // Surface the refresh outcome as a transient snackbar, then clear it. LaunchedEffect(state.refreshMessage) { state.refreshMessage?.let { @@ -121,6 +131,8 @@ fun ListDetailRoute( onEditRow = { // Seed the editor from the freshest server copy of the row. viewModel.loadRow(it.id) + // Tell everyone else which row is being worked on. + viewModel.setFocusedRow(it.id) editing = EditorTarget.Existing(it) }, onDeleteRow = viewModel::deleteRow, @@ -146,7 +158,10 @@ fun ListDetailRoute( val liveRow = (target as? EditorTarget.Existing)?.let { existing -> state.rows.firstOrNull { it.id == existing.row.id } ?: existing.row } - ModalBottomSheet(onDismissRequest = { editing = null }, sheetState = sheetState) { + ModalBottomSheet( + onDismissRequest = { editing = null; viewModel.setFocusedRow(null) }, + sheetState = sheetState, + ) { RowEditor( schema = state.schema, row = liveRow, @@ -154,12 +169,13 @@ fun ListDetailRoute( githubRepo = state.summary?.takeIf { it.isGithubBacked }?.githubRepo, nextIssueNumber = state.nextIssueNumber, onSave = { values -> + val done = { editing = null; viewModel.setFocusedRow(null) } when (target) { - EditorTarget.New -> viewModel.addRow(values) { editing = null } - is EditorTarget.Existing -> viewModel.updateRow(target.row.id, values) { editing = null } + EditorTarget.New -> viewModel.addRow(values) { done() } + is EditorTarget.Existing -> viewModel.updateRow(target.row.id, values) { done() } } }, - onCancel = { editing = null }, + onCancel = { editing = null; viewModel.setFocusedRow(null) }, ) } } @@ -227,6 +243,11 @@ fun ListDetailScreen( } }, actions = { + // Who else is in this list right now (nothing when nobody is). + ListPresenceIndicator( + participants = state.presence, + modifier = Modifier.padding(end = 4.dp), + ) // `POST /api/lists/{id}/refresh` only means anything for a // GitHub-backed list, so the action appears only there. if (state.isGithubBacked) { @@ -517,6 +538,8 @@ private fun ListDetailScreenPreview() { ListRow("r2", mapOf("title" to "Hyperion", "done" to "false")), ), isLoading = false, + presence = listOf(ListPresence("u2", displayName = "Casey", username = "casey")), + isCollaborative = true, ), onBack = {}, onAddRow = {}, diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailViewModel.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailViewModel.kt index a054794..8499a46 100644 --- a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailViewModel.kt +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailViewModel.kt @@ -6,16 +6,21 @@ import androidx.lifecycle.viewModelScope import com.interlinedlist.android.core.common.result.ApiResult import com.interlinedlist.android.feature.lists.data.GithubRepository import com.interlinedlist.android.feature.lists.data.ListsRepository +import com.interlinedlist.android.feature.lists.domain.ListFreshness +import com.interlinedlist.android.feature.lists.domain.ListPresence import com.interlinedlist.android.feature.lists.domain.ListRow import com.interlinedlist.android.feature.lists.domain.ListSchema import com.interlinedlist.android.feature.lists.domain.ListSummary import com.interlinedlist.android.feature.lists.ui.isSubscriptionGate import com.interlinedlist.android.feature.lists.ui.toUserMessage import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import javax.inject.Inject @@ -39,6 +44,10 @@ data class ListDetailUiState( * Null when unknown or not applicable — the row form simply omits the hint. */ val nextIssueNumber: Int? = null, + /** Other people currently in this list, from the freshness poll's presence half. */ + val presence: List = emptyList(), + /** The server's own verdict on whether anyone else is involved with this list. */ + val isCollaborative: Boolean = false, ) { val title: String get() = summary?.title.orEmpty() val isEmpty: Boolean get() = rows.isEmpty() && !isLoading && errorMessage == null @@ -64,6 +73,21 @@ class ListDetailViewModel @Inject constructor( private val _uiState = MutableStateFlow(ListDetailUiState()) val uiState: StateFlow = _uiState.asStateFlow() + /** The freshness/presence loop. Non-null only while the list is on screen. */ + private var heartbeatJob: Job? = null + + /** The row the user is on, published to everyone else on the next beat. */ + private var focusedRowId: String? = null + + /** Polled time since the user last did anything, used to stop an idle screen. */ + private var idleMillis = 0L + + /** Whether the list is currently on screen; nothing may beat when it is not. */ + private var isOnScreen = false + + /** Set once the server says the list is not collaborative — then we stay quiet. */ + private var pollingDisabled = false + init { load() } @@ -95,7 +119,119 @@ class ListDetailViewModel @Inject constructor( } } + // --- Collaborative freshness + presence -------------------------------- + + /** + * Starts the combined freshness poll and presence heartbeat for as long as the + * list is on screen. Idempotent: a second call while one is running is ignored. + * + * The loop is deliberately frugal, because the server's own guidance is that the + * database behind this endpoint bills for being awake: + * - [ACTIVE_INTERVAL_MS] while the list is actually moving, + * - [IDLE_INTERVAL_MS] when a beat brings no news (and after a failed beat), + * - it stops outright once the server reports the list is not collaborative, + * - and it stops after [MAX_IDLE_MS] without the user touching anything. + */ + fun startHeartbeat() { + isOnScreen = true + if (heartbeatJob?.isActive == true) return + // A fresh entry re-asks whether anyone else is on the list by now. + pollingDisabled = false + idleMillis = 0 + heartbeatJob = launchHeartbeat() + } + + private fun launchHeartbeat(): Job = viewModelScope.launch { + var interval = ACTIVE_INTERVAL_MS + while (isActive) { + when (val result = repository.pollFreshness(listId, rowVersions(), focusedRowId)) { + is ApiResult.Success -> { + applyFreshness(result.data) + // Nobody else can see this list, so there is nothing to hear + // about: stop rather than keep a shared database awake. + if (!result.data.collaborative) { + pollingDisabled = true + return@launch + } + interval = if (result.data.hasChanges) ACTIVE_INTERVAL_MS else IDLE_INTERVAL_MS + } + // Transient — back off rather than retry hard; the screen still works. + is ApiResult.Failure -> interval = IDLE_INTERVAL_MS + } + // A screen nobody has touched in ten minutes stops asking. + if (idleMillis >= MAX_IDLE_MS) return@launch + delay(interval) + idleMillis += interval + } + } + + /** + * Stops heartbeating when the list leaves the screen. There is no "leave" call + * to make — the heartbeat is what keeps presence alive, so it simply expires + * server-side — but the local presence is cleared so a returning screen never + * shows who *was* here. + */ + fun stopHeartbeat() { + isOnScreen = false + heartbeatJob?.cancel() + heartbeatJob = null + _uiState.update { it.copy(presence = emptyList()) } + } + + /** + * Publishes which row the user is on (null when they leave the editor). Also + * counts as interaction, so opening a row revives an idled-out screen. + */ + fun setFocusedRow(rowId: String?) { + focusedRowId = rowId + noteInteraction() + } + + /** + * Resets the idle timer; every user-initiated write calls it. Working in a list + * that had gone quiet brings the heartbeat back — but only while the list is on + * screen, and never on a list the server already said nobody else can see. + */ + private fun noteInteraction() { + idleMillis = 0 + if (isOnScreen && !pollingDisabled && heartbeatJob?.isActive != true) { + heartbeatJob = launchHeartbeat() + } + } + + /** Versions of the rows on screen. A row with no known version is not asked about. */ + private fun rowVersions(): Map = + _uiState.value.rows.mapNotNull { row -> row.version?.let { row.id to it } }.toMap() + + /** + * Applies one poll: replaces exactly the rows the server says moved, drops the + * ones it says are gone, and updates who is present. The whole table is never + * refetched — that is the entire point of the endpoint. + */ + private fun applyFreshness(freshness: ListFreshness) { + _uiState.update { state -> + val moved = freshness.changed.associateBy { it.id } + val kept = state.rows + .filterNot { it.id in freshness.deletedRowIds } + .map { moved[it.id] ?: it } + val keptIds = kept.mapTo(mutableSetOf()) { it.id } + // A changed row we were not holding is appended rather than thrown away. + val added = freshness.changed.filterNot { it.id in keptIds || it.id in freshness.deletedRowIds } + state.copy( + rows = kept + added, + presence = freshness.presence, + isCollaborative = freshness.collaborative, + ) + } + } + + override fun onCleared() { + super.onCleared() + stopHeartbeat() + } + fun addRow(values: Map, onDone: () -> Unit = {}) { + noteInteraction() _uiState.update { it.copy(isSaving = true) } viewModelScope.launch { when (val result = repository.addRow(listId, values)) { @@ -111,6 +247,7 @@ class ListDetailViewModel @Inject constructor( } fun updateRow(rowId: String, values: Map, onDone: () -> Unit = {}) { + noteInteraction() _uiState.update { it.copy(isSaving = true) } viewModelScope.launch { when (val result = repository.updateRow(listId, rowId, values)) { @@ -148,6 +285,7 @@ class ListDetailViewModel @Inject constructor( } fun deleteRow(rowId: String) { + noteInteraction() viewModelScope.launch { when (val result = repository.deleteRow(listId, rowId)) { is ApiResult.Success -> _uiState.update { state -> @@ -338,7 +476,16 @@ class ListDetailViewModel @Inject constructor( fun clearError() = _uiState.update { it.copy(errorMessage = null) } - private companion object { - const val NEW_CHILD_TITLE = "New list" + companion object { + private const val NEW_CHILD_TITLE = "New list" + + /** Beat interval while rows are actually moving — the first-party grid's own. */ + const val ACTIVE_INTERVAL_MS = 10_000L + + /** Backed-off interval once a beat brings no news, or after a failed beat. */ + const val IDLE_INTERVAL_MS = 60_000L + + /** Polling stops entirely after this long without the user doing anything. */ + const val MAX_IDLE_MS = 10 * 60_000L } } diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/presence/ListPresenceIndicator.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/presence/ListPresenceIndicator.kt new file mode 100644 index 0000000..b480eb0 --- /dev/null +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/presence/ListPresenceIndicator.kt @@ -0,0 +1,101 @@ +package com.interlinedlist.android.feature.lists.ui.presence + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import com.interlinedlist.android.feature.lists.domain.ListPresence + +/** Stable test tags for the list presence cluster. */ +object ListPresenceTestTags { + const val ROW = "listPresenceRow" + const val OVERFLOW = "listPresenceOverflow" + fun avatar(userId: String) = "listPresenceAvatar_$userId" +} + +/** + * A compact "who else is here" avatar cluster for the list detail top bar. + * + * Deliberately the same shape as the documents editor's presence indicator so the + * two features read identically; it is a separate copy rather than a shared one + * because features do not depend on each other. Renders nothing when nobody else + * is present, which is the normal case. + */ +@Composable +fun ListPresenceIndicator( + participants: List, + modifier: Modifier = Modifier, + maxAvatars: Int = 3, +) { + if (participants.isEmpty()) return + val shown = participants.take(maxAvatars) + val overflow = participants.size - shown.size + Row( + modifier = modifier + .testTag(ListPresenceTestTags.ROW) + .semantics { contentDescription = describe(participants) }, + verticalAlignment = Alignment.CenterVertically, + ) { + shown.forEachIndexed { index, person -> + PresenceAvatar( + initial = person.initial, + modifier = Modifier + .offset(x = (index * -8).dp) + .testTag(ListPresenceTestTags.avatar(person.userId)), + ) + } + if (overflow > 0) { + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier + .offset(x = (shown.size * -8).dp) + .size(28.dp) + .testTag(ListPresenceTestTags.OVERFLOW), + ) { + Box(Modifier.padding(2.dp), contentAlignment = Alignment.Center) { + Text("+$overflow", style = MaterialTheme.typography.labelSmall) + } + } + } + } +} + +/** Screen-reader text for the cluster: names the people, not the avatars. */ +internal fun describe(participants: List): String = when (participants.size) { + 0 -> "" + 1 -> "${participants.first().label} is also here" + else -> participants.joinToString(", ") { it.label } + " are also here" +} + +@Composable +private fun PresenceAvatar(initial: String, modifier: Modifier = Modifier) { + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.tertiaryContainer, + modifier = modifier + .size(28.dp) + .border(1.dp, MaterialTheme.colorScheme.surface, CircleShape), + ) { + Box(contentAlignment = Alignment.Center) { + Text( + text = initial, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onTertiaryContainer, + ) + } + } +} diff --git a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/FakeListsRepository.kt b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/FakeListsRepository.kt index 8d3c25c..65dd3ca 100644 --- a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/FakeListsRepository.kt +++ b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/FakeListsRepository.kt @@ -10,6 +10,7 @@ import com.interlinedlist.android.feature.lists.domain.InviteRole import com.interlinedlist.android.feature.lists.domain.ListConnection import com.interlinedlist.android.feature.lists.domain.ListDetail import com.interlinedlist.android.feature.lists.domain.ListFolder +import com.interlinedlist.android.feature.lists.domain.ListFreshness import com.interlinedlist.android.feature.lists.domain.ListInvite import com.interlinedlist.android.feature.lists.domain.ListRow import com.interlinedlist.android.feature.lists.domain.ListSchema @@ -55,6 +56,14 @@ class FakeListsRepository : ListsRepository { var updateRowResult: ApiResult? = null var deleteRowResult: ApiResult = ApiResult.Success(Unit) + // Collaborative freshness poll / presence heartbeat. + var freshnessResults: MutableList> = mutableListOf() + var freshnessResult: ApiResult = ApiResult.Success(ListFreshness()) + var pollCount = 0 + var detailCount = 0 + var lastPolledFocusedRowId: String? = null + var lastPolledRowVersions: Map? = null + // Folder management + contributors. var foldersResult: ApiResult> = ApiResult.Success(emptyList()) var updateFolderResult: ApiResult? = null @@ -274,14 +283,32 @@ class FakeListsRepository : ListsRepository { return deleteResult } - override suspend fun getListDetail(id: String, rowLimit: Int): ApiResult = - detailResult ?: ApiResult.Success( + override suspend fun getListDetail(id: String, rowLimit: Int): ApiResult { + detailCount++ + return detailResult ?: ApiResult.Success( ListDetail( summary = ListSummary(id, "Untitled", null, 0, null, false, null), schema = ListSchema.EMPTY, rows = emptyList(), ), ) + } + + /** + * Answers with the next scripted result from [freshnessResults] (so a test can + * script a sequence of beats), falling back to [freshnessResult] once they run + * out — which is also how "the same answer forever" is expressed. + */ + override suspend fun pollFreshness( + listId: String, + rowVersions: Map, + focusedRowId: String?, + ): ApiResult { + pollCount++ + lastPolledRowVersions = rowVersions + lastPolledFocusedRowId = focusedRowId + return if (freshnessResults.isNotEmpty()) freshnessResults.removeAt(0) else freshnessResult + } override suspend fun getRow(listId: String, rowId: String): ApiResult = getRowResult ?: ApiResult.Success(ListRow(rowId, emptyMap())) diff --git a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepositoryFreshnessTest.kt b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepositoryFreshnessTest.kt new file mode 100644 index 0000000..a2a4912 --- /dev/null +++ b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepositoryFreshnessTest.kt @@ -0,0 +1,214 @@ +package com.interlinedlist.android.feature.lists.data + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.dispatcher.DispatcherProvider +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.network.api.InterlinedListApi +import com.interlinedlist.android.feature.lists.data.local.CachedListEntity +import com.interlinedlist.android.feature.lists.data.local.ListDao +import com.interlinedlist.android.feature.lists.data.remote.ListsApi +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Before +import org.junit.Test +import retrofit2.Retrofit + +/** + * MockWebServer coverage for the grid's freshness poll / presence heartbeat + * (`POST /api/lists/{id}/data/versions`). + * + * The payloads here were captured against the live API: quoting a stale version + * returns the whole row under `changed` in the same shape `GET .../data` uses + * (`rowData` + `version`), and an id the server cannot resolve comes back as a + * bare string in `deleted`. Only `users` was not observable — it needs a second + * person on the list — so it is decoded tolerantly. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class DefaultListsRepositoryFreshnessTest { + + private lateinit var server: MockWebServer + private lateinit var api: ListsApi + private lateinit var userApi: InterlinedListApi + private lateinit var repository: DefaultListsRepository + + private val json = Json { ignoreUnknownKeys = true; explicitNulls = false; coerceInputValues = true } + private val dispatcher = StandardTestDispatcher() + + private val testDispatchers = object : DispatcherProvider { + override val io: CoroutineDispatcher get() = dispatcher + override val default: CoroutineDispatcher get() = dispatcher + override val main: CoroutineDispatcher get() = dispatcher + } + + @Before + fun setUp() { + server = MockWebServer().also { it.start() } + val retrofit = Retrofit.Builder() + .baseUrl(server.url("/")) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + api = retrofit.create(ListsApi::class.java) + userApi = retrofit.create(InterlinedListApi::class.java) + repository = DefaultListsRepository(api, userApi, FakeFreshnessDao(), json, testDispatchers) + } + + @After + fun tearDown() = server.shutdown() + + @Test + fun `poll posts the held versions and the focused row`() = runTest(dispatcher) { + server.enqueue( + MockResponse().setBody( + """{ "changed": [], "deleted": [], "users": [], "collaborative": false }""", + ), + ) + + val result = repository.pollFreshness("L1", mapOf("row_a" to 4, "row_b" to 7), focusedRowId = "row_a") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val request = server.takeRequest() + assertThat(request.method).isEqualTo("POST") + assertThat(request.path).isEqualTo("/api/lists/L1/data/versions") + + val body = json.parseToJsonElement(request.body.readUtf8()).jsonObject + assertThat(body["focusedRowId"]?.jsonPrimitive?.content).isEqualTo("row_a") + val versions = body["rowVersions"]!!.jsonObject + assertThat(versions["row_a"]?.jsonPrimitive?.int).isEqualTo(4) + assertThat(versions["row_b"]?.jsonPrimitive?.int).isEqualTo(7) + } + + @Test + fun `the live single-user answer means stop polling`() = runTest(dispatcher) { + server.enqueue( + MockResponse().setBody( + """{ "changed": [], "deleted": [], "users": [], "collaborative": false }""", + ), + ) + + val result = repository.pollFreshness("L1", emptyMap(), focusedRowId = null) + + val freshness = (result as ApiResult.Success).data + assertThat(freshness.collaborative).isFalse() + assertThat(freshness.hasChanges).isFalse() + assertThat(freshness.presence).isEmpty() + // No focused row means the key is simply omitted, not sent as null. + assertThat(server.takeRequest().body.readUtf8()).doesNotContain("focusedRowId") + } + + @Test + fun `changed rows deleted ids and presence are parsed`() = runTest(dispatcher) { + // `changed` and `deleted` are the live shapes; `users` follows the docs. + server.enqueue( + MockResponse().setBody( + """ + { + "changed": [ + { "id": "row_b", "version": 9, "rowData": { "status": "blocked", "count": 3 }, + "createdAt": "2026-09-16T21:20:00.069Z", "updatedAt": "2026-09-16T21:21:00.069Z", + "createdByUser": { "id": "usr_1", "username": "me", "displayName": "Me", "avatar": null }, + "lastEditedByUser": { "id": "usr_2", "username": "casey", "displayName": "Casey" } } + ], + "deleted": ["row_a"], + "users": [ + { "userId": "usr_2", "name": "Casey", "username": "casey", + "color": "#3366ff", "focusedRowId": "row_b" } + ], + "collaborative": true + } + """.trimIndent(), + ), + ) + + val freshness = (repository.pollFreshness("L1", mapOf("row_a" to 4, "row_b" to 7)) as ApiResult.Success).data + + assertThat(freshness.collaborative).isTrue() + assertThat(freshness.hasChanges).isTrue() + + val changed = freshness.changed.single() + assertThat(changed.id).isEqualTo("row_b") + assertThat(changed.version).isEqualTo(9) + assertThat(changed.valueFor("status")).isEqualTo("blocked") + // Any JSON value projects to a display string, exactly as fetched rows do. + assertThat(changed.valueFor("count")).isEqualTo("3") + + assertThat(freshness.deletedRowIds).containsExactly("row_a") + + val person = freshness.presence.single() + assertThat(person.userId).isEqualTo("usr_2") + assertThat(person.label).isEqualTo("Casey") + assertThat(person.initial).isEqualTo("C") + assertThat(person.focusedRowId).isEqualTo("row_b") + assertThat(person.color).isEqualTo("#3366ff") + } + + @Test + fun `unconfirmed shapes decode rather than failing the poll`() = runTest(dispatcher) { + // `data` instead of the live `rowData`, deleted as objects rather than + // strings, a user keyed by `id`/`displayName`, and extra keys throughout. + server.enqueue( + MockResponse().setBody( + """ + { + "changed": [ { "id": "row_b", "data": { "status": "shipped" }, "updatedAt": "now" } ], + "deleted": [ { "id": "row_a", "deletedAt": "now" } ], + "users": [ { "id": "usr_3", "displayName": "Robin", "avatar": null } ], + "collaborative": true, + "serverTime": "now" + } + """.trimIndent(), + ), + ) + + val freshness = (repository.pollFreshness("L1", mapOf("row_b" to 7)) as ApiResult.Success).data + + assertThat(freshness.changed.single().valueFor("status")).isEqualTo("shipped") + assertThat(freshness.deletedRowIds).containsExactly("row_a") + assertThat(freshness.presence.single().userId).isEqualTo("usr_3") + assertThat(freshness.presence.single().label).isEqualTo("Robin") + } + + @Test + fun `an empty body answer is tolerated`() = runTest(dispatcher) { + server.enqueue(MockResponse().setBody("{}")) + + val freshness = (repository.pollFreshness("L1", emptyMap()) as ApiResult.Success).data + + assertThat(freshness.changed).isEmpty() + assertThat(freshness.deletedRowIds).isEmpty() + assertThat(freshness.presence).isEmpty() + assertThat(freshness.collaborative).isFalse() + } + + @Test + fun `no more than the server's 500-row ceiling is ever asked about`() = runTest(dispatcher) { + server.enqueue(MockResponse().setBody("""{ "collaborative": true }""")) + val versions = (1..600).associate { "row_$it" to it } + + repository.pollFreshness("L1", versions) + + val body = json.parseToJsonElement(server.takeRequest().body.readUtf8()).jsonObject + assertThat(body["rowVersions"]!!.jsonObject).hasSize(500) + } +} + +private class FakeFreshnessDao : ListDao { + private val state = MutableStateFlow>(emptyList()) + override fun observeLists(): Flow> = state + override suspend fun upsertAll(lists: List) {} + override suspend fun upsert(list: CachedListEntity) {} + override suspend fun deleteById(id: String) {} + override suspend fun clear() {} +} diff --git a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepositoryTest.kt b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepositoryTest.kt index e3ace8b..661f5f5 100644 --- a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepositoryTest.kt +++ b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepositoryTest.kt @@ -140,6 +140,29 @@ class DefaultListsRepositoryTest { assertThat(detail.rows.single().valueFor("pages")).isEqualTo("412") } + @Test + fun `getListDetail reads the live rows payload with rowData and version`() = runTest(dispatcher) { + server.enqueue(MockResponse().setBody("""{ "list": { "id": "L1", "title": "Reading" } }""")) + server.enqueue(MockResponse().setBody("""[ { "key": "status", "type": "text" } ]""")) + // Exactly what the API returns: rows under `rows`, values under `rowData`. + server.enqueue( + MockResponse().setBody( + """ + { "rows": [ { "id": "r1", "rowData": { "status": "in review" }, "version": 1, + "lastEditedByUser": { "id": "u1", "username": "casey" } } ], + "pagination": { "total": 1, "limit": 20, "offset": 0, "hasMore": false } } + """.trimIndent(), + ), + ) + + val detail = (repository.getListDetail("L1") as ApiResult.Success).data + + val row = detail.rows.single() + assertThat(row.valueFor("status")).isEqualTo("in review") + // The version is what the freshness poll later quotes back. + assertThat(row.version).isEqualTo(1) + } + @Test fun `addRow posts the field map under data and returns the created row`() = runTest(dispatcher) { server.enqueue( diff --git a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/RowMapperTest.kt b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/RowMapperTest.kt index b79bf78..8300748 100644 --- a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/RowMapperTest.kt +++ b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/RowMapperTest.kt @@ -45,6 +45,28 @@ class RowMapperTest { assertThat(mapped.valueFor("meta")).isEqualTo("k: 1") } + @Test + fun `reads the live rowData key and keeps the row version`() { + val dto = RowDto( + id = "r1", + rowData = json.parseToJsonElement("""{ "status": "in review" }""").jsonObject, + version = 3, + ) + + val mapped = RowMapper.fromDto(dto) + + assertThat(mapped.valueFor("status")).isEqualTo("in review") + assertThat(mapped.version).isEqualTo(3) + } + + @Test + fun `a row with neither key maps to no values`() { + val mapped = RowMapper.fromDto(RowDto(id = "r1")) + + assertThat(mapped.values).isEmpty() + assertThat(mapped.version).isNull() + } + @Test fun `missing key returns empty via valueFor`() { val mapped = RowMapper.fromDto(row("""{ "present": "yes" }""")) diff --git a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailHeartbeatTest.kt b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailHeartbeatTest.kt new file mode 100644 index 0000000..ba1de56 --- /dev/null +++ b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailHeartbeatTest.kt @@ -0,0 +1,391 @@ +package com.interlinedlist.android.feature.lists.ui.detail + +import androidx.lifecycle.SavedStateHandle +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.feature.lists.FakeGithubRepository +import com.interlinedlist.android.feature.lists.FakeListsRepository +import com.interlinedlist.android.feature.lists.domain.FieldType +import com.interlinedlist.android.feature.lists.domain.ListDetail +import com.interlinedlist.android.feature.lists.domain.ListFreshness +import com.interlinedlist.android.feature.lists.domain.ListPresence +import com.interlinedlist.android.feature.lists.domain.ListRow +import com.interlinedlist.android.feature.lists.domain.ListSchema +import com.interlinedlist.android.feature.lists.domain.ListSummary +import com.interlinedlist.android.feature.lists.domain.SchemaField +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +/** + * The collaborative freshness poll / presence heartbeat on the list detail screen. + * + * It is a long-running loop, so these tests drive virtual time explicitly with + * [runCurrent]/[advanceTimeBy] and always stop the heartbeat before the test ends — + * never `advanceUntilIdle()`, which would never settle while the loop is running. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class ListDetailHeartbeatTest { + + private val dispatcher = StandardTestDispatcher() + + private val schema = ListSchema( + listOf( + SchemaField("title", "Title", FieldType.TEXT), + SchemaField("status", "Status", FieldType.TEXT), + ), + ) + + private val rows = listOf( + ListRow("r1", mapOf("title" to "Dune", "status" to "in review"), version = 4), + ListRow("r2", mapOf("title" to "Hyperion", "status" to "open"), version = 7), + ) + + private fun repoWithRows(rows: List = this.rows) = FakeListsRepository().apply { + detailResult = ApiResult.Success( + ListDetail( + summary = ListSummary("L1", "Reading", null, rows.size, null, false, null), + schema = schema, + rows = rows, + ), + ) + } + + private fun viewModel(repo: FakeListsRepository) = + ListDetailViewModel(repo, FakeGithubRepository(), SavedStateHandle(mapOf(LIST_ID_ARG to "L1"))) + + @Before fun setUp() = Dispatchers.setMain(dispatcher) + + @After fun tearDown() = Dispatchers.resetMain() + + @Test + fun `starting the heartbeat polls and keeps beating while the list is open`() = runTest(dispatcher) { + val repo = repoWithRows().apply { + freshnessResult = ApiResult.Success(ListFreshness(collaborative = true)) + } + val vm = viewModel(repo) + runCurrent() + + vm.startHeartbeat() + runCurrent() + assertThat(repo.pollCount).isEqualTo(1) + + // Nothing moved, so the loop backs off to the idle interval. + advanceTimeBy(ListDetailViewModel.ACTIVE_INTERVAL_MS + 100) + runCurrent() + assertThat(repo.pollCount).isEqualTo(1) + + advanceTimeBy(ListDetailViewModel.IDLE_INTERVAL_MS) + runCurrent() + assertThat(repo.pollCount).isEqualTo(2) + + vm.stopHeartbeat() + } + + @Test + fun `a beat that brings news keeps the fast interval`() = runTest(dispatcher) { + val repo = repoWithRows().apply { + freshnessResults = mutableListOf( + ApiResult.Success( + ListFreshness( + changed = listOf(ListRow("r1", mapOf("status" to "shipped"), version = 5)), + collaborative = true, + ), + ), + ) + freshnessResult = ApiResult.Success(ListFreshness(collaborative = true)) + } + val vm = viewModel(repo) + runCurrent() + + vm.startHeartbeat() + runCurrent() + assertThat(repo.pollCount).isEqualTo(1) + + advanceTimeBy(ListDetailViewModel.ACTIVE_INTERVAL_MS + 100) + runCurrent() + assertThat(repo.pollCount).isEqualTo(2) + + vm.stopHeartbeat() + } + + @Test + fun `stopping the heartbeat ends the polling and clears presence`() = runTest(dispatcher) { + val repo = repoWithRows().apply { + freshnessResult = ApiResult.Success( + ListFreshness( + presence = listOf(ListPresence("u2", displayName = "Casey", username = "casey")), + collaborative = true, + ), + ) + } + val vm = viewModel(repo) + runCurrent() + + vm.startHeartbeat() + runCurrent() + assertThat(vm.uiState.value.presence).hasSize(1) + val afterStart = repo.pollCount + + vm.stopHeartbeat() + runCurrent() + + assertThat(vm.uiState.value.presence).isEmpty() + advanceTimeBy(ListDetailViewModel.IDLE_INTERVAL_MS * 5) + runCurrent() + assertThat(repo.pollCount).isEqualTo(afterStart) + } + + @Test + fun `the heartbeat is idempotent - starting twice does not double the beats`() = runTest(dispatcher) { + val repo = repoWithRows().apply { + freshnessResult = ApiResult.Success(ListFreshness(collaborative = true)) + } + val vm = viewModel(repo) + runCurrent() + + vm.startHeartbeat() + vm.startHeartbeat() + runCurrent() + + assertThat(repo.pollCount).isEqualTo(1) + vm.stopHeartbeat() + } + + @Test + fun `the poll sends the focused row and the versions of the rows on screen`() = runTest(dispatcher) { + val repo = repoWithRows().apply { + freshnessResult = ApiResult.Success(ListFreshness(collaborative = true)) + } + val vm = viewModel(repo) + runCurrent() + + vm.setFocusedRow("r2") + vm.startHeartbeat() + runCurrent() + + assertThat(repo.lastPolledFocusedRowId).isEqualTo("r2") + assertThat(repo.lastPolledRowVersions).containsExactly("r1", 4, "r2", 7) + + // Leaving the row clears it again on the next beat. + vm.setFocusedRow(null) + advanceTimeBy(ListDetailViewModel.IDLE_INTERVAL_MS + 100) + runCurrent() + assertThat(repo.lastPolledFocusedRowId).isNull() + + vm.stopHeartbeat() + } + + @Test + fun `a row with no known version is not asked about`() = runTest(dispatcher) { + val repo = repoWithRows( + listOf( + ListRow("r1", mapOf("title" to "Dune"), version = 4), + ListRow("r2", mapOf("title" to "Hyperion")), + ), + ).apply { freshnessResult = ApiResult.Success(ListFreshness(collaborative = true)) } + val vm = viewModel(repo) + runCurrent() + + vm.startHeartbeat() + runCurrent() + + assertThat(repo.lastPolledRowVersions).containsExactly("r1", 4) + vm.stopHeartbeat() + } + + @Test + fun `changed rows are repainted without refetching the table`() = runTest(dispatcher) { + val repo = repoWithRows().apply { + freshnessResult = ApiResult.Success( + ListFreshness( + changed = listOf( + ListRow("r2", mapOf("title" to "Hyperion", "status" to "blocked"), version = 9), + ), + collaborative = true, + ), + ) + } + val vm = viewModel(repo) + runCurrent() + val detailCallsBefore = repo.detailCount + + vm.startHeartbeat() + runCurrent() + + val state = vm.uiState.value + assertThat(state.rows.map { it.id }).containsExactly("r1", "r2").inOrder() + // Only the changed row moved; the untouched one is the same instance. + assertThat(state.rows[0]).isEqualTo(rows[0]) + assertThat(state.rows[1].valueFor("status")).isEqualTo("blocked") + assertThat(state.rows[1].version).isEqualTo(9) + // The whole table was NOT refetched — that is the point of the endpoint. + assertThat(repo.detailCount).isEqualTo(detailCallsBefore) + + vm.stopHeartbeat() + } + + @Test + fun `deleted rows are dropped from the table`() = runTest(dispatcher) { + val repo = repoWithRows().apply { + freshnessResult = ApiResult.Success( + ListFreshness(deletedRowIds = listOf("r1"), collaborative = true), + ) + } + val vm = viewModel(repo) + runCurrent() + val detailCallsBefore = repo.detailCount + + vm.startHeartbeat() + runCurrent() + + assertThat(vm.uiState.value.rows.map { it.id }).containsExactly("r2") + assertThat(repo.detailCount).isEqualTo(detailCallsBefore) + + vm.stopHeartbeat() + } + + @Test + fun `presence from the poll is exposed to the screen`() = runTest(dispatcher) { + val repo = repoWithRows().apply { + freshnessResult = ApiResult.Success( + ListFreshness( + presence = listOf( + ListPresence("u2", displayName = "Casey", username = "casey", focusedRowId = "r2"), + ), + collaborative = true, + ), + ) + } + val vm = viewModel(repo) + runCurrent() + + vm.startHeartbeat() + runCurrent() + + val presence = vm.uiState.value.presence + assertThat(presence.map { it.userId }).containsExactly("u2") + assertThat(presence.first().focusedRowId).isEqualTo("r2") + assertThat(vm.uiState.value.isCollaborative).isTrue() + + vm.stopHeartbeat() + } + + @Test + fun `a list nobody else can see is polled once and then left alone`() = runTest(dispatcher) { + val repo = repoWithRows().apply { + freshnessResult = ApiResult.Success(ListFreshness(collaborative = false)) + } + val vm = viewModel(repo) + runCurrent() + + vm.startHeartbeat() + runCurrent() + assertThat(repo.pollCount).isEqualTo(1) + assertThat(vm.uiState.value.isCollaborative).isFalse() + + advanceTimeBy(ListDetailViewModel.IDLE_INTERVAL_MS * 10) + runCurrent() + + assertThat(repo.pollCount).isEqualTo(1) + } + + @Test + fun `a failed beat backs off instead of retrying hard`() = runTest(dispatcher) { + val repo = repoWithRows().apply { + freshnessResult = ApiResult.Failure(AppError.Network("offline")) + } + val vm = viewModel(repo) + runCurrent() + + vm.startHeartbeat() + runCurrent() + assertThat(repo.pollCount).isEqualTo(1) + + advanceTimeBy(ListDetailViewModel.ACTIVE_INTERVAL_MS + 100) + runCurrent() + assertThat(repo.pollCount).isEqualTo(1) + + advanceTimeBy(ListDetailViewModel.IDLE_INTERVAL_MS) + runCurrent() + assertThat(repo.pollCount).isEqualTo(2) + + vm.stopHeartbeat() + } + + @Test + fun `polling stops after ten quiet minutes and an edit revives it`() = runTest(dispatcher) { + val repo = repoWithRows().apply { + freshnessResult = ApiResult.Success(ListFreshness(collaborative = true)) + } + val vm = viewModel(repo) + runCurrent() + + vm.startHeartbeat() + runCurrent() + advanceTimeBy(ListDetailViewModel.MAX_IDLE_MS + ListDetailViewModel.IDLE_INTERVAL_MS) + runCurrent() + val quiesced = repo.pollCount + + advanceTimeBy(ListDetailViewModel.IDLE_INTERVAL_MS * 5) + runCurrent() + assertThat(repo.pollCount).isEqualTo(quiesced) + + // Touching a row starts it up again, without the screen doing anything. + vm.setFocusedRow("r1") + runCurrent() + assertThat(repo.pollCount).isEqualTo(quiesced + 1) + + vm.stopHeartbeat() + } + + @Test + fun `a non-collaborative list is not revived by interaction`() = runTest(dispatcher) { + val repo = repoWithRows().apply { + freshnessResult = ApiResult.Success(ListFreshness(collaborative = false)) + } + val vm = viewModel(repo) + runCurrent() + + vm.startHeartbeat() + runCurrent() + assertThat(repo.pollCount).isEqualTo(1) + + vm.setFocusedRow("r1") + vm.deleteRow("r2") + runCurrent() + + assertThat(repo.pollCount).isEqualTo(1) + vm.stopHeartbeat() + } + + @Test + fun `nothing beats again after the screen is left, even if an edit lands`() = runTest(dispatcher) { + val repo = repoWithRows().apply { + freshnessResult = ApiResult.Success(ListFreshness(collaborative = true)) + } + val vm = viewModel(repo) + runCurrent() + + vm.startHeartbeat() + runCurrent() + val afterStart = repo.pollCount + + vm.stopHeartbeat() + vm.setFocusedRow("r1") + vm.addRow(mapOf("title" to "Ubik")) + advanceTimeBy(ListDetailViewModel.IDLE_INTERVAL_MS * 3) + runCurrent() + + assertThat(repo.pollCount).isEqualTo(afterStart) + } +} diff --git a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/presence/ListPresenceTest.kt b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/presence/ListPresenceTest.kt new file mode 100644 index 0000000..28c16cd --- /dev/null +++ b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/presence/ListPresenceTest.kt @@ -0,0 +1,42 @@ +package com.interlinedlist.android.feature.lists.ui.presence + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.feature.lists.domain.ListPresence +import org.junit.Test + +/** + * How a present person is labelled. The presence payload's field names were not + * observable live (the array was empty on a single-user list), so every part of + * the name is optional and the fallbacks have to hold. + */ +class ListPresenceTest { + + @Test + fun `prefers a display name`() { + val person = ListPresence("u2", displayName = "Casey Jones", username = "casey") + assertThat(person.label).isEqualTo("Casey Jones") + assertThat(person.initial).isEqualTo("C") + } + + @Test + fun `falls back to the username then the id`() { + assertThat(ListPresence("u2", displayName = " ", username = "casey").label).isEqualTo("casey") + assertThat(ListPresence("u2").label).isEqualTo("u2") + assertThat(ListPresence("u2").initial).isEqualTo("U") + } + + @Test + fun `describes who is here for a screen reader`() { + assertThat(describe(emptyList())).isEmpty() + assertThat(describe(listOf(ListPresence("u2", displayName = "Casey")))) + .isEqualTo("Casey is also here") + assertThat( + describe( + listOf( + ListPresence("u2", displayName = "Casey"), + ListPresence("u3", username = "robin"), + ), + ), + ).isEqualTo("Casey, robin are also here") + } +}