diff --git a/app/src/main/java/chat/matron/android/AppDependencies.kt b/app/src/main/java/chat/matron/android/AppDependencies.kt index 73f45fa..a199b20 100644 --- a/app/src/main/java/chat/matron/android/AppDependencies.kt +++ b/app/src/main/java/chat/matron/android/AppDependencies.kt @@ -346,8 +346,8 @@ class AppDependencies( api, db, dbFile, store, engine, maintenance, itemsSync, missionsSync, memoriesSync, pinsSync = pinsSync, coordinatorSync = coordinatorSync, ) - core.itemsSync.onInviteLink = { link -> - core.inviteLinks.show(link, chat.matron.android.journal.RevealedInviteLinks.Source.ASK) + core.itemsSync.onInviteLink = { link, askId -> + core.inviteLinks.show(link, chat.matron.android.journal.RevealedInviteLinks.Source.ASK, askId) } cores[session.userID] = core // Nothing proportional to store history runs on the launch path any diff --git a/app/src/main/java/chat/matron/android/features/chat/ChatScreen.kt b/app/src/main/java/chat/matron/android/features/chat/ChatScreen.kt index 87ed039..b6ff268 100644 --- a/app/src/main/java/chat/matron/android/features/chat/ChatScreen.kt +++ b/app/src/main/java/chat/matron/android/features/chat/ChatScreen.kt @@ -812,6 +812,7 @@ private fun TimelineRowView( val grantKeepLevels by chatVM.grantKeepLevels.collectAsStateWithLifecycle() val inviteAskStates by chatVM.inviteAskStates.collectAsStateWithLifecycle() val inviteAskResolutions by chatVM.inviteAskResolutions.collectAsStateWithLifecycle() + val inviteAsksApprovedHere by chatVM.inviteAsksApprovedHere.collectAsStateWithLifecycle() // A bridge queued_release lands as a hidden row, so no visible row changes; // collecting the memo is what recomposes the sibling queue cards' buttons // (apple #162; Bugbot #48). @@ -894,7 +895,10 @@ private fun TimelineRowView( onGrantKeepLevelChange = { eventID, level -> chatVM.setGrantKeepLevel(eventID, level) }, inviteAsks = InviteAskHost( // The flows read here are what make the card recompose. - state = { eventID -> inviteAskStates.let { inviteAskResolutions }.let { chatVM.inviteAskState(eventID) } }, + state = { eventID, ask -> + inviteAskStates.let { inviteAskResolutions }.let { inviteAsksApprovedHere } + .let { chatVM.inviteAskState(eventID, ask) } + }, answer = { eventID, ask, approve -> chatVM.answerInviteAsk(eventID, ask, approve) }, ), ) diff --git a/app/src/main/java/chat/matron/android/features/chat/TimelineItemView.kt b/app/src/main/java/chat/matron/android/features/chat/TimelineItemView.kt index 2abf27d..0974a7b 100644 --- a/app/src/main/java/chat/matron/android/features/chat/TimelineItemView.kt +++ b/app/src/main/java/chat/matron/android/features/chat/TimelineItemView.kt @@ -345,7 +345,7 @@ private fun RenderedBody( CappedCard(maxWidth = 360.dp) { ContactInviteAskCard( ask = kind.ask, - state = inviteAsks?.state?.invoke(kind.eventID) ?: InviteAskState.Unavailable, + state = inviteAsks?.state?.invoke(kind.eventID, kind.ask) ?: InviteAskState.Unavailable, onApprove = { inviteAsks?.answer?.invoke(kind.eventID, kind.ask, true) }, onDecline = { inviteAsks?.answer?.invoke(kind.eventID, kind.ask, false) }, ) @@ -611,6 +611,6 @@ data class TappedImage(val url: String?, val model: Any, val name: String? = nul /// What an invite ask card in the timeline reads and calls: its state and /// the answer. An approved link is shown by the shell, not the card. class InviteAskHost( - val state: (eventID: String) -> InviteAskState, + val state: (eventID: String, ask: ContactInviteAsk) -> InviteAskState, val answer: (eventID: String, ask: ContactInviteAsk, approve: Boolean) -> Unit, ) diff --git a/app/src/main/java/chat/matron/android/journal/Contacts.kt b/app/src/main/java/chat/matron/android/journal/Contacts.kt index 28c93b5..22d89dd 100644 --- a/app/src/main/java/chat/matron/android/journal/Contacts.kt +++ b/app/src/main/java/chat/matron/android/journal/Contacts.kt @@ -402,7 +402,18 @@ class RevealedInviteLinks(val scope: kotlinx.coroutines.CoroutineScope) { /// The links waiting to be shown, oldest first: the first is on screen. val current: kotlinx.coroutines.flow.StateFlow> = _pending - fun show(link: String, source: Source) { + private val _approvedHere = kotlinx.coroutines.flow.MutableStateFlow>(emptySet()) + + /// The ids of the agents' asks this device approved in this session, + /// whichever screen the tap was on (the chat card or the ask's tracker + /// item), so the chat card reads "Approved" rather than "Approved on + /// another device". Ids only, never the link; never persisted. + val approvedHere: kotlinx.coroutines.flow.StateFlow> = _approvedHere + + /// Queues [link] to be shown once. [askId] names the agent's ask it + /// answered, when there is one. + fun show(link: String, source: Source, askId: String? = null) { + askId?.let { id -> _approvedHere.update { it + id } } _pending.update { it + Reveal(link, source) } } @@ -416,5 +427,6 @@ class RevealedInviteLinks(val scope: kotlinx.coroutines.CoroutineScope) { fun clearAll() { _pending.value = emptyList() + _approvedHere.value = emptySet() } } diff --git a/app/src/main/java/chat/matron/android/journal/ItemsApi.kt b/app/src/main/java/chat/matron/android/journal/ItemsApi.kt index 1ac28b9..01526f8 100644 --- a/app/src/main/java/chat/matron/android/journal/ItemsApi.kt +++ b/app/src/main/java/chat/matron/android/journal/ItemsApi.kt @@ -63,7 +63,14 @@ data class ItemDetail(val item: TrackerItem, val comments: List) /// [link] is an invite link, on the answer to an Approve tap on an agent's /// invite ask only — the https form when the journal sends it, else the /// `matron://` one: shown once on this device and never stored. -data class ItemCommentResult(val item: TrackerItem, val comment: TrackerComment, val link: String? = null) +/// [link] and [askId] come back only from an Approve tap on an agent's +/// invite ask mirror: the link to show once, and which ask it answered. +data class ItemCommentResult( + val item: TrackerItem, + val comment: TrackerComment, + val link: String? = null, + val askId: String? = null, +) /// `POST /items` body. [awaiting] is tri-state like the Swift `ItemAwaiting??`: /// [awaitingSet] false ⇒ key omitted (server default), true with a null diff --git a/app/src/main/java/chat/matron/android/journal/ItemsSync.kt b/app/src/main/java/chat/matron/android/journal/ItemsSync.kt index f57ce58..37cf50d 100644 --- a/app/src/main/java/chat/matron/android/journal/ItemsSync.kt +++ b/app/src/main/java/chat/matron/android/journal/ItemsSync.kt @@ -185,9 +185,10 @@ class ItemsSync( private var stopped = false /// Receives the invite link an Approve tap on an agent's invite ask - /// mirror brings back, to show once. Never stored here. + /// mirror brings back, to show once, with the id of the ask it answered + /// (when the journal names it). Never stored here. @Volatile - var onInviteLink: ((String) -> Unit)? = null + var onInviteLink: ((link: String, askId: String?) -> Unit)? = null private val _isSupported = MutableStateFlow(true) override val isSupported: StateFlow = _isSupported.asStateFlow() @@ -674,7 +675,7 @@ class ItemsSync( // outbox — without this the reply would be invisible // until the detail screen is reopened. store.commitOutboxResult(result.item, result.comment, deletingLocalID = row.localID) - result.link?.let { link -> onInviteLink?.invoke(link) } + result.link?.let { link -> onInviteLink?.invoke(link, result.askId) } refreshItem(itemID) } ItemOutboxEntity.OP_CREATE -> { diff --git a/app/src/main/java/chat/matron/android/journal/JournalApi.kt b/app/src/main/java/chat/matron/android/journal/JournalApi.kt index bdf1a51..f8e4b9c 100644 --- a/app/src/main/java/chat/matron/android/journal/JournalApi.kt +++ b/app/src/main/java/chat/matron/android/journal/JournalApi.kt @@ -1073,7 +1073,11 @@ class JournalApi( ?: throw JournalApiError.Transport("malformed comment response") // A tap on an invite ask's mirror answers with the link, this once: // the https form when the journal sends it, else the matron:// one. - return ItemCommentResult(decodeItem(obj), comment, link = InviteLinks.shareableOf(obj)) + return ItemCommentResult( + decodeItem(obj), comment, + link = InviteLinks.shareableOf(obj), + askId = obj.objectOrNull("ask")?.stringOrNull("id")?.takeIf { it.isNotEmpty() }, + ) } override suspend fun closeItem(id: String, resolution: ItemResolution, comment: String?): TrackerItem = diff --git a/app/src/main/java/chat/matron/android/viewmodels/ChatViewModel.kt b/app/src/main/java/chat/matron/android/viewmodels/ChatViewModel.kt index 88e474b..0a5a392 100644 --- a/app/src/main/java/chat/matron/android/viewmodels/ChatViewModel.kt +++ b/app/src/main/java/chat/matron/android/viewmodels/ChatViewModel.kt @@ -802,9 +802,16 @@ class ChatViewModel( private val _inviteAskResolutions = MutableStateFlow>(emptyMap()) val inviteAskResolutions: StateFlow> = _inviteAskResolutions.asStateFlow() - /// Render state for one ask card: this device's own answer, then the - /// timeline's record, then Idle. - fun inviteAskState(eventID: String): InviteAskState { + /// The asks this device approved this session, from the chat card or + /// the ask's tracker item. Collected by the chat so a tracker-item tap + /// redraws the card. + val inviteAsksApprovedHere: StateFlow> = + inviteLinks?.approvedHere ?: MutableStateFlow>(emptySet()).asStateFlow() + + /// Render state for one ask card: this device's own answer (on the card, + /// or on [ask]'s tracker item this session), then the timeline's record, + /// then Idle. + fun inviteAskState(eventID: String, ask: ContactInviteAsk? = null): InviteAskState { _inviteAskStates.value[eventID]?.let { if (it !is InviteAskState.Idle) return it } val local = inviteAnswers[eventID] when (local) { @@ -812,6 +819,9 @@ class ChatViewModel( "decline" -> return InviteAskState.Declined EXPIRED_ANSWER -> return InviteAskState.Expired } + // Approved on this device's tracker item: the mirror's close (and a + // later card tap's `not_pending`) cannot tell that from another device. + if (ask != null && ask.askId in inviteAsksApprovedHere.value) return InviteAskState.Approved _inviteAskResolutions.value[eventID]?.let { return it } if (local == INVITE_ELSEWHERE) return InviteAskState.ApprovedElsewhere return if (inviteAsks == null) InviteAskState.Unavailable else InviteAskState.Idle @@ -821,12 +831,12 @@ class ChatViewModel( /// [inviteLinks], which shows it once. fun answerInviteAsk(eventID: String, ask: ContactInviteAsk, approve: Boolean) { val answerer = inviteAsks ?: return - if (!inviteAskState(eventID).answerable) return + if (!inviteAskState(eventID, ask).answerable) return _inviteAskStates.value = _inviteAskStates.value + (eventID to InviteAskState.Sending) (inviteLinks?.scope ?: scope).launch { try { val answer = answerer.answerInviteAsk(ask.askId, approve) - answer.link?.let { inviteLinks?.show(it, RevealedInviteLinks.Source.ASK) } + answer.link?.let { inviteLinks?.show(it, RevealedInviteLinks.Source.ASK, ask.askId) } rememberInviteAnswer(eventID, if (approve) "approve" else "decline") _inviteAskStates.value = _inviteAskStates.value - eventID } catch (cancel: CancellationException) { diff --git a/app/src/test/java/chat/matron/android/journal/ContactsApiTest.kt b/app/src/test/java/chat/matron/android/journal/ContactsApiTest.kt index 432de18..8aab10d 100644 --- a/app/src/test/java/chat/matron/android/journal/ContactsApiTest.kt +++ b/app/src/test/java/chat/matron/android/journal/ContactsApiTest.kt @@ -151,6 +151,7 @@ class ContactsApiTest { "ask":{"id":"ia_1","state":"approved"},"invite":{"id":"inv_1"},"link":"$link"}""")) val result = api().commentItem("it_1", "Approve", emptyList(), idempotencyKey = null, action = "Approve") assertEquals(link, result.link) + assertEquals("ia_1", result.askId) assertEquals("it_1", result.comment.itemID) } diff --git a/app/src/test/java/chat/matron/android/journal/ItemsSyncTest.kt b/app/src/test/java/chat/matron/android/journal/ItemsSyncTest.kt index 91c13cc..449e632 100644 --- a/app/src/test/java/chat/matron/android/journal/ItemsSyncTest.kt +++ b/app/src/test/java/chat/matron/android/journal/ItemsSyncTest.kt @@ -157,9 +157,14 @@ private class FakeItems : ItemsProviding { synchronized(lock) { _commentErrorForItemID[id] }?.let { throw it } if (failComments) throw JournalApiError.Transport("offline") val item = TrackerItem(id = id, num = 1, kind = ItemKind.QUESTION, awaiting = ItemAwaiting.AGENT, title = "Q", originConvoID = "c1") - return ItemCommentResult(item, TrackerComment("ic_srv", id, ItemAuthor.USER, body = body)) + val answer = inviteAnswer + return ItemCommentResult(item, TrackerComment("ic_srv", id, ItemAuthor.USER, body = body), answer?.first, answer?.second) } + /// (link, ask id) every comment call answers with, as an Approve tap on + /// an invite ask's mirror does. + @Volatile var inviteAnswer: Pair? = null + override suspend fun closeItem(id: String, resolution: ItemResolution, comment: String?): TrackerItem = error("unused") override suspend fun reopenItem(id: String, comment: String?): TrackerItem = error("unused") override suspend fun rankItem(id: String, change: ItemRankChange): TrackerItem = error("unused") @@ -431,6 +436,19 @@ class ItemsSyncTest { rig.sync.stop() } + @Test + fun anInviteAskApproveHandsOverTheLinkAndTheAskItAnswered() = runBlocking { + val api = FakeItems(); api.inviteAnswer = "matron://contact?v=1" to "ia_1" + val rig = make(api) + val received = java.util.concurrent.CopyOnWriteArrayList>() + rig.sync.onInviteLink = { link, askId -> received.add(link to askId) } + rig.sync.start() + rig.sync.enqueueComment("it_1", "L1", "Approve", emptyList(), action = "Approve") + waitUntil { received.isNotEmpty() } + assertEquals(listOf("matron://contact?v=1" to "ia_1"), received.toList()) + rig.sync.stop() + } + @Test fun outboxDrainsOnRunningAndDeletesOnSuccess() = runBlocking { val api = FakeItems(); api.failComments = true diff --git a/app/src/test/java/chat/matron/android/viewmodels/InviteAskViewModelTest.kt b/app/src/test/java/chat/matron/android/viewmodels/InviteAskViewModelTest.kt index 40a7f03..c8ece47 100644 --- a/app/src/test/java/chat/matron/android/viewmodels/InviteAskViewModelTest.kt +++ b/app/src/test/java/chat/matron/android/viewmodels/InviteAskViewModelTest.kt @@ -2,6 +2,15 @@ package chat.matron.android.viewmodels import chat.matron.android.chat.FakeMediaService import chat.matron.android.chat.FakeTimelineService +import chat.matron.android.chat.JournalTimelineMapper +import chat.matron.android.chat.TimelineItem +import chat.matron.android.journal.JournalEvent +import chat.matron.android.journal.parseJsonObjectOrNull +import java.time.Instant +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonObject +import okhttp3.HttpUrl.Companion.toHttpUrl import chat.matron.android.events.ContactInviteAsk import chat.matron.android.events.InviteAskState import chat.matron.android.journal.ContactRefusal @@ -51,7 +60,28 @@ class InviteAskViewModelTest { store: InMemoryKeyValueStore, answerer: InviteAskAnswering?, links: RevealedInviteLinks = RevealedInviteLinks(scope), - ) = ChatViewModel("!r:s", FakeTimelineService(), FakeMediaService(), scope, store, inviteAsks = answerer, inviteLinks = links) + timeline: FakeTimelineService = FakeTimelineService(), + ) = ChatViewModel("!r:s", timeline, FakeMediaService(), scope, store, inviteAsks = answerer, inviteLinks = links) + + private fun event(seq: Long, type: String, payload: kotlinx.serialization.json.JsonObject): TimelineItem = + JournalTimelineMapper.timelineItem( + JournalEvent(seq, "!r:s", Instant.ofEpochSecond(seq), "agent:box-a", type, payload), + "user:alice", "https://example.com".toHttpUrl(), + )!! + + /// The ask card (seq 10) and its tracker mirror's close (seq 12), the + /// way the journal records an approve made on the mirror item. + private fun approvedTimeline(): FakeTimelineService = FakeTimelineService().apply { + val card = event(10, "permission_request", parseJsonObjectOrNull( + """{"kind":"contact_invite","direction":"out","ask_id":"ia_1","from_name":"box-a"}""", + )!!) + val close = event(12, "item", buildJsonObject { + put("item_id", "it_1"); put("num", 1); put("kind", "question"); put("title", "Approve making an invite link") + put("by", "user"); put("action", "closed"); put("resolution", "decided"); put("consent", "invite") + putJsonObject("comment") { put("id", "c12"); put("body", "Approved — the link was shown on the device you tapped on.") } + }) + snapshotsToEmit = listOf(listOf(card, close)) + } @Test fun readOnlyWithNoAnswerer() = vmTest { scope -> @@ -107,4 +137,51 @@ class InviteAskViewModelTest { assertEquals("You already have 20 open invites. Withdraw one first.", state.message) assertTrue(state.answerable) } + + @Test + fun anApproveOnTheTrackerItemHereReadsAsApprovedOnTheCard() = vmTest { scope -> + val links = RevealedInviteLinks(scope) + val vm = vm(scope, InMemoryKeyValueStore(), Answerer(), links, approvedTimeline()) + vm.start().join() + waitUntil { vm.inviteAskResolutions.value.isNotEmpty() } + // The tracker item's Approve came back with the link on this device. + links.show(link, RevealedInviteLinks.Source.ASK, askId = "ia_1") + assertEquals(InviteAskState.Approved, vm.inviteAskState("10", ask)) + assertEquals("Approved", vm.inviteAskState("10", ask).resolvedText) + } + + @Test + fun anApproveFromAnotherDeviceStillReadsAsElsewhere() = vmTest { scope -> + val links = RevealedInviteLinks(scope) + val vm = vm(scope, InMemoryKeyValueStore(), Answerer(), links, approvedTimeline()) + vm.start().join() + waitUntil { vm.inviteAskResolutions.value.isNotEmpty() } + // Another ask approved here does not make this one ours. + links.show(link, RevealedInviteLinks.Source.ASK, askId = "ia_other") + assertEquals(InviteAskState.ApprovedElsewhere, vm.inviteAskState("10", ask)) + assertEquals("Approved on another device.", vm.inviteAskState("10", ask).resolvedText) + } + + @Test + fun aCardTapAfterApprovingOnTheTrackerItemHereReadsAsApproved() = vmTest { scope -> + val links = RevealedInviteLinks(scope) + links.show(link, RevealedInviteLinks.Source.ASK, askId = "ia_1") + val vm = vm(scope, InMemoryKeyValueStore(), Answerer(error = ContactRefusal(409, "not_pending")), links) + assertEquals(InviteAskState.Approved, vm.inviteAskState("10", ask)) + } + + @Test + fun theApprovedHereSetIsMemoryOnlyAndKeepsNoLink() = vmTest { scope -> + val store = InMemoryKeyValueStore() + val links = RevealedInviteLinks(scope) + links.show(link, RevealedInviteLinks.Source.ASK, askId = "ia_1") + assertEquals(setOf("ia_1"), links.approvedHere.value) + links.done(links.current.value.single()) + // Done drops the link, never the fact that it was approved here. + assertEquals(setOf("ia_1"), links.approvedHere.value) + vm(scope, store, Answerer(), links).inviteAskState("10", ask) + assertTrue(store.getStringList("matron.inviteAskAnswers.!r:s").orEmpty().isEmpty()) + links.clearAll() + assertTrue(links.approvedHere.value.isEmpty()) + } }