From f37011ec967b5cf34236a5a04622c7191f408538 Mon Sep 17 00:00:00 2001 From: toddmitchell Date: Fri, 19 Jun 2026 12:28:04 -0500 Subject: [PATCH 01/23] Contact migration step 1: shared name helpers + Contact->ContactBookEntry projection Additive foundation for migrating the contact book onto ContactRepository: - ContactName.resolveDisplayName/initials (homebase-api), shared by all consumers. - Contact.toContactBookEntry() projection in core (server-shaped Contact -> flat UI model), display name via the shared resolver, image fields from ContactImageRef. No consumers rewired yet; nothing removed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../client/contacts/ContactNameExtensions.kt | 48 +++++++++++++++++++ .../contactbook/model/ContactBookEntry.kt | 39 +++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactNameExtensions.kt diff --git a/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactNameExtensions.kt b/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactNameExtensions.kt new file mode 100644 index 000000000..d18ab56b3 --- /dev/null +++ b/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactNameExtensions.kt @@ -0,0 +1,48 @@ +package id.homebase.api.client.contacts + +/** + * Single source of truth for turning a contact's [ContactName] into the values UIs render, shared + * by every consumer of [ContactRepository] so display-name/initials logic can't drift. + * + * The name is stored redundantly (a combined `displayName` plus `givenName`/`surname` parts), and + * for connection/profile-synced contacts only `displayName` is set — these helpers encode the one + * agreed resolution order. + */ + +/** + * Best display name: `displayName`, else `givenName surname`, else the identity/contact-point + * fallbacks in [odinId] → [phone] → [email] order. Null only when nothing renderable exists. + */ +fun ContactName?.resolveDisplayName( + odinId: String? = null, + phone: String? = null, + email: String? = null, +): String? { + this?.displayName?.takeIf { it.isNotBlank() }?.let { return it } + val composed = listOfNotNull(this?.givenName, this?.surname) + .joinToString(" ") + .trim() + if (composed.isNotBlank()) return composed + odinId?.takeIf { it.isNotBlank() }?.let { return it } + phone?.takeIf { it.isNotBlank() }?.let { return it } + email?.takeIf { it.isNotBlank() }?.let { return it } + return null +} + +/** + * Avatar initials: first letters of given+surname when both present, else first letters of the + * first/last whitespace tokens of `displayName`, else `"?"`. + */ +fun ContactName?.initials(): String { + val first = this?.givenName?.trim()?.takeIf { it.isNotEmpty() }?.firstOrNull() + val last = this?.surname?.trim()?.takeIf { it.isNotEmpty() }?.firstOrNull() + if (first != null && last != null) return "$first$last".uppercase() + + val display = this?.displayName ?: return "?" + val tokens = display.trim().split("\\s+".toRegex()).filter { it.isNotEmpty() } + return when { + tokens.size >= 2 -> "${tokens.first().first()}${tokens.last().first()}".uppercase() + tokens.size == 1 -> tokens.first().first().uppercaseChar().toString() + else -> "?" + } +} diff --git a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/model/ContactBookEntry.kt b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/model/ContactBookEntry.kt index 33afcd426..a9e1b20d8 100644 --- a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/model/ContactBookEntry.kt +++ b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/model/ContactBookEntry.kt @@ -4,6 +4,7 @@ package id.homebase.core.ui.screens.contactbook.model import androidx.compose.runtime.Immutable import id.homebase.api.client.KeyHeader +import id.homebase.api.client.contacts.Contact import id.homebase.api.client.contacts.ContactBirthday import id.homebase.api.client.contacts.ContactContent import id.homebase.api.client.contacts.ContactEmail @@ -11,6 +12,7 @@ import id.homebase.api.client.contacts.ContactLocation import id.homebase.api.client.contacts.ContactName import id.homebase.api.client.contacts.ContactPhone import id.homebase.api.client.contacts.ContactsProvider +import id.homebase.api.client.contacts.resolveDisplayName import id.homebase.api.client.drives.HomebaseFile import id.homebase.api.client.drives.files.PayloadDescriptor import id.homebase.api.client.drives.upload.EmbeddedThumb @@ -186,3 +188,40 @@ fun HomebaseFile.toContactBookEntry(): ContactBookEntry? { imagePayload = imagePayload, ) } + +/** + * Projects the server-shaped [Contact] domain model (from `ContactRepository`) into the flat + * contact-manager UI model. The display name is resolved via the shared + * [id.homebase.api.client.contacts.resolveDisplayName] so it can't drift from other consumers; + * null when nothing is renderable. Image-display fields come from [Contact.image]. + */ +fun Contact.toContactBookEntry(): ContactBookEntry? { + val name = content.name + val display = name.resolveDisplayName( + odinId = content.odinId, + phone = content.phone?.number, + email = content.email?.email, + ) ?: return null + + return ContactBookEntry( + uniqueId = uniqueId, + fileId = image?.fileId ?: uniqueId, + versionTag = versionTag, + odinId = content.odinId, + displayName = display, + givenName = name?.givenName, + additionalName = name?.additionalName, + surname = name?.surname, + phone = content.phone?.number, + email = content.email?.email, + city = content.location?.city, + country = content.location?.country, + birthday = content.birthday?.date, + source = content.source, + driveId = image?.driveId, + keyHeader = image?.keyHeader, + isEncrypted = image?.isEncrypted ?: false, + previewThumbnail = image?.previewThumbnail, + imagePayload = image?.payload, + ) +} From 239a0e914d2212e98338093802a71c0b347e99a3 Mon Sep 17 00:00:00 2001 From: toddmitchell Date: Fri, 19 Jun 2026 13:52:59 -0500 Subject: [PATCH 02/23] Contact migration step 2: contact book consumes ContactRepository MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewire the contact book onto the single source of truth and delete the core-side read/write wrappers: - ContactBookViewModel + ContactDetailViewModel inject ContactRepository instead of ContactBookStream + ContactBookService. They project repo.contacts (List) -> ContactBookEntry via the new projection; reads use repo.isLoaded/ensureLoaded; writes use repo.save/delete/sync/setImage with the optimistic update owned by the repo (no more stream insert/remove dance). - ContactSaveHelper saves through ContactRepository; ContactSaveResult.Success no longer carries an optimistic entry. - Delete ContactBookStream + ContactBookService; AppModule registers neither and the post-auth bootstrap starts ContactRepository. - ContactDetailViewModel now ensureLoaded()s on deep-link. Main-parity (the §7/#6 fixes and shared helpers are re-applied next). Core + Konsist + api jvmTest suites pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../kotlin/id/homebase/core/di/AppModule.kt | 9 +- .../screens/contactbook/ContactBookService.kt | 109 ---------- .../screens/contactbook/ContactBookStream.kt | 194 ------------------ .../contactbook/ContactBookViewModel.kt | 30 +-- .../screens/contactbook/ContactSaveHelper.kt | 52 ++--- .../detail/ContactDetailViewModel.kt | 33 ++- 6 files changed, 50 insertions(+), 377 deletions(-) delete mode 100644 homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/ContactBookService.kt delete mode 100644 homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/ContactBookStream.kt diff --git a/homebase-core/src/commonMain/kotlin/id/homebase/core/di/AppModule.kt b/homebase-core/src/commonMain/kotlin/id/homebase/core/di/AppModule.kt index ad03c8791..0954f7431 100644 --- a/homebase-core/src/commonMain/kotlin/id/homebase/core/di/AppModule.kt +++ b/homebase-core/src/commonMain/kotlin/id/homebase/core/di/AppModule.kt @@ -68,8 +68,7 @@ import id.homebase.core.auth.AuthConnectionCoordinator import id.homebase.core.util.PlatformInfo import id.homebase.core.vault.VaultPreferences import id.homebase.core.contactbook.ContactBookPreferences -import id.homebase.core.ui.screens.contactbook.ContactBookService -import id.homebase.core.ui.screens.contactbook.ContactBookStream +import id.homebase.api.client.contacts.ContactRepository import id.homebase.core.ui.screens.contactbook.ContactBookViewModel import id.homebase.core.ui.screens.contactbook.detail.ContactDetailViewModel import id.homebase.core.ui.screens.contactbook.settings.ContactBookSettingsViewModel @@ -210,8 +209,8 @@ val appModule = module { // drive; writes through the api-layer ContactsProvider. No optional-drive // activation — the drive is always mounted. single { ContactBookPreferences(get()) } - singleOf(::ContactBookStream) - single { ContactBookService(get()) } + // Read+write contact source of truth lives in homebase-api (ContactRepository); the contact + // book consumes it directly. No core-side stream/service wrapper. // region Location add-on single { LocationPreferences(get()) } @@ -431,7 +430,7 @@ val appModule = module { // Contact Book: re-seed prefs + reload the contact list for the new // identity (singletons survive logout — clear stale in-memory state). get().reset() - get().apply { reset(); start() } + get().apply { reset(); start() } // Hydrate the saved-stickers tray for the new identity (mirror Vault). get().apply { reset(); start() } diff --git a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/ContactBookService.kt b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/ContactBookService.kt deleted file mode 100644 index 6f411d2e1..000000000 --- a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/ContactBookService.kt +++ /dev/null @@ -1,109 +0,0 @@ -@file:OptIn(ExperimentalUuidApi::class) - -package id.homebase.core.ui.screens.contactbook - -import co.touchlab.kermit.Logger -import id.homebase.api.client.ForbiddenException -import id.homebase.api.client.contacts.ContactContent -import id.homebase.api.client.contacts.ContactWriteResponse -import id.homebase.api.client.contacts.ContactWriteResult -import id.homebase.api.client.contacts.ContactsProvider -import kotlin.uuid.ExperimentalUuidApi -import kotlin.uuid.Uuid - -private const val TAG = "ContactBookService" - -/** - * Write path for the contact manager. Thin wrapper over the api-layer - * [ContactsProvider] (the V2 `/api/v2/contacts` controller) — the one write path - * that supports contacts WITHOUT an odinId, which both device-imported and - * manually-created contacts need. Server-written contacts sync down to the - * Contacts drive, where [ContactBookStream] picks them up. - * - * All methods return success/failure rather than throwing, so the ViewModel can - * surface a typed error and revert its optimistic update. - */ -class ContactBookService( - private val contactsProvider: ContactsProvider, -) { - /** - * Create-or-update via the provider's bounded merge-and-retry flow. Pass - * [knownUniqueId] + [knownVersionTag] for an edit (goes straight to UPDATE); - * omit them for a new contact (CREATE, falling back to UPDATE on 409). - * Returns the new uniqueId/versionTag, or null on failure. Rethrows - * [ForbiddenException] (403) so callers can explain the missing-permission cause. - */ - suspend fun save( - content: ContactContent, - knownUniqueId: Uuid? = null, - knownVersionTag: Uuid? = null, - ): ContactWriteResponse? = try { - contactsProvider.saveContact( - content = content, - knownUniqueId = knownUniqueId, - knownVersionTag = knownVersionTag, - ) - } catch (e: kotlin.coroutines.cancellation.CancellationException) { - throw e - } catch (e: ForbiddenException) { - throw e - } catch (e: Exception) { - Logger.w(e, TAG) { "saveContact failed" } - null - } - - /** - * Soft-delete. Returns true on success (or already-gone), false on a generic/transient - * error. Rethrows [ForbiddenException] (403) so callers can explain the missing-permission - * cause distinctly — mirrors [save]. - */ - suspend fun delete(uniqueId: Uuid): Boolean = try { - contactsProvider.deleteContact(uniqueId) - true - } catch (e: kotlin.coroutines.cancellation.CancellationException) { - throw e - } catch (e: ForbiddenException) { - throw e - } catch (e: Exception) { - Logger.w(e, TAG) { "deleteContact failed for $uniqueId" } - false - } - - /** - * Uploads (client-encrypts) an avatar for an existing contact via the provider's - * version-gated image endpoint. Must be called after [save] so [uniqueId] and - * [versionTag] are known. Returns true on success. - */ - suspend fun setPhoto( - uniqueId: Uuid, - contactDriveId: Uuid, - bytes: ByteArray, - contentType: String, - versionTag: Uuid, - ): Boolean = try { - val result = contactsProvider.setContactImage( - uniqueId = uniqueId, - contactDriveId = contactDriveId, - imageBytes = bytes, - contentType = contentType, - versionTag = versionTag, - ) - result is ContactWriteResult.Ok - } catch (e: kotlin.coroutines.cancellation.CancellationException) { - throw e - } catch (e: Exception) { - Logger.w(e, TAG) { "setContactImage failed for $uniqueId" } - false - } - - /** Best-effort enrichment of a connected identity from its public profile. */ - suspend fun syncFromIdentity(odinId: String) { - try { - contactsProvider.syncContact(id.homebase.api.common.OdinId(odinId)) - } catch (e: kotlin.coroutines.cancellation.CancellationException) { - throw e - } catch (e: Exception) { - Logger.w(e, TAG) { "syncContact failed for $odinId" } - } - } -} diff --git a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/ContactBookStream.kt b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/ContactBookStream.kt deleted file mode 100644 index 5884a41b2..000000000 --- a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/ContactBookStream.kt +++ /dev/null @@ -1,194 +0,0 @@ -@file:OptIn(ExperimentalUuidApi::class) - -package id.homebase.core.ui.screens.contactbook - -import co.touchlab.kermit.Logger -import id.homebase.api.client.auth.CredentialsManager -import id.homebase.api.client.contacts.ContactsProvider -import id.homebase.api.client.drives.HomebaseFile -import id.homebase.api.client.drives.QueryBatchSortField -import id.homebase.api.client.drives.QueryBatchSortOrder -import id.homebase.api.client.eventbus.BackendEvent -import id.homebase.api.client.eventbus.EventBus -import id.homebase.api.sync.database.DatabaseManager -import id.homebase.api.sync.database.QueryBatch -import id.homebase.core.config.contactTargetDrive -import id.homebase.core.ui.screens.contactbook.model.ContactBookEntry -import id.homebase.core.ui.screens.contactbook.model.toContactBookEntry -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.drop -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlin.uuid.ExperimentalUuidApi -import kotlin.uuid.Uuid - -private const val TAG = "ContactBookStream" - -/** - * Real-time view of the user's contacts, read from the (mandatory) Contacts - * drive's local index and kept fresh via [EventBus] — same pattern as - * [id.homebase.chat.services.convo.contact.DriveContactService], but it produces - * the richer [ContactBookEntry] (phone/email/location/birthday) the manager UI - * needs rather than the connection-oriented `ContactUiModel`. - * - * Writes do NOT go through this class — they go through - * [ContactBookService] (api ContactsProvider). After a successful write the - * ViewModel calls [insertOrUpdateOptimistic] / [removeOptimistic] for instant - * feedback; the authoritative row lands later via drive sync. - */ -class ContactBookStream( - private val databaseManager: DatabaseManager, - private val credentialsManager: CredentialsManager, - private val eventBus: EventBus, - private val scope: CoroutineScope, -) { - private val driveId = contactTargetDrive.alias - - private val _contacts = MutableStateFlow>(emptyList()) - val contacts: StateFlow> = _contacts.asStateFlow() - - private val _isLoaded = MutableStateFlow(false) - val isLoaded: StateFlow = _isLoaded.asStateFlow() - - // Resurrection guard: a removed contact must not reappear from a stale batch - // before the server-confirmed delete syncs down. - private val deletedIds = mutableSetOf() - - // Serializes loadAll so concurrent ensureLoaded() callers (the screen) don't run - // overlapping queries, and so ensureLoaded's check-then-load is atomic. - private val loadMutex = Mutex() - - init { - scope.launch { observeEvents() } - } - - /** Load from the local DB. Called from onPostAuthenticated, never from init. */ - fun start() { - scope.launch { loadAll() } - } - - /** - * Guarantees the contact list has been loaded for the current session, on demand. - * The screen calls this on entry so it never depends on the post-auth bootstrap - * (onPostAuthenticated -> start()) having actually run: that chain can be skipped by - * the headless/foreground promotion race, leaving [isLoaded] stuck false and the list - * spinning forever. Idempotent and cheap once loaded. - */ - suspend fun ensureLoaded() { - if (_isLoaded.value) return - loadMutex.withLock { - if (_isLoaded.value) return - loadAll() - } - } - - /** Clear all in-memory state for a clean login as a different identity. */ - fun reset() { - _contacts.value = emptyList() - _isLoaded.value = false - deletedIds.clear() - } - - suspend fun loadAll() { - val creds = credentialsManager.getActiveCredentials() ?: run { - _isLoaded.value = true - return - } - try { - val result = QueryBatch(creds.getIdentityId()).queryBatchAsync( - dbm = databaseManager, - driveId = driveId, - noOfItems = 1000, - sortOrder = QueryBatchSortOrder.NewestFirst, - sortField = QueryBatchSortField.CreatedDate, - fileSystemType = 0, - filetypesAnyOf = listOf(ContactsProvider.CONTACT_FILE_TYPE), - ) - val entries = result.records - .mapNotNull { it.toContactBookEntry() } - .filter { it.uniqueId !in deletedIds } - // The contact drive can hold >1 row per identity; NewestFirst + - // distinctBy keeps the freshest per uniqueId. - .distinctBy { it.uniqueId } - _contacts.value = entries.sortedBy { it.sortKey } - Logger.d(tag = TAG) { "loadAll: ${entries.size} contact(s)" } - } catch (e: Exception) { - Logger.e(e, TAG) { "Failed to load contacts" } - } - _isLoaded.value = true - } - - private suspend fun observeEvents() { - // The EventBus replays its last event (replay=1) to every new subscriber. - // This stream is a lazily-constructed singleton whose collector subscribes - // the first time it is resolved — which is inside onPostAuthenticated() at - // login. If a SessionEnded is sitting in the replay buffer from the - // pre-login Unauthenticated state (cold start lands on the login screen, - // which emits SessionEnded), it would be redelivered here and call reset() - // (_isLoaded=false), racing start()'s loadAll() (_isLoaded=true). When that - // replayed reset() lands AFTER loadAll(), the contact list spins forever - // until the next logout/login. A replayed event is stale by construction - // here, so skip whatever is already buffered and act on live events only. - val replayed = eventBus.events.replayCache.size - if (replayed > 0) { - Logger.d(tag = TAG) { "observeEvents: skipping $replayed replayed event(s) at startup" } - } - eventBus.events.drop(replayed).collect { event -> - when (event) { - is BackendEvent.SessionEnded -> reset() - - is BackendEvent.DataEvent.BatchReceived -> { - if (event.driveId != driveId) return@collect - processBatchIncrementally(event.batchData) - } - - is BackendEvent.DriveEvent.Stopped -> { - if (event.driveId != driveId) return@collect - if (event.totalCount > 0) { - try { - loadAll() - } catch (e: Exception) { - Logger.e(e, TAG) { "post-Stopped reload failed: ${e.message}" } - } - } - } - - else -> {} - } - } - } - - private fun processBatchIncrementally(batch: List) { - for (file in batch) { - val entry = file.toContactBookEntry() ?: continue - if (entry.uniqueId in deletedIds) continue - upsert(entry) - } - } - - private fun upsert(entry: ContactBookEntry) { - _contacts.update { current -> - val list = current.toMutableList() - val idx = list.indexOfFirst { it.uniqueId == entry.uniqueId } - if (idx >= 0) list[idx] = entry else list.add(entry) - list.sortedBy { it.sortKey } - } - } - - /** Optimistically add or replace a contact after a successful write. */ - fun insertOrUpdateOptimistic(entry: ContactBookEntry) { - deletedIds -= entry.uniqueId - upsert(entry) - } - - /** Optimistically remove a contact after a delete is issued. */ - fun removeOptimistic(uniqueId: Uuid) { - deletedIds += uniqueId - _contacts.update { current -> current.filterNot { it.uniqueId == uniqueId } } - } -} diff --git a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/ContactBookViewModel.kt b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/ContactBookViewModel.kt index b9cde4330..9191ef6de 100644 --- a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/ContactBookViewModel.kt +++ b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/ContactBookViewModel.kt @@ -11,6 +11,7 @@ import id.homebase.api.client.connections.CircleWithMembers import id.homebase.api.client.connections.ConnectionNetworkProvider import id.homebase.api.client.connections.ConnectionRequestOrigin import id.homebase.api.client.connections.ConnectionStatus +import id.homebase.api.client.contacts.ContactRepository import id.homebase.api.client.eventbus.BackendEvent import id.homebase.api.client.eventbus.EventBus import id.homebase.api.common.OdinId @@ -27,6 +28,7 @@ import id.homebase.core.config.contactTargetDrive import id.homebase.core.contactbook.ContactBookPreferences import id.homebase.core.ui.screens.contactbook.model.ContactBookEntry import id.homebase.core.ui.screens.contactbook.model.ContactBookSource +import id.homebase.core.ui.screens.contactbook.model.toContactBookEntry import io.github.vinceglb.filekit.PlatformFile import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -37,6 +39,7 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -57,8 +60,7 @@ import kotlin.uuid.ExperimentalUuidApi * See also [id.homebase.core.contactbook.ContactBookPreferences] (no `activated` flag). */ class ContactBookViewModel( - private val stream: ContactBookStream, - private val service: ContactBookService, + private val repo: ContactRepository, private val preferences: ContactBookPreferences, private val conversationService: ConversationService, private val connectionService: ConnectionService, @@ -91,7 +93,7 @@ class ContactBookViewModel( // relying on the post-auth bootstrap (onPostAuthenticated -> start()), which can be // skipped by the headless/foreground promotion race and leave the list spinning. // Idempotent once loaded. - viewModelScope.launch { stream.ensureLoaded() } + viewModelScope.launch { repo.ensureLoaded() } viewModelScope.launch { ownerSessionRepository.user.collect { session -> _header.update { it.copy(ownerSession = session) } @@ -142,10 +144,15 @@ class ContactBookViewModel( val members: CircleMembersUi?, ) + /** Server-shaped repository contacts projected into the flat UI model. */ + private val entries: StateFlow> = repo.contacts + .map { list -> list.mapNotNull { it.toContactBookEntry() } } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + val uiState: StateFlow = combine( combine( - stream.contacts, - stream.isLoaded, + entries, + repo.isLoaded, connectionService.connections, connectionService.circles, ) { c, l, conn, circ -> @@ -280,7 +287,7 @@ class ContactBookViewModel( is ContactBookUiAction.MessageClicked -> handleMessage(action.entry) is ContactBookUiAction.SyncClicked -> { val odinId = action.entry.odinId ?: return - viewModelScope.launch { service.syncFromIdentity(odinId) } + viewModelScope.launch { repo.sync(OdinId(odinId)) } } ContactBookUiAction.CloseOverlay -> _overlay.value = null @@ -297,9 +304,9 @@ class ContactBookViewModel( if (!draft.isSavable) return _overlay.value = null viewModelScope.launch { - when (val result = saveContactDraft(service, draft, editing, photo, contactTargetDrive.alias)) { + when (val result = saveContactDraft(repo, draft, editing, photo)) { is ContactSaveResult.Success -> { - stream.insertOrUpdateOptimistic(result.entry) + // repo.save already applied the optimistic update. if (result.photoFailed) { _events.tryEmit(ContactBookUiEvent.Error(ContactBookError.PhotoFailed)) } @@ -334,7 +341,7 @@ class ContactBookViewModel( // Members are bundled with the circle list — resolve them to contact entries // synchronously, no second network call. val domains = circle.members.map { it.domainName }.toSet() - val members = entriesForDomains(domains, stream.contacts.value).sortedBy { it.sortKey } + val members = entriesForDomains(domains, entries.value).sortedBy { it.sortKey } _circleMembers.value = CircleMembersUi( circleName = circle.circle.name, members = members, @@ -372,11 +379,10 @@ class ContactBookViewModel( private fun handleDelete(entry: ContactBookEntry) { _overlay.value = null - stream.removeOptimistic(entry.uniqueId) viewModelScope.launch { - if (!service.delete(entry.uniqueId)) { + // repo.delete does the optimistic remove and restores on a generic failure. + if (!repo.delete(entry.uniqueId)) { _events.tryEmit(ContactBookUiEvent.Error(ContactBookError.DeleteFailed)) - stream.loadAll() // re-sync truth after a failed delete } } } diff --git a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/ContactSaveHelper.kt b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/ContactSaveHelper.kt index 196df8272..ef78bdf09 100644 --- a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/ContactSaveHelper.kt +++ b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/ContactSaveHelper.kt @@ -2,13 +2,14 @@ package id.homebase.core.ui.screens.contactbook +import id.homebase.api.client.ForbiddenException import id.homebase.api.client.contacts.ContactBirthday import id.homebase.api.client.contacts.ContactContent import id.homebase.api.client.contacts.ContactEmail import id.homebase.api.client.contacts.ContactLocation -import id.homebase.api.client.ForbiddenException import id.homebase.api.client.contacts.ContactName import id.homebase.api.client.contacts.ContactPhone +import id.homebase.api.client.contacts.ContactRepository import id.homebase.core.ui.screens.contactbook.model.ContactBookEntry import id.homebase.core.ui.screens.contactbook.model.ContactBookSource import id.homebase.core.util.resolveContentType @@ -21,8 +22,9 @@ import kotlin.uuid.Uuid /** Outcome of [saveContactDraft]. */ sealed interface ContactSaveResult { - /** Saved; push [entry] into the stream. [photoFailed] = contact saved but avatar upload failed. */ - data class Success(val entry: ContactBookEntry, val photoFailed: Boolean) : ContactSaveResult + /** Saved. [photoFailed] = contact saved but avatar upload failed. The repository has already + * applied the optimistic update to its `contacts` flow, so callers don't push anything. */ + data class Success(val photoFailed: Boolean) : ContactSaveResult /** Server rejected the write with 403 — the app token lacks the manage-contacts permission. */ data object Forbidden : ContactSaveResult @@ -32,18 +34,16 @@ sealed interface ContactSaveResult { } /** - * Builds [ContactContent] from a [draft], saves it via [service], uploads the [photo] - * (if any) after the contact exists, and returns the optimistic [ContactBookEntry] for - * the caller to push into its stream. Shared by the contact-list and contact-detail - * ViewModels so edit behaves identically in both. Distinguishes a 403 (Forbidden) so the - * UI can explain the missing-permission cause rather than a generic failure. + * Builds [ContactContent] from a [draft] and saves it through [repo] (which owns the optimistic + * update), then uploads the [photo] if any. Shared by the contact-list and contact-detail + * ViewModels so edit behaves identically in both. Distinguishes a 403 (Forbidden) so the UI can + * explain the missing-permission cause rather than a generic failure. */ suspend fun saveContactDraft( - service: ContactBookService, + repo: ContactRepository, draft: ContactDraft, editing: ContactBookEntry?, photo: PlatformFile?, - contactDriveId: Uuid, ): ContactSaveResult { if (!draft.isSavable) return ContactSaveResult.Failed @@ -70,7 +70,7 @@ suspend fun saveContactDraft( ) val response = try { - service.save( + repo.save( content = content, knownUniqueId = editing?.uniqueId, knownVersionTag = editing?.versionTag, @@ -80,37 +80,16 @@ suspend fun saveContactDraft( } ?: return ContactSaveResult.Failed val photoFailed = photo != null && - !uploadContactPhoto(service, response.uniqueId, response.versionTag, photo, contactDriveId) + !uploadContactPhoto(repo, response.uniqueId, response.versionTag, photo) - val display = draft.displayName.ifBlank { normalizedPhone ?: draft.email }.ifBlank { "?" } - val entry = (editing ?: ContactBookEntry( - uniqueId = response.uniqueId, - fileId = response.uniqueId, - versionTag = response.versionTag, - displayName = display, - )).copy( - uniqueId = response.uniqueId, - versionTag = response.versionTag, - odinId = odinId, - displayName = display, - givenName = draft.givenName.ifBlank { null }, - surname = draft.surname.ifBlank { null }, - phone = normalizedPhone, - email = draft.email.ifBlank { null }, - city = draft.city.ifBlank { null }, - country = draft.country.ifBlank { null }, - birthday = draft.birthday.ifBlank { null }, - source = content.source, - ) - return ContactSaveResult.Success(entry, photoFailed) + return ContactSaveResult.Success(photoFailed) } private suspend fun uploadContactPhoto( - service: ContactBookService, + repo: ContactRepository, uniqueId: Uuid, versionTag: Uuid, photo: PlatformFile, - contactDriveId: Uuid, ): Boolean { val bytes = try { photo.readBytes() @@ -121,9 +100,8 @@ private suspend fun uploadContactPhoto( } if (bytes.isEmpty()) return false val contentType = resolveContentType(photo.name, photo.mimeType()?.toString()) - return service.setPhoto( + return repo.setImage( uniqueId = uniqueId, - contactDriveId = contactDriveId, bytes = bytes, contentType = contentType, versionTag = versionTag, diff --git a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailViewModel.kt b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailViewModel.kt index 665fe02b5..f1ee68d4e 100644 --- a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailViewModel.kt +++ b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailViewModel.kt @@ -17,17 +17,17 @@ import id.homebase.chat.services.ChatMessageStream import id.homebase.chat.services.convo.ConversationService import id.homebase.chat.services.convo.ConversationStream import id.homebase.chat.services.convo.contact.ConnectionService +import id.homebase.api.client.contacts.ContactRepository import id.homebase.core.config.AUTO_CONNECTIONS_CIRCLE_ID import id.homebase.core.config.CONFIRMED_CONNECTIONS_CIRCLE_ID -import id.homebase.core.config.contactTargetDrive import id.homebase.core.ui.navigation.Route -import id.homebase.core.ui.screens.contactbook.ContactBookService -import id.homebase.core.ui.screens.contactbook.ContactBookStream import id.homebase.core.ui.screens.contactbook.model.ContactBookEntry import id.homebase.core.ui.screens.contactbook.ContactSaveResult import id.homebase.core.ui.screens.contactbook.model.ContactBookSource +import id.homebase.core.ui.screens.contactbook.model.toContactBookEntry import id.homebase.core.ui.screens.contactbook.saveContactDraft import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -46,8 +46,7 @@ private const val OVERVIEW_MESSAGE_CAP = 1000 class ContactDetailViewModel( savedStateHandle: SavedStateHandle, - private val contactBookStream: ContactBookStream, - private val contactBookService: ContactBookService, + private val contactRepository: ContactRepository, private val conversationService: ConversationService, private val conversationStream: ConversationStream, private val chatMessageStream: ChatMessageStream, @@ -69,10 +68,12 @@ class ContactDetailViewModel( get() = route.odinId?.ifBlank { null } ?: _uiState.value.entry?.odinId?.ifBlank { null } init { + // Self-sufficient on deep-link: ensure the repo has loaded even if the list wasn't opened. + viewModelScope.launch { contactRepository.ensureLoaded() } // Keep the entry + connection status live (an edit / block reflects immediately). viewModelScope.launch { combine( - contactBookStream.contacts, + contactRepository.contacts.map { list -> list.mapNotNull { it.toContactBookEntry() } }, connectionService.connections, connectionService.circles, ) { contacts, conn, circ -> @@ -200,7 +201,7 @@ class ContactDetailViewModel( private fun handleSync() { val domain = odinId ?: return _events.tryEmit(ContactDetailEvent.SyncStarted) - viewModelScope.launch { contactBookService.syncFromIdentity(domain) } + viewModelScope.launch { contactRepository.sync(OdinId(domain)) } } private fun handleSave(action: ContactDetailAction.SaveContact) { @@ -208,14 +209,13 @@ class ContactDetailViewModel( _uiState.update { it.copy(editOpen = false) } viewModelScope.launch { when (val result = saveContactDraft( - service = contactBookService, + repo = contactRepository, draft = action.draft, editing = editing, photo = action.photo, - contactDriveId = contactTargetDrive.alias, )) { is ContactSaveResult.Success -> { - contactBookStream.insertOrUpdateOptimistic(result.entry) + // repo.save already applied the optimistic update. if (result.photoFailed) _events.tryEmit(ContactDetailEvent.PhotoError) } ContactSaveResult.Forbidden -> _events.tryEmit(ContactDetailEvent.Forbidden) @@ -266,18 +266,11 @@ class ContactDetailViewModel( _events.tryEmit(ContactDetailEvent.Back) return@launch } - contactBookStream.removeOptimistic(entry.uniqueId) + // repo.delete does the optimistic remove and restores on failure. val event = try { - if (contactBookService.delete(entry.uniqueId)) { - ContactDetailEvent.Back - } else { - // Generic/transient failure — restore the contact and stay put. - contactBookStream.insertOrUpdateOptimistic(entry) - ContactDetailEvent.DeleteError - } + if (contactRepository.delete(entry.uniqueId)) ContactDetailEvent.Back + else ContactDetailEvent.DeleteError } catch (e: ForbiddenException) { - // 403 — app lacks manage-contacts permission. Restore and explain. - contactBookStream.insertOrUpdateOptimistic(entry) ContactDetailEvent.DeleteForbidden } _events.tryEmit(event) From c653dd0b8ceb87fc139ee00df85822f8505feae8 Mon Sep 17 00:00:00 2001 From: toddmitchell Date: Fri, 19 Jun 2026 15:53:37 -0500 Subject: [PATCH 03/23] =?UTF-8?q?Contact=20migration=20step=203:=20re-appl?= =?UTF-8?q?y=20=C2=A77=20+=20#6;=20drop=20dead=20HomebaseFile=20parser?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - §7: contact detail "Contact details" section lists the Homebase ID, so an identity-only contact no longer reads "None". - #6: no-clear merge mitigation on the repository path — saveContactDraft now coalesces the saved content (keeps the old value for a blanked field, matching the server's no-clear merge, so the optimistic entry doesn't flash empty then reappear) and reports clearedFieldsIgnored; both the list and detail screens surface a "Clearing a contact field isn't supported yet…" snackbar. - Remove the now-dead HomebaseFile.toContactBookEntry (the repo path parses to Contact via toContact(); the UI projects Contact.toContactBookEntry) and its now-unused imports; refresh the ContactBookEntry KDoc. Core + Konsist + api jvmTest suites pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../composeResources/values/strings.xml | 1 + .../screens/contactbook/ContactBookScreen.kt | 3 + .../screens/contactbook/ContactBookUiState.kt | 1 + .../contactbook/ContactBookViewModel.kt | 3 + .../screens/contactbook/ContactSaveHelper.kt | 58 ++++++++++++++---- .../contactbook/detail/ContactDetailScreen.kt | 4 ++ .../detail/ContactDetailSections.kt | 4 ++ .../detail/ContactDetailUiState.kt | 2 + .../detail/ContactDetailViewModel.kt | 3 + .../contactbook/model/ContactBookEntry.kt | 60 ++----------------- 10 files changed, 70 insertions(+), 69 deletions(-) diff --git a/homebase-common/src/commonMain/composeResources/values/strings.xml b/homebase-common/src/commonMain/composeResources/values/strings.xml index 9b002c2ff..a95543c00 100644 --- a/homebase-common/src/commonMain/composeResources/values/strings.xml +++ b/homebase-common/src/commonMain/composeResources/values/strings.xml @@ -1402,6 +1402,7 @@ Couldn't delete — this app doesn't have permission to manage your contacts. Re-grant access by extending permissions in the owner console, then try again. The contact was saved, but the photo couldn't be uploaded. Couldn't open the chat. + Clearing a contact field isn't supported yet, so that field was kept. Contact blocked. Contact unblocked. diff --git a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/ContactBookScreen.kt b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/ContactBookScreen.kt index 655c32425..2f9b3a7d6 100644 --- a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/ContactBookScreen.kt +++ b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/ContactBookScreen.kt @@ -56,6 +56,7 @@ import id.homebase.resources.MR import id.homebase.resources.contactbook_action_add import id.homebase.resources.contactbook_error_delete import id.homebase.resources.contactbook_error_forbidden +import id.homebase.resources.contactbook_error_clear_unsupported import id.homebase.resources.contactbook_error_message import id.homebase.resources.contactbook_error_photo import id.homebase.resources.contactbook_error_save @@ -82,6 +83,7 @@ fun ContactBookScreen( val errDelete = stringResource(MR.string.contactbook_error_delete) val errPhoto = stringResource(MR.string.contactbook_error_photo) val errMessage = stringResource(MR.string.contactbook_error_message) + val errClearUnsupported = stringResource(MR.string.contactbook_error_clear_unsupported) val errForbidden = stringResource(MR.string.contactbook_error_forbidden) LaunchedEffect(Unit) { @@ -96,6 +98,7 @@ fun ContactBookScreen( ContactBookError.DeleteFailed -> errDelete ContactBookError.PhotoFailed -> errPhoto ContactBookError.MessageFailed -> errMessage + ContactBookError.ClearUnsupported -> errClearUnsupported } snackbarHostState.showSnackbar(msg) } diff --git a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/ContactBookUiState.kt b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/ContactBookUiState.kt index 899b3e266..3275756f3 100644 --- a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/ContactBookUiState.kt +++ b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/ContactBookUiState.kt @@ -136,4 +136,5 @@ enum class ContactBookError { DeleteFailed, PhotoFailed, MessageFailed, + ClearUnsupported, } diff --git a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/ContactBookViewModel.kt b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/ContactBookViewModel.kt index 9191ef6de..d440c3ba6 100644 --- a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/ContactBookViewModel.kt +++ b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/ContactBookViewModel.kt @@ -310,6 +310,9 @@ class ContactBookViewModel( if (result.photoFailed) { _events.tryEmit(ContactBookUiEvent.Error(ContactBookError.PhotoFailed)) } + if (result.clearedFieldsIgnored) { + _events.tryEmit(ContactBookUiEvent.Error(ContactBookError.ClearUnsupported)) + } } ContactSaveResult.Forbidden -> _events.tryEmit(ContactBookUiEvent.Error(ContactBookError.SaveForbidden)) diff --git a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/ContactSaveHelper.kt b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/ContactSaveHelper.kt index ef78bdf09..0d06d2198 100644 --- a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/ContactSaveHelper.kt +++ b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/ContactSaveHelper.kt @@ -22,9 +22,16 @@ import kotlin.uuid.Uuid /** Outcome of [saveContactDraft]. */ sealed interface ContactSaveResult { - /** Saved. [photoFailed] = contact saved but avatar upload failed. The repository has already - * applied the optimistic update to its `contacts` flow, so callers don't push anything. */ - data class Success(val photoFailed: Boolean) : ContactSaveResult + /** + * Saved. [photoFailed] = contact saved but avatar upload failed. [clearedFieldsIgnored] = the + * edit blanked a previously-set field, which the V2 server merge can't express (empty = "leave + * existing alone"), so that field was kept — the UI should tell the user clearing isn't + * supported yet. The repository already applied the optimistic update, so callers push nothing. + */ + data class Success( + val photoFailed: Boolean, + val clearedFieldsIgnored: Boolean = false, + ) : ContactSaveResult /** Server rejected the write with 403 — the app token lacks the manage-contacts permission. */ data object Forbidden : ContactSaveResult @@ -51,22 +58,47 @@ suspend fun saveContactDraft( ?.let { ContactFieldValidation.normalizePhone(it) } // An odinId may be set on a new contact, or kept from the edited one. val odinId = draft.odinId.trim().ifBlank { null } ?: editing?.odinId?.ifBlank { null } - val hasLocation = draft.city.isNotBlank() || draft.country.isNotBlank() + + // The V2 server merges per-leaf with Coalesce(incoming, existing): a blanked field is "leave + // alone", never cleared. Mirror that here — keep the old value when an edit blanked a + // previously-set field — so the saved/optimistic content matches what the drive will sync back + // (no "cleared" field flashing empty then reappearing). `didClear` drives the user warning. + fun coalesce(new: String?, old: String?): String? = new?.ifBlank { null } ?: old?.ifBlank { null } + fun didClear(new: String?, old: String?): Boolean = !old.isNullOrBlank() && new.isNullOrBlank() + + val mergedDisplay = coalesce(draft.displayName, editing?.displayName) + val mergedGiven = coalesce(draft.givenName, editing?.givenName) + val mergedSurname = coalesce(draft.surname, editing?.surname) + val mergedPhone = coalesce(normalizedPhone, editing?.phone) + val mergedEmail = coalesce(draft.email, editing?.email) + val mergedCity = coalesce(draft.city, editing?.city) + val mergedCountry = coalesce(draft.country, editing?.country) + val mergedBirthday = coalesce(draft.birthday, editing?.birthday) + + val clearedFieldsIgnored = editing != null && ( + didClear(draft.givenName, editing.givenName) || + didClear(draft.surname, editing.surname) || + didClear(normalizedPhone, editing.phone) || + didClear(draft.email, editing.email) || + didClear(draft.city, editing.city) || + didClear(draft.country, editing.country) || + didClear(draft.birthday, editing.birthday) + ) val content = ContactContent( odinId = odinId, name = ContactName( - displayName = draft.displayName.ifBlank { null }, - givenName = draft.givenName.ifBlank { null }, - surname = draft.surname.ifBlank { null }, + displayName = mergedDisplay, + givenName = mergedGiven, + surname = mergedSurname, ), source = editing?.source ?: ContactBookSource.MANUAL, - location = if (hasLocation) { - ContactLocation(city = draft.city.ifBlank { null }, country = draft.country.ifBlank { null }) + location = if (mergedCity != null || mergedCountry != null) { + ContactLocation(city = mergedCity, country = mergedCountry) } else null, - phone = normalizedPhone?.let { ContactPhone(it) }, - email = draft.email.ifBlank { null }?.let { ContactEmail(it) }, - birthday = draft.birthday.ifBlank { null }?.let { ContactBirthday(it) }, + phone = mergedPhone?.let { ContactPhone(it) }, + email = mergedEmail?.let { ContactEmail(it) }, + birthday = mergedBirthday?.let { ContactBirthday(it) }, ) val response = try { @@ -82,7 +114,7 @@ suspend fun saveContactDraft( val photoFailed = photo != null && !uploadContactPhoto(repo, response.uniqueId, response.versionTag, photo) - return ContactSaveResult.Success(photoFailed) + return ContactSaveResult.Success(photoFailed, clearedFieldsIgnored) } private suspend fun uploadContactPhoto( diff --git a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailScreen.kt b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailScreen.kt index f3be77c86..0a6a1d859 100644 --- a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailScreen.kt +++ b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailScreen.kt @@ -83,6 +83,7 @@ import id.homebase.resources.contactbook_error_connection_forbidden import id.homebase.resources.contactbook_error_delete import id.homebase.resources.contactbook_error_delete_forbidden import id.homebase.resources.contactbook_error_forbidden +import id.homebase.resources.contactbook_error_clear_unsupported import id.homebase.resources.contactbook_error_photo import id.homebase.resources.contactbook_error_save import id.homebase.resources.menu_back @@ -102,6 +103,7 @@ fun ContactDetailScreen( val errSave = stringResource(MR.string.contactbook_error_save) val errPhoto = stringResource(MR.string.contactbook_error_photo) + val errClearUnsupported = stringResource(MR.string.contactbook_error_clear_unsupported) val errForbidden = stringResource(MR.string.contactbook_error_forbidden) val errDelete = stringResource(MR.string.contactbook_error_delete) val errDeleteForbidden = stringResource(MR.string.contactbook_error_delete_forbidden) @@ -125,6 +127,8 @@ fun ContactDetailScreen( ContactDetailEvent.ConnectionForbidden -> snackbarHostState.showSnackbar(errConnectionForbidden) ContactDetailEvent.PhotoError -> snackbarHostState.showSnackbar(errPhoto) + ContactDetailEvent.ClearUnsupported -> + snackbarHostState.showSnackbar(errClearUnsupported) ContactDetailEvent.Blocked -> snackbarHostState.showSnackbar(msgBlocked) ContactDetailEvent.Unblocked -> snackbarHostState.showSnackbar(msgUnblocked) ContactDetailEvent.Disconnected -> snackbarHostState.showSnackbar(msgDisconnected) diff --git a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailSections.kt b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailSections.kt index f9f4ceb39..a46da5e33 100644 --- a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailSections.kt +++ b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailSections.kt @@ -18,6 +18,7 @@ import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.AlternateEmail import androidx.compose.material.icons.outlined.Block import androidx.compose.material.icons.outlined.Cake import androidx.compose.material.icons.outlined.Call @@ -223,6 +224,9 @@ fun ContactFieldsSection( ) val fields = buildList { + // The Homebase ID is a contact detail too — without it an identity-only contact + // (synced from a connection, no phone/email) reads as "None" here despite having data. + entry.odinId?.takeIf { it.isNotBlank() }?.let { add(Icons.Outlined.AlternateEmail to it) } entry.phone?.takeIf { it.isNotBlank() }?.let { add(Icons.Outlined.Call to it) } entry.email?.takeIf { it.isNotBlank() }?.let { add(Icons.Outlined.Email to it) } entry.location?.takeIf { it.isNotBlank() }?.let { add(Icons.Outlined.LocationOn to it) } diff --git a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailUiState.kt b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailUiState.kt index 8d4ef4f44..fda349dfe 100644 --- a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailUiState.kt +++ b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailUiState.kt @@ -73,6 +73,8 @@ sealed interface ContactDetailEvent { /** 403 on block/unblock/disconnect — app lacks manage-connections permission. */ data object ConnectionForbidden : ContactDetailEvent data object PhotoError : ContactDetailEvent + /** Edit blanked a previously-set field, which the server merge can't express — field kept. */ + data object ClearUnsupported : ContactDetailEvent /** Success confirmations for connection actions. */ data object Blocked : ContactDetailEvent data object Unblocked : ContactDetailEvent diff --git a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailViewModel.kt b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailViewModel.kt index f1ee68d4e..657831d9e 100644 --- a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailViewModel.kt +++ b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailViewModel.kt @@ -217,6 +217,9 @@ class ContactDetailViewModel( is ContactSaveResult.Success -> { // repo.save already applied the optimistic update. if (result.photoFailed) _events.tryEmit(ContactDetailEvent.PhotoError) + if (result.clearedFieldsIgnored) { + _events.tryEmit(ContactDetailEvent.ClearUnsupported) + } } ContactSaveResult.Forbidden -> _events.tryEmit(ContactDetailEvent.Forbidden) ContactSaveResult.Failed -> _events.tryEmit(ContactDetailEvent.Error) diff --git a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/model/ContactBookEntry.kt b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/model/ContactBookEntry.kt index a9e1b20d8..0956954de 100644 --- a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/model/ContactBookEntry.kt +++ b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/model/ContactBookEntry.kt @@ -11,12 +11,9 @@ import id.homebase.api.client.contacts.ContactEmail import id.homebase.api.client.contacts.ContactLocation import id.homebase.api.client.contacts.ContactName import id.homebase.api.client.contacts.ContactPhone -import id.homebase.api.client.contacts.ContactsProvider import id.homebase.api.client.contacts.resolveDisplayName -import id.homebase.api.client.drives.HomebaseFile import id.homebase.api.client.drives.files.PayloadDescriptor import id.homebase.api.client.drives.upload.EmbeddedThumb -import id.homebase.api.serialization.OdinSystemSerializer import id.homebase.core.image.HomebaseImageData import id.homebase.core.util.initials import kotlin.io.encoding.Base64 @@ -32,10 +29,10 @@ object ContactBookSource { } /** - * A contact in the contact-manager UI. Built from one file on the (mandatory) - * Contacts drive by deserializing its header [ContactContent]. The drive is the - * read source; writes go through the api-layer ContactsProvider (see - * [id.homebase.core.ui.screens.contactbook.ContactBookService]). + * A contact in the contact-manager UI — the flat projection of the api + * [id.homebase.api.client.contacts.Contact] domain model produced by + * [id.homebase.api.client.contacts.ContactRepository] (see [Contact.toContactBookEntry]). Both + * reads and writes flow through that repository. */ @Immutable data class ContactBookEntry( @@ -140,55 +137,6 @@ data class ContactBookEntry( ) } -/** - * Maps a Contacts-drive [HomebaseFile] to a [ContactBookEntry], or null if the - * header content is absent (e.g. a large contact spilled into a payload — rare; - * imported/manual contacts always embed) or cannot be parsed. - */ -fun HomebaseFile.toContactBookEntry(): ContactBookEntry? { - val uniqueId = fileMetadata.appData.uniqueId ?: return null - val contentJson = fileMetadata.appData.content ?: return null - val content = try { - OdinSystemSerializer.deserialize(contentJson) - } catch (_: Exception) { - return null - } - - val name = content.name - val display = name?.displayName?.takeIf { it.isNotBlank() } - ?: listOfNotNull(name?.givenName, name?.surname) - .joinToString(" ").trim().ifBlank { null } - ?: content.odinId?.takeIf { it.isNotBlank() } - ?: content.phone?.number?.takeIf { it.isNotBlank() } - ?: content.email?.email?.takeIf { it.isNotBlank() } - ?: return null // nothing renderable — skip - - val imagePayload = fileMetadata.payloads - ?.firstOrNull { it.key == ContactsProvider.CONTACT_IMAGE_PAYLOAD_KEY } - - return ContactBookEntry( - uniqueId = uniqueId, - fileId = fileId, - versionTag = fileMetadata.versionTag, - odinId = content.odinId, - displayName = display, - givenName = name?.givenName, - additionalName = name?.additionalName, - surname = name?.surname, - phone = content.phone?.number, - email = content.email?.email, - city = content.location?.city, - country = content.location?.country, - birthday = content.birthday?.date, - source = content.source, - driveId = driveId, - keyHeader = keyHeader, - isEncrypted = fileMetadata.isEncrypted, - previewThumbnail = fileMetadata.appData.previewThumbnail, - imagePayload = imagePayload, - ) -} - /** * Projects the server-shaped [Contact] domain model (from `ContactRepository`) into the flat * contact-manager UI model. The display name is resolved via the shared From efcd49368644c2a33df0e7ad59ddabb878c430aa Mon Sep 17 00:00:00 2001 From: toddmitchell Date: Fri, 19 Jun 2026 16:35:15 -0500 Subject: [PATCH 04/23] Chat migration: ContactService + connection-accept on ContactRepository; delete DriveContactService MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrates the chat contact read+write path onto the single ContactRepository source of truth and removes the duplicate chat contact stack: - ContactUiModel gains a Contact.toContactUiModel() projection (server-shaped Contact -> connection-oriented chat model), via the shared resolveDisplayName/ initials helpers. - ContactService now sources raw contacts from ContactRepository.contacts and layers connection state on top (unchanged enrichment); it no longer starts a drive reader (the repo is started by the post-auth bootstrap). - ConnectionRequestService writes contacts on connection accept/finalize via contactRepository.sync(odinId) instead of DriveContactService.saveContactForOdinId. - Delete DriveContactService and the entire chat duplicate model family (ContactServerFile, ContactName/Phone/Email/Location/Birthday, ContactImage, ContactProtocol, ContactSizer); drop its DI registration. Now both chat and the contact book read/write through one ContactRepository: one drive query, one event observer, one writer. Chat + core + Konsist + api jvmTest suites pass. NOT yet device-validated — the chat people-pickers (new conversation, select/add members, conversation list) read through ContactService and need a smoke test. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../id/homebase/chat/data/ContactUiModel.kt | 30 +- .../services/convo/contact/ContactBirthday.kt | 8 - .../services/convo/contact/ContactEmail.kt | 8 - .../services/convo/contact/ContactImage.kt | 17 - .../services/convo/contact/ContactLocation.kt | 9 - .../services/convo/contact/ContactName.kt | 52 --- .../services/convo/contact/ContactPhone.kt | 8 - .../services/convo/contact/ContactProtocol.kt | 5 - .../convo/contact/ContactServerFile.kt | 17 - .../services/convo/contact/ContactService.kt | 9 +- .../services/convo/contact/ContactSizer.kt | 19 - .../convo/contact/DriveContactService.kt | 392 ------------------ .../requests/ConnectionRequestService.kt | 12 +- .../kotlin/id/homebase/core/di/AppModule.kt | 2 - 14 files changed, 41 insertions(+), 547 deletions(-) delete mode 100644 homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/convo/contact/ContactBirthday.kt delete mode 100644 homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/convo/contact/ContactEmail.kt delete mode 100644 homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/convo/contact/ContactImage.kt delete mode 100644 homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/convo/contact/ContactLocation.kt delete mode 100644 homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/convo/contact/ContactName.kt delete mode 100644 homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/convo/contact/ContactPhone.kt delete mode 100644 homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/convo/contact/ContactProtocol.kt delete mode 100644 homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/convo/contact/ContactServerFile.kt delete mode 100644 homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/convo/contact/ContactSizer.kt delete mode 100644 homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/convo/contact/DriveContactService.kt diff --git a/homebase-chat/src/commonMain/kotlin/id/homebase/chat/data/ContactUiModel.kt b/homebase-chat/src/commonMain/kotlin/id/homebase/chat/data/ContactUiModel.kt index f518a0276..ec23bb6e7 100644 --- a/homebase-chat/src/commonMain/kotlin/id/homebase/chat/data/ContactUiModel.kt +++ b/homebase-chat/src/commonMain/kotlin/id/homebase/chat/data/ContactUiModel.kt @@ -1,10 +1,16 @@ +@file:OptIn(ExperimentalUuidApi::class) + package id.homebase.chat.data import androidx.compose.runtime.Immutable import id.homebase.api.client.connections.RedactedIdentityConnectionRegistration -import kotlin.uuid.Uuid +import id.homebase.api.client.contacts.Contact +import id.homebase.api.client.contacts.initials +import id.homebase.api.client.contacts.resolveDisplayName import id.homebase.api.common.OdinId import id.homebase.chat.services.convo.contact.ContactConnectionState +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid @Immutable data class ContactUiModel( @@ -18,3 +24,25 @@ data class ContactUiModel( val connection: RedactedIdentityConnectionRegistration? = null, val connectionState: ContactConnectionState = ContactConnectionState.Unknown ) + +/** + * Projects the server-shaped [Contact] domain model (from `ContactRepository`) into the + * connection-oriented chat UI model. Identity-keyed: a contact with no odinId (e.g. a manual + * phone-only entry) can't be a [ContactUiModel] and is skipped (null). Connection state is layered + * on later by `ContactService`. + */ +fun Contact.toContactUiModel(): ContactUiModel? { + val odinIdStr = content.odinId?.takeIf { it.isNotBlank() } ?: return null + val odin = OdinId(odinIdStr) + return ContactUiModel( + id = uniqueId, + odinId = odin, + name = content.name.resolveDisplayName( + odinId = odinIdStr, + phone = content.phone?.number, + email = content.email?.email, + ) ?: odin.domainName, + avatarInitials = content.name.initials(), + avatarUrl = "https://$odinIdStr/pub/image", + ) +} diff --git a/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/convo/contact/ContactBirthday.kt b/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/convo/contact/ContactBirthday.kt deleted file mode 100644 index 708795423..000000000 --- a/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/convo/contact/ContactBirthday.kt +++ /dev/null @@ -1,8 +0,0 @@ -package id.homebase.chat.services.convo.contact - -import kotlinx.serialization.Serializable - -@Serializable -data class ContactBirthday( - val date: String? -) \ No newline at end of file diff --git a/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/convo/contact/ContactEmail.kt b/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/convo/contact/ContactEmail.kt deleted file mode 100644 index fc5193ef4..000000000 --- a/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/convo/contact/ContactEmail.kt +++ /dev/null @@ -1,8 +0,0 @@ -package id.homebase.chat.services.convo.contact - -import kotlinx.serialization.Serializable - -@Serializable -data class ContactEmail( - val email: String? -) \ No newline at end of file diff --git a/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/convo/contact/ContactImage.kt b/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/convo/contact/ContactImage.kt deleted file mode 100644 index c540ef13f..000000000 --- a/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/convo/contact/ContactImage.kt +++ /dev/null @@ -1,17 +0,0 @@ -package id.homebase.chat.services.convo.contact - -import kotlinx.serialization.Serializable - -/** - * Base64-encoded image bytes attached to a contact. Mirrors the TypeScript `RawContact.image` - * shape. The `content` is base64 of the raw image; the `contentType` is the MIME type - * ("image/jpeg" etc.). - * - * When a contact is saved with an image present, the image is stripped from the header - * content and uploaded as a separate encrypted payload under [ContactProtocol.ProfileImageKey]. - */ -@Serializable -data class ContactImage( - val content: String, - val contentType: String -) diff --git a/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/convo/contact/ContactLocation.kt b/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/convo/contact/ContactLocation.kt deleted file mode 100644 index 9db8365b6..000000000 --- a/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/convo/contact/ContactLocation.kt +++ /dev/null @@ -1,9 +0,0 @@ -package id.homebase.chat.services.convo.contact - -import kotlinx.serialization.Serializable - -@Serializable -data class ContactLocation( - val city: String?, - val country: String? -) \ No newline at end of file diff --git a/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/convo/contact/ContactName.kt b/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/convo/contact/ContactName.kt deleted file mode 100644 index 0f9fc1474..000000000 --- a/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/convo/contact/ContactName.kt +++ /dev/null @@ -1,52 +0,0 @@ -package id.homebase.chat.services.convo.contact - -import kotlinx.serialization.Serializable - -@Serializable -data class ContactName( - val displayName: String?, - val givenName: String?, - val additionalName: String?, - val surname: String? -) { - - fun initials(): String { - val first = - givenName - ?.trim() - ?.takeIf { it.isNotEmpty() } - ?.firstOrNull() - - val last = - surname - ?.trim() - ?.takeIf { it.isNotEmpty() } - ?.firstOrNull() - - if (first != null && last != null) { - return "${first}${last}".uppercase() - } - - // Fallback: try display name tokens - if (displayName == null) { - return "?" - } - - val tokens = - displayName - .trim() - .split("\\s+".toRegex()) - .filter { it.isNotEmpty() } - - return when { - tokens.size >= 2 -> - "${tokens.first().first()}${tokens.last().first()}".uppercase() - - tokens.size == 1 -> - tokens.first().first().uppercaseChar().toString() - - else -> - "?" - } - } -} \ No newline at end of file diff --git a/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/convo/contact/ContactPhone.kt b/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/convo/contact/ContactPhone.kt deleted file mode 100644 index 3edac415d..000000000 --- a/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/convo/contact/ContactPhone.kt +++ /dev/null @@ -1,8 +0,0 @@ -package id.homebase.chat.services.convo.contact - -import kotlinx.serialization.Serializable - -@Serializable -data class ContactPhone( - val number: String? -) \ No newline at end of file diff --git a/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/convo/contact/ContactProtocol.kt b/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/convo/contact/ContactProtocol.kt deleted file mode 100644 index 1ee40b118..000000000 --- a/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/convo/contact/ContactProtocol.kt +++ /dev/null @@ -1,5 +0,0 @@ -package id.homebase.chat.services.convo.contact - -object ContactProtocol { - const val ProfileImageKey = "prfl_pic" -} diff --git a/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/convo/contact/ContactServerFile.kt b/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/convo/contact/ContactServerFile.kt deleted file mode 100644 index 9151f0b2a..000000000 --- a/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/convo/contact/ContactServerFile.kt +++ /dev/null @@ -1,17 +0,0 @@ -package id.homebase.chat.services.convo.contact - -import id.homebase.api.common.OdinId -import kotlinx.serialization.Serializable - -@Serializable -data class ContactServerFile( - val odinId: OdinId?, - val name: ContactName, - val source: String?, // 'contact' | 'public' | 'user'; - - val location: ContactLocation? = null, - val phone: ContactPhone? = null, - val email: ContactEmail? = null, - val birthday: ContactBirthday? = null, - val image: ContactImage? = null -) \ No newline at end of file diff --git a/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/convo/contact/ContactService.kt b/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/convo/contact/ContactService.kt index c60eefa11..167a5f4ed 100644 --- a/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/convo/contact/ContactService.kt +++ b/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/convo/contact/ContactService.kt @@ -1,17 +1,20 @@ package id.homebase.chat.services.convo.contact import id.homebase.api.client.connections.ConnectionStatus +import id.homebase.api.client.contacts.ContactRepository import id.homebase.api.common.OdinId import id.homebase.chat.data.ContactUiModel +import id.homebase.chat.data.toContactUiModel import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch class ContactService( - private val driveContacts: DriveContactService, + private val contactRepository: ContactRepository, private val connections: ConnectionService, private val scope: CoroutineScope ) { @@ -28,12 +31,12 @@ class ContactService( if (started) return started = true - driveContacts.start() + // ContactRepository is started by the post-auth bootstrap; we only need connections here. connections.start() scope.launch { combine( - driveContacts.contacts, + contactRepository.contacts.map { list -> list.mapNotNull { it.toContactUiModel() } }, connections.connections ) { contacts, connectionState -> diff --git a/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/convo/contact/ContactSizer.kt b/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/convo/contact/ContactSizer.kt deleted file mode 100644 index d608e60fa..000000000 --- a/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/convo/contact/ContactSizer.kt +++ /dev/null @@ -1,19 +0,0 @@ -package id.homebase.chat.services.convo.contact - -import id.homebase.chat.services.ChatProtocol -import kotlin.io.encoding.Base64 -import kotlin.io.encoding.ExperimentalEncodingApi - -/** - * Decides whether a serialized contact JSON fits in the file header (appData.content) or must - * spill into a separate payload. Mirrors the size check in TypeScript `saveContact` - * (`uint8ArrayToBase64(payloadBytes).length < MAX_HEADER_CONTENT_BYTES`). - */ -object ContactSizer { - - @OptIn(ExperimentalEncodingApi::class) - fun shouldEmbedInHeader(json: String): Boolean { - val base64Length = Base64.encode(json.encodeToByteArray()).length - return base64Length < ChatProtocol.MaxHeaderContentBytes - } -} diff --git a/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/convo/contact/DriveContactService.kt b/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/convo/contact/DriveContactService.kt deleted file mode 100644 index 22503d719..000000000 --- a/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/convo/contact/DriveContactService.kt +++ /dev/null @@ -1,392 +0,0 @@ -package id.homebase.chat.services.convo.contact - -import co.touchlab.kermit.Logger -import id.homebase.api.client.KeyHeader -import id.homebase.api.client.auth.CredentialsManager -import id.homebase.api.client.drives.AccessControlList -import id.homebase.api.client.drives.FileSystemType -import id.homebase.api.client.drives.HomebaseFile -import id.homebase.api.client.drives.QueryBatchSortField -import id.homebase.api.client.drives.QueryBatchSortOrder -import id.homebase.api.client.drives.files.PayloadFile -import id.homebase.api.client.drives.files.ThumbnailFile -import id.homebase.api.client.drives.query.QueryBatchCursor -import id.homebase.api.client.drives.upload.CreateFileResult -import id.homebase.api.client.drives.upload.FileUpdateInstructionSet -import id.homebase.api.client.drives.upload.UpdateFileByUniqueIdRequest -import id.homebase.api.client.drives.upload.UpdateLocale -import id.homebase.api.client.drives.upload.UpdateManifest -import id.homebase.api.client.drives.upload.UploadAppFileMetaData -import id.homebase.api.client.drives.upload.UploadFileMetadata -import id.homebase.api.client.drives.upload.UploadFileRequest -import id.homebase.api.client.drives.upload.DriveUploadProvider -import id.homebase.api.client.eventbus.BackendEvent -import id.homebase.api.client.eventbus.EventBus -import id.homebase.api.client.identity.PublicIdentityRepository -import id.homebase.api.common.BatchResult -import id.homebase.api.common.OdinId -import id.homebase.api.crypto.ByteArrayUtil -import id.homebase.api.crypto.Md5 -import id.homebase.api.file.FileOperationsProvider -import id.homebase.api.image.createThumbnails -import id.homebase.api.serialization.OdinSystemSerializer -import id.homebase.api.sync.database.DatabaseManager -import id.homebase.api.sync.database.QueryBatch -import id.homebase.chat.data.ContactUiModel -import id.homebase.chat.services.ChatProtocol -import id.homebase.core.config.contactTargetDrive -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch -import kotlin.io.encoding.Base64 -import kotlin.io.encoding.ExperimentalEncodingApi - -class DriveContactService( - private val credentialsManager: CredentialsManager, - private val dbm: DatabaseManager, - private val eventBus: EventBus, - private val scope: CoroutineScope, - private val driveUploadProvider: DriveUploadProvider, - private val publicIdentityRepository: PublicIdentityRepository, - private val fileOperationsProvider: FileOperationsProvider, -) { - - companion object { - private const val TAG = "DriveContactService" - } - - private val contactDrive = contactTargetDrive.alias - private val _contacts = MutableStateFlow>(emptyList()) - - private val contactByOdinId = - MutableStateFlow>(emptyMap()) - - val contacts: StateFlow> = _contacts.asStateFlow() - - init { - scope.launch { - eventBus.events.collect { event -> - // Two refresh triggers, by design — after the silent-DriveSync (e7f46062) - // and pure-push chat-drive (736b4f07) refactors, contact rows land via two - // event classes and we must converge from both: - // - // 1) DriveEvent.Stopped(totalCount > 0): catch-up after a sync round - // (cold-boot, reconnect). Gate on totalCount > 0 ALONE (not on - // result == Success): DriveSync writes each batch to DriveMainIndex - // before the next batch starts, so totalCount > 0 with Failure still - // means real contact rows landed. totalCount == 0 means cursor at - // HEAD: nothing to refresh. - // - // 2) DataEvent.BatchReceived: live pure-push WS upserts from - // DriveWebSocketUpsertWorker (and in-process OptimisticWriter). - // Without this branch, a contact arriving over the open WS (e.g. - // a just-accepted connection request) lands in DriveMainIndex but - // the _contacts StateFlow stays stale until next app restart. - // - // Both branches scope.launch { refresh() } — never call refresh() inline: - // QueryBatch hangs on partial connectivity, which would park the EventBus - // buffer and cascade into stalling the chat Send path. - if (event is BackendEvent.SessionEnded) { - reset() - } else if (event is BackendEvent.DriveEvent.Stopped && - event.driveId == contactDrive && - event.totalCount > 0 - ) { - scope.launch { refresh() } - } else if (event is BackendEvent.DataEvent.BatchReceived && - event.driveId == contactDrive && - event.batchData.isNotEmpty() - ) { - scope.launch { refresh() } - } - } - } - } - - private var startJob: Job? = null - - /** - * Logout: cancel any in-flight refresh and drop the previous identity's - * contacts. ContactService (which combines this flow) re-emits empty - * automatically. The next session repopulates via the contact-drive sync. - */ - fun reset() { - startJob?.cancel() - startJob = null - _contacts.value = emptyList() - contactByOdinId.value = emptyMap() - } - - fun start() { - if (startJob?.isActive == true) return - startJob = scope.launch { - refresh() - } - } - - fun resolveByOdinId(odinId: OdinId): ContactUiModel? { - return contactByOdinId.value[odinId] - } - - private suspend fun refresh() { - val result = fetchContacts() - // The contact drive can hold more than one file row for the same - // identity (e.g. a row from the connection-accept flow plus one from a - // later public-profile fetch). Records arrive NewestFirst, so - // distinctBy keeps the freshest row per odinId and drops the stale - // duplicate — otherwise the duplicate entry flows into every consumer - // of `contacts`. Chat pickers each defend with their own - // `.distinctBy { it.odinId }`; the moments audience picker did not, so - // it surfaced the duplicate. Dedupe once here at the source. - val deduped = result.records.distinctBy { it.odinId } - _contacts.value = deduped - contactByOdinId.value = - deduped.associateBy { it.odinId } - } - - - suspend fun fetchContacts( - limit: Int = 1000, - cursor: QueryBatchCursor? = null - ): BatchResult { - - val c = credentialsManager.requireActiveCredentials() - val queryBatch = QueryBatch(c.getIdentityId()) - - val result = - queryBatch.queryBatchAsync( - dbm = dbm, - driveId = contactDrive, - noOfItems = limit, - cursor = cursor, - sortOrder = QueryBatchSortOrder.NewestFirst, - sortField = QueryBatchSortField.CreatedDate, - fileSystemType = 0, - filetypesAnyOf = listOf(ChatProtocol.ContactFileType) - ) - - return BatchResult( - records = result.records.mapNotNull { mapToContact(it) }, - hasMoreRows = result.hasMoreRows, - cursor = result.cursor - ) - } - - /** - * Persists a contact to the owner's encrypted contact drive. Deduplicates by deriving the - * uniqueId deterministically from the contact's odinId (MD5-GUID), so repeated calls for the - * same identity update the existing record instead of creating a duplicate. - * - * Mirrors the TypeScript `saveContact` flow: embeds the JSON in the file header when it - * fits under [ChatProtocol.MaxHeaderContentBytes]; otherwise spills into a - * [ChatProtocol.DefaultPayloadKey] payload. If the contact carries a base64 image it's - * encrypted into a [ContactProtocol.ProfileImageKey] payload with thumbnails. - */ - @OptIn(ExperimentalEncodingApi::class) - suspend fun saveContact(contact: ContactServerFile): CreateFileResult? { - val odinId = contact.odinId - ?: throw IllegalArgumentException("Contact is missing odinId") - - val uniqueId = Md5.toGuidId(odinId.toString().lowercase()) - - val credentials = credentialsManager.requireActiveCredentials() - val existing = dbm.driveMainIndex.selectHomebaseFileByUnique( - credentials.getIdentityId(), contactDrive, uniqueId - ) - - val keyHeader = existing?.keyHeader?.aesKey?.let { aesKey -> - KeyHeader(iv = ByteArrayUtil.getRndByteArray(16), aesKey = aesKey) - } ?: KeyHeader.newRandom16() - - val payloads = mutableListOf() - val thumbnails = mutableListOf() - var previewThumb: id.homebase.api.client.drives.upload.EmbeddedThumb? = null - - // Strip image from header content; it travels as its own payload. - val image = contact.image - val contactForHeader = contact.copy(image = null) - - if (image != null) { - val imageBytes = Base64.decode(image.content) - - val (_, tinyThumb, generatedThumbs) = - createThumbnails(imageBytes, ContactProtocol.ProfileImageKey) - previewThumb = tinyThumb - - val payloadKeyHeader = KeyHeader( - iv = ByteArrayUtil.getRndByteArray(16), - aesKey = keyHeader.aesKey - ) - val encryptedImageBytes = payloadKeyHeader.encryptDataAes(imageBytes) - val encryptedImagePath = fileOperationsProvider.writeBytesToTempFile( - bytes = encryptedImageBytes, - prefix = "contact_img", - suffix = ".enc" - ) - - payloads += PayloadFile( - key = ContactProtocol.ProfileImageKey, - filePath = encryptedImagePath, - contentType = image.contentType, - iv = payloadKeyHeader.iv, - isPreEncrypted = true - ) - - thumbnails += generatedThumbs.map { thumb -> - thumb.copy( - thumbnailBytes = payloadKeyHeader.encryptDataAes(thumb.thumbnailBytes) - ) - } - } - - val contentJson = OdinSystemSerializer.serialize(contactForHeader) - val canEmbedInHeader = ContactSizer.shouldEmbedInHeader(contentJson) - - if (!canEmbedInHeader) { - val jsonKeyHeader = KeyHeader( - iv = ByteArrayUtil.getRndByteArray(16), - aesKey = keyHeader.aesKey - ) - val encryptedJsonBytes = - jsonKeyHeader.encryptDataAes(contentJson.encodeToByteArray()) - val encryptedJsonPath = fileOperationsProvider.writeBytesToTempFile( - bytes = encryptedJsonBytes, - prefix = "contact_body", - suffix = ".enc" - ) - - payloads += PayloadFile( - key = ChatProtocol.DefaultPayloadKey, - filePath = encryptedJsonPath, - contentType = "application/json", - iv = jsonKeyHeader.iv, - isPreEncrypted = true - ) - } - - val metadata = UploadFileMetadata( - allowDistribution = false, - isEncrypted = true, - accessControlList = AccessControlList(requiredSecurityGroup = "Owner"), - versionTag = existing?.fileMetadata?.versionTag, - appData = UploadAppFileMetaData( - uniqueId = uniqueId, - tags = listOf(uniqueId), - fileType = ChatProtocol.ContactFileType, - content = if (canEmbedInHeader) contentJson else null, - previewThumbnail = previewThumb - ) - ) - - Logger.d(tag = TAG) { - "saveContact odinId=$odinId uniqueId=$uniqueId existing=${existing != null} " + - "hasImage=${image != null} embedInHeader=$canEmbedInHeader" - } - - return if (existing != null) { - val result = driveUploadProvider.updateFileByUniqueId( - UpdateFileByUniqueIdRequest( - driveId = contactDrive, - uniqueId = uniqueId, - keyHeader = keyHeader, - instructions = FileUpdateInstructionSet( - transferIv = ByteArrayUtil.getRndByteArray(16), - locale = UpdateLocale.Local, - recipients = emptyList(), - manifest = UpdateManifest.build( - payloads = payloads, - toDeletePayloads = null, - thumbnails = thumbnails, - generatePayloadIv = false - ) - ), - metadata = metadata.encryptContent(keyHeader), - payloads = payloads, - thumbnails = thumbnails - ) - ) ?: return null - - CreateFileResult( - fileId = existing.fileId, - driveId = contactDrive, - globalTransitId = null, - recipientStatus = result.recipientStatus, - newVersionTag = result.newVersionTag - ) - } else { - driveUploadProvider.uploadFile( - UploadFileRequest( - driveId = contactDrive, - keyHeader = keyHeader, - metadata = metadata.encryptContent(keyHeader), - payloads = payloads, - thumbnails = thumbnails, - fileSystemType = FileSystemType.Standard - ) - ) - } - } - - /** - * Best-effort: fetches the other identity's public profile via `sitedata.json` and persists - * it to the contact drive. Swallows errors so callers (typically the connection-request - * flow) are never interrupted by a contact-save failure. - */ - suspend fun saveContactForOdinId(odinId: OdinId) { - try { - val identity = publicIdentityRepository.resolve(odinId) ?: return - - // Coerce blank strings to null so the reader's `?: domainName` fallback kicks - // in — otherwise we'd persist a contact with an empty displayName and the - // conversation list would render blanks instead of the domain. - saveContact( - ContactServerFile( - odinId = odinId, - name = ContactName( - displayName = identity.displayName?.takeIf { it.isNotBlank() }, - givenName = identity.firstName?.takeIf { it.isNotBlank() }, - additionalName = null, - surname = identity.surName?.takeIf { it.isNotBlank() }, - ), - source = "public" - ) - ) - } catch (e: kotlin.coroutines.cancellation.CancellationException) { - throw e - } catch (e: Exception) { - Logger.w(throwable = e, tag = TAG) { "saveContactForOdinId failed for $odinId" } - } - } - - private suspend fun mapToContact(header: HomebaseFile): ContactUiModel? { - val metadata = header.fileMetadata - val appData = metadata.appData - - val uid = appData.uniqueId - if (uid == null) { - Logger.e("Contact found with null uniqueId") - return null - } - - val content = appData.content ?: "" - val parsedContact = try { - OdinSystemSerializer.deserialize(content) - } catch (e: Exception) { - Logger.e(e) { "Failed to deserialize contact content for uid=$uid: ${content.take(200)}" } - return null - } - - return ContactUiModel( - id = uid, - odinId = parsedContact.odinId - ?: throw IllegalStateException("why is the odin id missing?"), - name = parsedContact.name.displayName?.takeIf { it.isNotBlank() } - ?: parsedContact.odinId.domainName, - avatarInitials = parsedContact.name.initials(), - avatarUrl = "https://${parsedContact.odinId}/pub/image" - ) - } -} diff --git a/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/requests/ConnectionRequestService.kt b/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/requests/ConnectionRequestService.kt index ad2e5933f..1016169e8 100644 --- a/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/requests/ConnectionRequestService.kt +++ b/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/requests/ConnectionRequestService.kt @@ -7,6 +7,7 @@ import id.homebase.api.client.connections.ConnectionRequestHeader import id.homebase.api.client.connections.ConnectionRequestProvider import id.homebase.api.client.connections.IncomingConnectionRequestResponse import id.homebase.api.client.connections.OutgoingConnectionRequestResponse +import id.homebase.api.client.contacts.ContactRepository import id.homebase.api.client.eventbus.BackendEvent import id.homebase.api.client.eventbus.EventBus import id.homebase.api.common.OdinId @@ -15,7 +16,6 @@ import id.homebase.chat.data.IncomingConnectionRequestUiModel import id.homebase.chat.data.OutgoingConnectionRequestUiModel import id.homebase.chat.services.convo.contact.ConnectionCacheRepository import id.homebase.chat.services.convo.contact.ConnectionService -import id.homebase.chat.services.convo.contact.DriveContactService import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow @@ -28,7 +28,7 @@ import kotlin.time.Clock class ConnectionRequestService( private val connectionRequestProvider: ConnectionRequestProvider, - private val driveContactService: DriveContactService, + private val contactRepository: ContactRepository, private val connectionService: ConnectionService, private val eventBus: EventBus, private val scope: CoroutineScope, @@ -225,18 +225,18 @@ class ConnectionRequestService( removeFromOutgoing(header.recipient) refresh() connectionService.refresh() - driveContactService.saveContactForOdinId(header.recipient) + contactRepository.sync(header.recipient) } AutoConnectOutcome.AlreadyConnected -> { connectionService.refresh() - driveContactService.saveContactForOdinId(header.recipient) + contactRepository.sync(header.recipient) } AutoConnectOutcome.PendingManualApproval -> { markOutgoingOptimistically(header.recipient) refresh() // Save contact so they appear in the contact list immediately — matches // the legacy sendConnectionRequest flow, which saved on HTTP-200. - driveContactService.saveContactForOdinId(header.recipient) + contactRepository.sync(header.recipient) } AutoConnectOutcome.OutgoingRequestAlreadyExists, AutoConnectOutcome.DuplicateIntroductoryRequest -> { @@ -264,7 +264,7 @@ class ConnectionRequestService( */ suspend fun acceptIncomingRequest(senderId: OdinId) { connectionRequestProvider.acceptIncomingRequest(senderId) - driveContactService.saveContactForOdinId(senderId) + contactRepository.sync(senderId) removeFromIncoming(senderId) refresh() connectionService.refresh() diff --git a/homebase-core/src/commonMain/kotlin/id/homebase/core/di/AppModule.kt b/homebase-core/src/commonMain/kotlin/id/homebase/core/di/AppModule.kt index 0954f7431..b76ed0b0a 100644 --- a/homebase-core/src/commonMain/kotlin/id/homebase/core/di/AppModule.kt +++ b/homebase-core/src/commonMain/kotlin/id/homebase/core/di/AppModule.kt @@ -60,7 +60,6 @@ import id.homebase.chat.services.convo.PostCreateIntroductionPreflightBus import id.homebase.chat.services.convo.contact.ConnectionCacheRepository import id.homebase.chat.services.convo.contact.ConnectionService import id.homebase.chat.services.convo.contact.ContactService -import id.homebase.chat.services.convo.contact.DriveContactService import id.homebase.chat.services.outbox.OptimisticWriter import id.homebase.chat.services.requests.ConnectionRequestService import id.homebase.core.NotificationActionBridge @@ -469,7 +468,6 @@ val appModule = module { singleOf(::ConnectionCacheRepository) singleOf(::ConnectionService) - singleOf(::DriveContactService) singleOf(::ContactService) singleOf(::ConversationStream) bind ConversationLoader::class single { get() } From cd053704b927f5d202657f0206aaaf355db18949 Mon Sep 17 00:00:00 2001 From: toddmitchell Date: Fri, 19 Jun 2026 16:48:07 -0500 Subject: [PATCH 05/23] Contact detail: divider above danger zone + labeled details with first/last name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Danger zone is now set off by a HorizontalDivider (clearer separation). - "Contact details" lists labeled rows (overline label + value): First name, Last name, Homebase ID, Phone, Email, Location, Birthday — name parts first so an identity contact shows real details, not just the Homebase ID; the rest tuck behind the existing "More" toggle. Reuses the edit-form label strings + a new contactbook_detail_location. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../composeResources/values/strings.xml | 1 + .../detail/ContactDetailSections.kt | 45 ++++++++++++++----- 2 files changed, 35 insertions(+), 11 deletions(-) diff --git a/homebase-common/src/commonMain/composeResources/values/strings.xml b/homebase-common/src/commonMain/composeResources/values/strings.xml index a95543c00..b61ea0e63 100644 --- a/homebase-common/src/commonMain/composeResources/values/strings.xml +++ b/homebase-common/src/commonMain/composeResources/values/strings.xml @@ -1356,6 +1356,7 @@ Recent media No recent media yet Contact details + Location Circles Not in any of your circles yet. Connect with this person to add them to your circles. diff --git a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailSections.kt b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailSections.kt index a46da5e33..8fb277a91 100644 --- a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailSections.kt +++ b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailSections.kt @@ -25,8 +25,10 @@ import androidx.compose.material.icons.outlined.Call import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.Email import androidx.compose.material.icons.outlined.LocationOn +import androidx.compose.material.icons.outlined.Person import androidx.compose.material.icons.outlined.PersonRemove import androidx.compose.material.icons.outlined.Sync +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme @@ -54,6 +56,13 @@ import id.homebase.resources.contactbook_detail_circles import id.homebase.resources.contactbook_detail_circles_connect import id.homebase.resources.contactbook_detail_circles_empty import id.homebase.resources.contactbook_detail_contact_details +import id.homebase.resources.contactbook_detail_location +import id.homebase.resources.contactbook_edit_birthday +import id.homebase.resources.contactbook_edit_email +import id.homebase.resources.contactbook_edit_given_name +import id.homebase.resources.contactbook_edit_odinid +import id.homebase.resources.contactbook_edit_phone +import id.homebase.resources.contactbook_edit_surname import id.homebase.resources.contactbook_detail_danger_zone import id.homebase.resources.contactbook_detail_groups_connect import id.homebase.resources.contactbook_detail_groups_empty @@ -223,14 +232,25 @@ fun ContactFieldsSection( modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), ) + // Labels resolved here (stringResource can't be called inside the buildList builder). + val lblFirst = stringResource(MR.string.contactbook_edit_given_name) + val lblLast = stringResource(MR.string.contactbook_edit_surname) + val lblId = stringResource(MR.string.contactbook_edit_odinid) + val lblPhone = stringResource(MR.string.contactbook_edit_phone) + val lblEmail = stringResource(MR.string.contactbook_edit_email) + val lblLocation = stringResource(MR.string.contactbook_detail_location) + val lblBirthday = stringResource(MR.string.contactbook_edit_birthday) + + // Each field is (icon, label, value). Name parts come first so an identity contact shows its + // real details, not just the Homebase ID; the rest tuck behind "More". val fields = buildList { - // The Homebase ID is a contact detail too — without it an identity-only contact - // (synced from a connection, no phone/email) reads as "None" here despite having data. - entry.odinId?.takeIf { it.isNotBlank() }?.let { add(Icons.Outlined.AlternateEmail to it) } - entry.phone?.takeIf { it.isNotBlank() }?.let { add(Icons.Outlined.Call to it) } - entry.email?.takeIf { it.isNotBlank() }?.let { add(Icons.Outlined.Email to it) } - entry.location?.takeIf { it.isNotBlank() }?.let { add(Icons.Outlined.LocationOn to it) } - entry.birthday?.takeIf { it.isNotBlank() }?.let { add(Icons.Outlined.Cake to it) } + entry.givenName?.takeIf { it.isNotBlank() }?.let { add(Triple(Icons.Outlined.Person, lblFirst, it)) } + entry.surname?.takeIf { it.isNotBlank() }?.let { add(Triple(Icons.Outlined.Person, lblLast, it)) } + entry.odinId?.takeIf { it.isNotBlank() }?.let { add(Triple(Icons.Outlined.AlternateEmail, lblId, it)) } + entry.phone?.takeIf { it.isNotBlank() }?.let { add(Triple(Icons.Outlined.Call, lblPhone, it)) } + entry.email?.takeIf { it.isNotBlank() }?.let { add(Triple(Icons.Outlined.Email, lblEmail, it)) } + entry.location?.takeIf { it.isNotBlank() }?.let { add(Triple(Icons.Outlined.LocationOn, lblLocation, it)) } + entry.birthday?.takeIf { it.isNotBlank() }?.let { add(Triple(Icons.Outlined.Cake, lblBirthday, it)) } } if (fields.isEmpty()) { Text( @@ -243,7 +263,7 @@ fun ContactFieldsSection( } val visible = if (expanded) fields else fields.take(2) - visible.forEach { (icon, value) -> DetailField(icon, value) } + visible.forEach { (icon, label, value) -> DetailField(icon, label, value) } if (fields.size > 2) { TextButton( @@ -274,8 +294,10 @@ fun ManagementSection( ) { onAction(ContactDetailAction.SyncClicked) } } - // Danger zone — disconnect / block / delete. - Spacer(modifier = Modifier.height(8.dp)) + // Danger zone — set apart with a divider so it's clearly separated from the rest. + Spacer(modifier = Modifier.height(16.dp)) + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) + Spacer(modifier = Modifier.height(12.dp)) Text( text = stringResource(MR.string.contactbook_detail_danger_zone), style = MaterialTheme.typography.titleSmall, @@ -329,10 +351,11 @@ private fun SharedMediaThumb(item: SharedMediaItem, size: Dp, onClick: () -> Uni } @Composable -private fun DetailField(icon: ImageVector, value: String?) { +private fun DetailField(icon: ImageVector, label: String, value: String?) { if (value.isNullOrBlank()) return ListItem( leadingContent = { Icon(icon, contentDescription = null) }, + overlineContent = { Text(label) }, headlineContent = { Text(value) }, ) } From 8064a05d5a89ed0bc0122892a9db70107128fece Mon Sep 17 00:00:00 2001 From: toddmitchell Date: Fri, 19 Jun 2026 18:51:39 -0500 Subject: [PATCH 06/23] Use ContactDetail for 1:1 contact info; retire the ContactInfo overview screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The minimal ContactInfoScreen (avatar + name + odinId) shown for a 1:1 conversation / group-member tap is a strict subset of the contact-detail screen, which already loads the 1:1 conversation overview (recent media, groups-in-common, circles) plus the full contact fields, message/connect, and danger zone. Converge on one screen: - Both nav sites (1:1 conversation info, group-member tap) now navigate to Route.ContactBookDetail(uniqueId = md5(odinId), odinId) instead of Route.ContactInfo. - Delete ContactInfoScreen/ViewModel/UiState/UiAction, Route.ContactInfo, and the DI registration. The chat-side ShowContactInfo actions/events are unchanged — they just deliver an odinId to the screen callback, which now opens the contact detail. Core + chat + Konsist jvmTest suites pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../chat/contactinfo/ContactInfoScreen.kt | 112 ------------------ .../chat/contactinfo/ContactInfoUiAction.kt | 5 - .../chat/contactinfo/ContactInfoUiState.kt | 15 --- .../chat/contactinfo/ContactInfoViewModel.kt | 51 -------- .../id/homebase/core/ui/navigation/Routes.kt | 4 - .../kotlin/id/homebase/core/di/AppModule.kt | 2 - .../homebase/core/ui/navigation/AppNavHost.kt | 29 +++-- 7 files changed, 17 insertions(+), 201 deletions(-) delete mode 100644 homebase-chat/src/commonMain/kotlin/id/homebase/chat/contactinfo/ContactInfoScreen.kt delete mode 100644 homebase-chat/src/commonMain/kotlin/id/homebase/chat/contactinfo/ContactInfoUiAction.kt delete mode 100644 homebase-chat/src/commonMain/kotlin/id/homebase/chat/contactinfo/ContactInfoUiState.kt delete mode 100644 homebase-chat/src/commonMain/kotlin/id/homebase/chat/contactinfo/ContactInfoViewModel.kt diff --git a/homebase-chat/src/commonMain/kotlin/id/homebase/chat/contactinfo/ContactInfoScreen.kt b/homebase-chat/src/commonMain/kotlin/id/homebase/chat/contactinfo/ContactInfoScreen.kt deleted file mode 100644 index 1ced9759f..000000000 --- a/homebase-chat/src/commonMain/kotlin/id/homebase/chat/contactinfo/ContactInfoScreen.kt +++ /dev/null @@ -1,112 +0,0 @@ -package id.homebase.chat.contactinfo - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import id.homebase.chat.widget.ErrorInfoItem -import id.homebase.chat.widget.LoadingListItem -import id.homebase.core.avatars.AvatarOptions -import id.homebase.core.avatars.ContactAvatar -import id.homebase.resources.MR -import id.homebase.resources.error_no_contact_loaded -import id.homebase.resources.menu_back -import org.jetbrains.compose.resources.stringResource - -@Composable -fun ContactInfoScreen( - viewModel: ContactInfoViewModel, - onNavigateBack: () -> Unit, -) { - val uiState by viewModel.uiState.collectAsStateWithLifecycle() - - when (uiState.uiEvent) { - is ContactInfoUiEvent.Back -> { - viewModel.eventConsumed() - onNavigateBack() - } - - null -> {} - } - - ContactInfoUi( - uiState = uiState, - onUiAction = viewModel::onUiAction - ) -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun ContactInfoUi( - uiState: ContactInfoUiState, - onUiAction: (ContactInfoUiAction) -> Unit, -) { - Scaffold( - topBar = { - TopAppBar( - title = {}, - navigationIcon = { - IconButton(onClick = { onUiAction(ContactInfoUiAction.BackClicked) }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(MR.string.menu_back) - ) - } - }, - ) - } - ) { padding -> - Column( - modifier = Modifier.padding(padding).fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - if (uiState.contact == null) { - if (uiState.isLoading) { - LoadingListItem() - } else { - ErrorInfoItem(stringResource(MR.string.error_no_contact_loaded)) - } - } - - uiState.contact?.let { contact -> - ContactAvatar( - odinId = contact.odinId, - profileImageData = null, - initials = contact.avatarInitials, - options = AvatarOptions( - size = 72.dp, - fontSize = 24.sp, - ), - sharedTransitionScope = null, - animatedVisibilityScope = null - ) - Spacer(modifier = Modifier.height(16.dp)) - Text( - text = contact.name, - style = MaterialTheme.typography.headlineSmall, - ) - Text( - text = contact.odinId.domainName, - style = MaterialTheme.typography.bodyMedium, - ) - } - } - } -} \ No newline at end of file diff --git a/homebase-chat/src/commonMain/kotlin/id/homebase/chat/contactinfo/ContactInfoUiAction.kt b/homebase-chat/src/commonMain/kotlin/id/homebase/chat/contactinfo/ContactInfoUiAction.kt deleted file mode 100644 index 9a9f702e6..000000000 --- a/homebase-chat/src/commonMain/kotlin/id/homebase/chat/contactinfo/ContactInfoUiAction.kt +++ /dev/null @@ -1,5 +0,0 @@ -package id.homebase.chat.contactinfo - -sealed interface ContactInfoUiAction { - data object BackClicked : ContactInfoUiAction -} \ No newline at end of file diff --git a/homebase-chat/src/commonMain/kotlin/id/homebase/chat/contactinfo/ContactInfoUiState.kt b/homebase-chat/src/commonMain/kotlin/id/homebase/chat/contactinfo/ContactInfoUiState.kt deleted file mode 100644 index c26ebb74c..000000000 --- a/homebase-chat/src/commonMain/kotlin/id/homebase/chat/contactinfo/ContactInfoUiState.kt +++ /dev/null @@ -1,15 +0,0 @@ -package id.homebase.chat.contactinfo - -import androidx.compose.runtime.Immutable -import id.homebase.chat.data.ContactUiModel - -@Immutable -data class ContactInfoUiState( - val isLoading: Boolean = true, - val contact: ContactUiModel? = null, - val uiEvent: ContactInfoUiEvent? = null, -) - -sealed interface ContactInfoUiEvent { - object Back : ContactInfoUiEvent -} diff --git a/homebase-chat/src/commonMain/kotlin/id/homebase/chat/contactinfo/ContactInfoViewModel.kt b/homebase-chat/src/commonMain/kotlin/id/homebase/chat/contactinfo/ContactInfoViewModel.kt deleted file mode 100644 index 869a802e4..000000000 --- a/homebase-chat/src/commonMain/kotlin/id/homebase/chat/contactinfo/ContactInfoViewModel.kt +++ /dev/null @@ -1,51 +0,0 @@ -package id.homebase.chat.contactinfo - -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import androidx.navigation.toRoute -import co.touchlab.kermit.Logger -import id.homebase.api.common.OdinId -import id.homebase.chat.services.convo.contact.ContactService -import id.homebase.core.ui.navigation.Route -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch - -class ContactInfoViewModel( - savedStateHandle: SavedStateHandle, - val contactService: ContactService, -) : ViewModel() { - val route = savedStateHandle.toRoute() - private val _uiState = MutableStateFlow(ContactInfoUiState()) - val uiState: StateFlow = _uiState.asStateFlow() - - init { - loadData() - } - - fun onUiAction(action: ContactInfoUiAction) { - when (action) { - is ContactInfoUiAction.BackClicked -> _uiState.update { it.copy(uiEvent = ContactInfoUiEvent.Back)} - } - } - - fun eventConsumed() { - _uiState.update { it.copy(uiEvent = null) } - } - - private fun loadData() { - viewModelScope.launch { - try { - contactService.start() - val contact = contactService.resolveByOdinId(OdinId(route.odinId)) - _uiState.update { it.copy(contact = contact, isLoading = false) } - } catch (e: Exception) { - Logger.e( "Failed to load contact", e) - _uiState.update { it.copy(isLoading = false) } - } - } - } -} \ No newline at end of file diff --git a/homebase-common/src/commonMain/kotlin/id/homebase/core/ui/navigation/Routes.kt b/homebase-common/src/commonMain/kotlin/id/homebase/core/ui/navigation/Routes.kt index 45515dfd1..83e1c6f4e 100644 --- a/homebase-common/src/commonMain/kotlin/id/homebase/core/ui/navigation/Routes.kt +++ b/homebase-common/src/commonMain/kotlin/id/homebase/core/ui/navigation/Routes.kt @@ -39,10 +39,6 @@ sealed class Route { data class MessageInfo(val conversationId: String, val messageId: String, val fileId: String) : Route() - @Serializable - @SerialName("contact") - data class ContactInfo(val odinId: String) : Route() - @Serializable @SerialName("archived-conversations") data object ArchivedConversations : Route() diff --git a/homebase-core/src/commonMain/kotlin/id/homebase/core/di/AppModule.kt b/homebase-core/src/commonMain/kotlin/id/homebase/core/di/AppModule.kt index 0954f7431..684f06392 100644 --- a/homebase-core/src/commonMain/kotlin/id/homebase/core/di/AppModule.kt +++ b/homebase-core/src/commonMain/kotlin/id/homebase/core/di/AppModule.kt @@ -18,7 +18,6 @@ import okio.Path.Companion.toPath import id.homebase.auth.login.LoginViewModel import id.homebase.chat.addgroupmembers.AddGroupMembersViewModel import id.homebase.chat.archivedconversations.ArchivedConversationsViewModel -import id.homebase.chat.contactinfo.ContactInfoViewModel import id.homebase.chat.conversationlist.ConversationListViewModel import id.homebase.chat.conversationlist.ExtendPermissionViewModel import id.homebase.chat.conversationmedia.ConversationMediaViewModel @@ -661,7 +660,6 @@ val appModule = module { viewModelOf(::CreateConversationGroupViewModel) viewModelOf(::SelectMembersViewModel) viewModelOf(::MessageInfoViewModel) - viewModelOf(::ContactInfoViewModel) viewModelOf(::ConversationSettingsViewModel) viewModelOf(::ConversationMediaViewModel) viewModelOf(::GroupSettingsViewModel) diff --git a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/navigation/AppNavHost.kt b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/navigation/AppNavHost.kt index 2e2fe2004..3790808fd 100644 --- a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/navigation/AppNavHost.kt +++ b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/navigation/AppNavHost.kt @@ -69,7 +69,7 @@ import id.homebase.api.youauth.YouAuthState import id.homebase.auth.login.LoginScreen import id.homebase.chat.addgroupmembers.AddGroupMembersScreen import id.homebase.chat.archivedconversations.ArchivedConversationsScreen -import id.homebase.chat.contactinfo.ContactInfoScreen +import id.homebase.api.crypto.Md5 import id.homebase.chat.conversationlist.ConversationListScreen import id.homebase.chat.conversationmedia.ConversationMediaScreen import id.homebase.chat.conversationlist.ConversationListViewModel @@ -882,7 +882,14 @@ fun AppNavHost( navController.navigate(Route.CreateConversation) }, onNavigateToContactInfo = { - navController.navigate(Route.ContactInfo(it)) + // 1:1 contact info is the full contact-detail screen + // (keyed by the contact uniqueId = md5(odinId)). + navController.navigate( + Route.ContactBookDetail( + uniqueId = Md5.toGuidId(it.lowercase()).toString(), + odinId = it, + ) + ) }, onNavigateToConversationSettings = { navController.navigate(Route.ConversationSettings(it)) @@ -993,15 +1000,6 @@ fun AppNavHost( } } - composable { - if (isAuthenticated) { - ContactInfoScreen( - viewModel = koinViewModel(), - onNavigateBack = { navController.popBackStack() }, - ) - } - } - composable { if (isAuthenticated) { MessageInfoScreen( @@ -1077,7 +1075,14 @@ fun AppNavHost( viewModel = koinViewModel(), onNavigateBack = { navController.popBackStack() }, onShowContactInfo = { - navController.navigate(Route.ContactInfo(it)) + // 1:1 contact info is the full contact-detail screen + // (keyed by the contact uniqueId = md5(odinId)). + navController.navigate( + Route.ContactBookDetail( + uniqueId = Md5.toGuidId(it.lowercase()).toString(), + odinId = it, + ) + ) }, onAddMembers = { navController.navigate(Route.GroupAddMembers(it)) From 42c38be1f8f1f274826d5e3762398c3e2d8e4bff Mon Sep 17 00:00:00 2001 From: toddmitchell Date: Fri, 19 Jun 2026 19:23:27 -0500 Subject: [PATCH 07/23] 1:1 conversation "info" opens the contact detail screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bug: opening a 1:1 chat and tapping the header (or the overflow "Conversation info", or long-pressing it in the list) fired ShowConversationSettings, which opened the conversation-overview/settings screen — not the contact detail. The earlier ContactInfo rewire only covered reaction taps / group members, missing this main path. handleShowConversationSettings now routes a 1:1 (non-group, non-self) to NavigateToContactInfo(peerOdinId) -> the full contact-detail screen. Groups still open group settings; note-to-self keeps the conversation-settings screen (no contact). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../conversationlist/ConversationLifecycleHandler.kt | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/homebase-chat/src/commonMain/kotlin/id/homebase/chat/conversationlist/ConversationLifecycleHandler.kt b/homebase-chat/src/commonMain/kotlin/id/homebase/chat/conversationlist/ConversationLifecycleHandler.kt index f93c55cfc..3a067cc02 100644 --- a/homebase-chat/src/commonMain/kotlin/id/homebase/chat/conversationlist/ConversationLifecycleHandler.kt +++ b/homebase-chat/src/commonMain/kotlin/id/homebase/chat/conversationlist/ConversationLifecycleHandler.kt @@ -161,7 +161,12 @@ internal class ConversationLifecycleHandler( /* Conversation options */ fun handleShowConversationSettings(action: ConversationListUiAction.ShowConversationSettings) { - if (action.conversation.isGroupConversation) { + val conversation = action.conversation + // A 1:1 conversation's "info" is the contact — open the full contact-detail screen + // (keyed by the peer's odinId) instead of a separate conversation-overview screen. + // Groups go to group settings; note-to-self has no contact, so it keeps the settings screen. + val peerOdinId = conversation.participants.firstOrNull()?.domainName + if (conversation.isGroupConversation) { uiState.update { it.copy( uiEvent = NavigateToGroupSettings( @@ -169,6 +174,8 @@ internal class ConversationLifecycleHandler( ) ) } + } else if (!conversation.isWithSelf && peerOdinId != null) { + uiState.update { it.copy(uiEvent = NavigateToContactInfo(peerOdinId)) } } else { uiState.update { it.copy( From 4792b8359ecd9af97d368455022278d2106c302e Mon Sep 17 00:00:00 2001 From: toddmitchell Date: Fri, 19 Jun 2026 19:34:01 -0500 Subject: [PATCH 08/23] Contact detail: surface full shared-content overview (parity with 1:1 settings) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RecentMediaSection now shows "See all" whenever the 1:1 has ANY shared content (media, files, audio, dice rolls, or locations) — not just media — so non-media items are reachable even with no media to strip; the empty state shows only when there's truly nothing. "See all" already routes to the same ConversationMedia screen the conversation-settings overview used, so the contact detail now fully subsumes that overview with nothing lost. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../detail/ContactDetailSections.kt | 33 ++++++++++++------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailSections.kt b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailSections.kt index 8fb277a91..d001a5ac4 100644 --- a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailSections.kt +++ b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailSections.kt @@ -80,7 +80,12 @@ import id.homebase.resources.conversation_media_see_all import org.jetbrains.compose.resources.stringResource import kotlin.uuid.ExperimentalUuidApi -/** Recent-media strip + "See all". The header is always shown; an empty state replaces the strip. */ +/** + * Shared-content overview for the 1:1 conversation: a recent-media strip plus a "See all" that + * reaches the full shared-content screen (media, files, audio, dice rolls, locations). "See all" + * shows whenever there's *any* shared content — not just media — so non-media items are reachable + * even when there's nothing to strip. The empty state shows only when there's truly nothing. + */ @Composable fun RecentMediaSection( overview: ConversationOverview?, @@ -88,6 +93,11 @@ fun RecentMediaSection( onSeeAll: () -> Unit, ) { val media = overview?.media.orEmpty() + val hasAnything = overview != null && ( + overview.media.isNotEmpty() || overview.files.isNotEmpty() || + overview.audio.isNotEmpty() || overview.diceRolls.isNotEmpty() || + overview.locations.isNotEmpty() + ) Row( modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 4.dp), @@ -98,21 +108,14 @@ fun RecentMediaSection( style = MaterialTheme.typography.titleSmall, modifier = Modifier.weight(1f), ) - if (media.isNotEmpty()) { + if (hasAnything) { TextButton(onClick = onSeeAll) { Text(stringResource(MR.string.conversation_media_see_all)) } } } - if (media.isEmpty()) { - Text( - text = stringResource(MR.string.contactbook_detail_no_recent_media), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), - ) - } else { - LazyRow( + when { + media.isNotEmpty() -> LazyRow( modifier = Modifier.fillMaxWidth(), contentPadding = PaddingValues(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -121,6 +124,14 @@ fun RecentMediaSection( SharedMediaThumb(item, size = 84.dp) { onMediaClick(item) } } } + // Has non-media shared content but no media to strip: "See all" above leads to it. + hasAnything -> Unit + else -> Text( + text = stringResource(MR.string.contactbook_detail_no_recent_media), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) } } From 99215fbe711ede94673e9488694a0579457d93b9 Mon Sep 17 00:00:00 2001 From: toddmitchell Date: Mon, 22 Jun 2026 13:35:44 -0500 Subject: [PATCH 09/23] Contacts: fix ContactRepository/ContactsProvider review findings Wire clearKeyCache through reset(), evict the stale per-uniqueId AES key on delete, make the deletedIds resurrection guard race-safe, and lift delete suppression before sync re-creates a contact. Adds ContactRepositoryTest covering the load/delete/sync paths. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../api/client/contacts/ContactRepository.kt | 50 ++- .../api/client/contacts/ContactsProvider.kt | 14 +- .../client/contacts/ContactRepositoryTest.kt | 396 ++++++++++++++++++ 3 files changed, 445 insertions(+), 15 deletions(-) create mode 100644 homebase-api/src/jvmTest/kotlin/id/homebase/api/client/contacts/ContactRepositoryTest.kt diff --git a/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactRepository.kt b/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactRepository.kt index fbcf2de55..3e62a0bd6 100644 --- a/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactRepository.kt +++ b/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactRepository.kt @@ -12,6 +12,7 @@ import id.homebase.api.client.drives.SystemDriveConstants import id.homebase.api.client.eventbus.BackendEvent import id.homebase.api.client.eventbus.EventBus import id.homebase.api.common.OdinId +import id.homebase.api.crypto.Md5 import id.homebase.api.sync.database.DatabaseManager import id.homebase.api.sync.database.QueryBatch import kotlinx.coroutines.CoroutineScope @@ -56,8 +57,11 @@ class ContactRepository( val isLoaded: StateFlow = _isLoaded.asStateFlow() // Resurrection guard: a removed contact must not reappear from a stale batch before the - // server-confirmed delete syncs down. - private val deletedIds = mutableSetOf() + // server-confirmed delete syncs down. A StateFlow (not a plain MutableSet) because it's + // touched from multiple threads — observeEvents runs on the shared Dispatchers.Default scope + // while save/delete/loadAll run on arbitrary caller coroutines; atomic update {} / .value + // avoids the data race a bare mutableSetOf would have. + private val deletedIds = MutableStateFlow>(emptySet()) // Serializes loadAll so concurrent ensureLoaded() callers don't run overlapping queries. private val loadMutex = Mutex() @@ -79,7 +83,11 @@ class ContactRepository( fun reset() { _contacts.value = emptyList() _isLoaded.value = false - deletedIds.clear() + deletedIds.value = emptySet() + // Drop the provider's per-uniqueId AES-key cache: uniqueId = md5(odinId) collides across + // identities, so a surviving entry would encrypt the next identity's image under this + // identity's key. Launched because clearKeyCache() is suspend (mutex-guarded). + scope.launch { contactsProvider.clearKeyCache() } } /** @@ -110,15 +118,30 @@ class ContactRepository( filetypesAnyOf = listOf(ContactsProvider.CONTACT_FILE_TYPE), ) // The drive can hold >1 row per identity; NewestFirst + distinctBy keeps the freshest. - _contacts.value = result.records + val deleted = deletedIds.value + val fresh = result.records .mapNotNull { it.toContact() } - .filter { it.uniqueId !in deletedIds } .distinctBy { it.uniqueId } + _contacts.value = fresh.filter { it.uniqueId !in deleted } + + // Bound the resurrection guard so it can't grow unbounded across a session: this query + // is authoritative, so any id we know we deleted that the server no longer returns has + // had its delete honored and can be forgotten. Remove exactly those (not `intersect + // present`) so a delete issued concurrently with this query — whose id isn't in this + // snapshot — keeps its suppression. + if (deleted.isNotEmpty()) { + val present = fresh.mapTo(HashSet()) { it.uniqueId } + val confirmedGone = deleted - present + if (confirmedGone.isNotEmpty()) deletedIds.update { it - confirmedGone } + } + + _isLoaded.value = true Logger.d(tag = TAG) { "loadAll: ${_contacts.value.size} contact(s)" } } catch (e: Exception) { + // Leave _isLoaded untouched so ensureLoaded() will retry this session instead of being + // stuck with an empty list from a transient query failure. Logger.e(e, TAG) { "Failed to load contacts" } } - _isLoaded.value = true } private suspend fun observeEvents() { @@ -134,7 +157,7 @@ class ContactRepository( if (event.driveId != driveId) return@collect for (file in event.batchData) { val contact = file.toContact() ?: continue - if (contact.uniqueId in deletedIds) continue + if (contact.uniqueId in deletedIds.value) continue upsert(contact) } } @@ -189,7 +212,7 @@ class ContactRepository( return null } - deletedIds -= response.uniqueId + deletedIds.update { it - response.uniqueId } val existingImage = _contacts.value.firstOrNull { it.uniqueId == response.uniqueId }?.image upsert(Contact(response.uniqueId, response.versionTag, content, existingImage)) return response @@ -200,7 +223,7 @@ class ContactRepository( * truth. Returns true on success (or already-gone). Rethrows [ForbiddenException] (403). */ suspend fun delete(uniqueId: Uuid): Boolean { - deletedIds += uniqueId + deletedIds.update { it + uniqueId } _contacts.update { current -> current.filterNot { it.uniqueId == uniqueId } } return try { contactsProvider.deleteContact(uniqueId) @@ -208,12 +231,12 @@ class ContactRepository( } catch (e: CancellationException) { throw e } catch (e: ForbiddenException) { - deletedIds -= uniqueId + deletedIds.update { it - uniqueId } loadAll() throw e } catch (e: Exception) { Logger.w(e, TAG) { "deleteContact failed for $uniqueId" } - deletedIds -= uniqueId + deletedIds.update { it - uniqueId } loadAll() false } @@ -221,6 +244,11 @@ class ContactRepository( /** Best-effort server-side enrichment of a connected identity from its public profile. */ suspend fun sync(odinId: OdinId) { + // A prior delete of this same identity left its uniqueId in the resurrection guard. The + // server (re-)creates the contact under uniqueId = md5(odinId), so lift the suppression for + // that id first — otherwise the re-created contact's incoming batch would be dropped. + // Md5.toGuidId mirrors the server's hash; domainName is already lower-cased/normalized. + deletedIds.update { it - Md5.toGuidId(odinId.domainName) } try { contactsProvider.syncContact(odinId) } catch (e: CancellationException) { diff --git a/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactsProvider.kt b/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactsProvider.kt index 57d1eeece..9ee423e85 100644 --- a/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactsProvider.kt +++ b/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactsProvider.kt @@ -128,6 +128,12 @@ class ContactsProvider( secret = creds.secret, ) + // Drop any cached AES key for this contact. The key is cached by uniqueId (= md5(odinId), + // stable across delete+recreate), but a recreated contact is a NEW file with a NEW key — a + // surviving entry would encrypt the recreated contact's image under the dead file's key. + // Safe to evict even on a failed delete: the cache simply repopulates on next image use. + aesKeyCacheMutex.withLock { aesKeyCache.remove(uniqueId) } + if (response.status == 404) return false throwForFailure(response) return true @@ -292,10 +298,10 @@ class ContactsProvider( } /** - * Drops the cached contact AES keys. MUST be called on session end / logout before the image - * path goes live: keys are cached by `uniqueId` (= md5(odinId)), which collides across - * identities, so a stale entry would encrypt a new identity's image under the previous - * identity's key. (Wired into the SessionEnded path when [setContactImage] gets a real caller.) + * Drops the cached contact AES keys. Called on session end / logout (from + * [ContactRepository.reset]): keys are cached by `uniqueId` (= md5(odinId)), which collides + * across identities, so a stale entry would encrypt a new identity's image under the previous + * identity's key. */ @OptIn(ExperimentalUuidApi::class) suspend fun clearKeyCache() { diff --git a/homebase-api/src/jvmTest/kotlin/id/homebase/api/client/contacts/ContactRepositoryTest.kt b/homebase-api/src/jvmTest/kotlin/id/homebase/api/client/contacts/ContactRepositoryTest.kt new file mode 100644 index 000000000..b40f0b24f --- /dev/null +++ b/homebase-api/src/jvmTest/kotlin/id/homebase/api/client/contacts/ContactRepositoryTest.kt @@ -0,0 +1,396 @@ +@file:OptIn(ExperimentalUuidApi::class, ExperimentalCoroutinesApi::class) + +package id.homebase.api.client.contacts + +import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver +import id.homebase.api.client.KeyHeader +import id.homebase.api.client.auth.ApiCredentials +import id.homebase.api.client.auth.CredentialsManager +import id.homebase.api.client.drives.FileState +import id.homebase.api.client.drives.FileSystemType +import id.homebase.api.client.drives.HomebaseFile +import id.homebase.api.client.drives.ServerMetadata +import id.homebase.api.client.drives.SystemDriveConstants +import id.homebase.api.client.drives.files.AppFileMetaData +import id.homebase.api.client.drives.files.FileMetadata +import id.homebase.api.client.drives.files.PayloadDescriptor +import id.homebase.api.client.eventbus.BackendEvent +import id.homebase.api.client.eventbus.EventBus +import id.homebase.api.common.OdinId +import id.homebase.api.common.SecureByteArray +import id.homebase.api.crypto.Md5 +import id.homebase.api.serialization.OdinSystemSerializer +import id.homebase.api.sync.database.DatabaseManager +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.http.ContentType +import io.ktor.http.HttpMethod +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.plus +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +/** + * Behavioural pins for [ContactRepository] — the source-of-truth layer that owns the live contact + * list, the optimistic write-through, and the "resurrection guard" that keeps a deleted contact + * from reappearing out of a stale drive batch. + * + * The repo is driven with real collaborators rather than mocks: a real in-memory [DatabaseManager] + * (so [ContactRepository.loadAll] runs an actual QueryBatch), a real [ContactsProvider] over a Ktor + * [MockEngine] (so writes exercise the true HTTP mapping), and a real [EventBus]. observeEvents() + * runs on a [TestScope.backgroundScope] child with an [UnconfinedTestDispatcher] (see [repo]) so it + * subscribes eagerly; [advanceUntilIdle] drains any pending work before each assertion. + */ +class ContactRepositoryTest { + + private val testDomain = OdinId("test.homebase.id") + private val contactDriveId = SystemDriveConstants.contactDrive.alias + + // ApiCredentials.getIdentityId() is currently a fixed value; seeded rows must use the same one + // or QueryBatch won't see them. + private val identityId = Uuid.parse("7b1be23b-48bb-4304-bc7b-db5910c09a92") + + private val tag = Uuid.parse("22222222-2222-2222-2222-222222222222") + + private val jsonHeaders = + headersOf("Content-Type" to listOf(ContentType.Application.Json.toString())) + + // ------------------------------------------------------------ + // Collaborator builders + // ------------------------------------------------------------ + + private suspend fun credentialsManager(): CredentialsManager = CredentialsManager().apply { + val creds = ApiCredentials.create( + domain = testDomain, + clientAccessToken = "test-token", + sharedSecret = SecureByteArray("0123456789abcdef".encodeToByteArray()), + ) + storeCredentials(creds) + setActiveCredentials(creds) + } + + private fun memoryDb(): DatabaseManager = + DatabaseManager({ JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY) }) + + /** + * One engine that covers every write the repo issues, routed by method/path: + * DELETE → 204, POST `/sync/` → 202, POST `/contacts` (create) and PUT (update) → 200 okBody. + * The okBody's uniqueId/versionTag are [okUniqueId]/[tag] so a `save()` resolves to a known id. + */ + private fun writeEngine(okUniqueId: Uuid): MockEngine = MockEngine { req -> + val path = req.url.encodedPath + when { + req.method == HttpMethod.Delete -> + respond("", HttpStatusCode.NoContent, jsonHeaders) + path.contains("/sync/") -> + respond("", HttpStatusCode.Accepted, jsonHeaders) + else -> + respond( + ContactFixtures.okBody(okUniqueId.toString(), tag.toString()), + HttpStatusCode.OK, + jsonHeaders, + ) + } + } + + /** + * Builds the repo on a scope that is a child of [TestScope.backgroundScope] (so its collector is + * torn down with the test) but uses an [UnconfinedTestDispatcher] so `observeEvents()` subscribes + * eagerly at construction. Without the eager dispatcher the collector wouldn't subscribe until a + * later `advanceUntilIdle`, and a meanwhile-emitted first event would be swallowed by the + * `drop(replayCache.size)` replay-skip in observeEvents. + */ + private fun TestScope.repo( + cm: CredentialsManager, + dbm: DatabaseManager, + eventBus: EventBus, + engine: MockEngine, + ): ContactRepository { + // No image tests here, so the header reader is never invoked. + val provider = ContactsProvider(HttpClient(engine), cm, { _, _ -> null }) + val scope = backgroundScope + UnconfinedTestDispatcher(testScheduler) + return ContactRepository(provider, dbm, cm, eventBus, scope) + } + + // ------------------------------------------------------------ + // Contact-file builders + // ------------------------------------------------------------ + + /** A Contacts-drive [HomebaseFile] that [HomebaseFile.toContact] accepts, mirroring real rows. */ + private fun contactFile( + uniqueId: Uuid, + content: ContactContent, + withImagePayload: Boolean = false, + ): HomebaseFile = HomebaseFile( + fileId = Uuid.parse("99999999-9999-9999-9999-999999999999"), + driveId = contactDriveId, + fileState = FileState.Active, + fileSystemType = FileSystemType.Standard, + keyHeader = KeyHeader(iv = ByteArray(16), aesKey = SecureByteArray(ByteArray(16))), + fileMetadata = FileMetadata( + isEncrypted = true, + versionTag = tag, + appData = AppFileMetaData( + uniqueId = uniqueId, + fileType = ContactsProvider.CONTACT_FILE_TYPE, + content = OdinSystemSerializer.serialize(content), + ), + payloads = if (withImagePayload) { + listOf( + PayloadDescriptor( + key = ContactsProvider.CONTACT_IMAGE_PAYLOAD_KEY, + contentType = "image/jpeg", + bytesWritten = 1024L, + ), + ) + } else { + null + }, + ), + serverMetadata = ServerMetadata(), + ) + + /** Seed a contact row into the Contacts drive so [ContactRepository.loadAll] returns it. */ + private suspend fun seedContact( + dbm: DatabaseManager, + uniqueId: Uuid, + content: ContactContent, + created: Long, + ) { + val fileId = Uuid.fromLongs(0L, created) // distinct per row + val file = contactFile(uniqueId, content).copy(fileId = fileId) + dbm.driveMainIndex.upsertDriveMainIndex( + identityId = identityId, + driveId = contactDriveId, + fileId = fileId, + uniqueId = uniqueId, + globalTransitId = null, + groupId = null, + senderId = null, + originalAuthor = null, + fileType = ContactsProvider.CONTACT_FILE_TYPE.toLong(), + dataType = 0L, + archivalStatus = 0L, + fileState = 1L, + historyStatus = 0L, + userDate = created, + created = created, + modified = created, + fileSystemType = 0L, + jsonHeader = OdinSystemSerializer.serialize(file), + ) + } + + private fun batch(vararg files: HomebaseFile) = + BackendEvent.DataEvent.BatchReceived(driveId = contactDriveId, batchData = files.toList()) + + private fun named(displayName: String) = + ContactContent(name = ContactName(displayName = displayName)) + + // ------------------------------------------------------------ + // Event-driven read path + // ------------------------------------------------------------ + + @Test + fun batchReceived_addsThenDedupesByUniqueId() = runTest { + val cm = credentialsManager() + val eventBus = EventBus() + val repo = repo(cm, memoryDb(), eventBus, writeEngine(Uuid.random())) + advanceUntilIdle() // let observeEvents() subscribe before we emit + + val uid = Uuid.parse("11111111-1111-1111-1111-111111111111") + eventBus.emit(batch(contactFile(uid, named("Sam")))) + advanceUntilIdle() + assertEquals(listOf("Sam"), repo.contacts.value.map { it.content.name?.displayName }) + + // Same uniqueId again -> replaces in place, not appended. + eventBus.emit(batch(contactFile(uid, named("Samuel")))) + advanceUntilIdle() + assertEquals(1, repo.contacts.value.size) + assertEquals("Samuel", repo.contacts.value.single().content.name?.displayName) + } + + @Test + fun reset_clearsContactsAndLoadedState() = runTest { + val cm = credentialsManager() + val eventBus = EventBus() + val repo = repo(cm, memoryDb(), eventBus, writeEngine(Uuid.random())) + advanceUntilIdle() + + val uid = Uuid.parse("11111111-1111-1111-1111-111111111111") + eventBus.emit(batch(contactFile(uid, named("Sam")))) + advanceUntilIdle() + assertTrue(repo.contacts.value.isNotEmpty()) + + repo.reset() + assertTrue(repo.contacts.value.isEmpty()) + assertFalse(repo.isLoaded.value) + } + + @Test + fun delete_suppressesLaterBatchForSameId() = runTest { + // Resurrection guard: a stale batch must not re-add a contact we just deleted. + val cm = credentialsManager() + val eventBus = EventBus() + val uid = Uuid.parse("11111111-1111-1111-1111-111111111111") + val repo = repo(cm, memoryDb(), eventBus, writeEngine(uid)) + advanceUntilIdle() + + assertTrue(repo.delete(uid)) + + eventBus.emit(batch(contactFile(uid, named("Ghost")))) + advanceUntilIdle() + assertTrue(repo.contacts.value.isEmpty(), "deleted id must stay suppressed") + } + + // ------------------------------------------------------------ + // Write-through (optimistic) + // ------------------------------------------------------------ + + @Test + fun save_upsertsOptimisticallyAndLiftsDeleteGuard() = runTest { + val cm = credentialsManager() + val eventBus = EventBus() + val uid = Uuid.parse("11111111-1111-1111-1111-111111111111") + val repo = repo(cm, memoryDb(), eventBus, writeEngine(uid)) + advanceUntilIdle() + + // Delete first so the id sits in the resurrection guard... + assertTrue(repo.delete(uid)) + // ...then save it back. save() resolves to `uid` via the okBody. + val response = repo.save(named("Sam"), knownUniqueId = uid, knownVersionTag = tag) + assertNotNull(response) + assertEquals(uid, repo.contacts.value.single().uniqueId) + + // Guard lifted: a subsequent drive batch for the same id now reconciles instead of being + // dropped. + eventBus.emit(batch(contactFile(uid, named("Sam (synced)")))) + advanceUntilIdle() + assertEquals("Sam (synced)", repo.contacts.value.single().content.name?.displayName) + } + + @Test + fun save_preservesExistingImageOnContentEdit() = runTest { + val cm = credentialsManager() + val eventBus = EventBus() + val uid = Uuid.parse("11111111-1111-1111-1111-111111111111") + val repo = repo(cm, memoryDb(), eventBus, writeEngine(uid)) + advanceUntilIdle() + + // A contact that already carries an avatar payload arrives via sync. + eventBus.emit(batch(contactFile(uid, named("Sam"), withImagePayload = true))) + advanceUntilIdle() + assertNotNull(repo.contacts.value.single().image) + + // Editing the content (which has no image) must not drop the known avatar reference. + repo.save(named("Samuel"), knownUniqueId = uid, knownVersionTag = tag) + val after = repo.contacts.value.single() + assertEquals("Samuel", after.content.name?.displayName) + assertNotNull(after.image, "content edit must preserve the existing image ref") + } + + @Test + fun sync_liftsDeleteGuardSoReSyncReappears() = runTest { + // #4: sync(odinId) clears the md5(odinId) id from the guard so reconnecting a previously + // deleted identity isn't suppressed. + val cm = credentialsManager() + val eventBus = EventBus() + val odinId = OdinId("sam.dotyou.cloud") + val uid = Md5.toGuidId(odinId.domainName) // the id the server derives for this identity + val repo = repo(cm, memoryDb(), eventBus, writeEngine(uid)) + advanceUntilIdle() + + assertTrue(repo.delete(uid)) + eventBus.emit(batch(contactFile(uid, named("Sam")))) + advanceUntilIdle() + assertTrue(repo.contacts.value.isEmpty(), "still suppressed before sync") + + repo.sync(odinId) + eventBus.emit(batch(contactFile(uid, named("Sam")))) + advanceUntilIdle() + assertEquals(1, repo.contacts.value.size, "sync must lift the guard for md5(odinId)") + } + + // ------------------------------------------------------------ + // loadAll (real DB) + // ------------------------------------------------------------ + + @Test + fun loadAll_loadsSeededContacts() = runTest { + val cm = credentialsManager() + val dbm = memoryDb() + val a = Uuid.parse("aaaaaaaa-0000-0000-0000-000000000001") + val b = Uuid.parse("bbbbbbbb-0000-0000-0000-000000000002") + seedContact(dbm, a, named("Alice"), created = 100L) + seedContact(dbm, b, named("Bob"), created = 200L) + + val repo = repo(cm, dbm, EventBus(), writeEngine(Uuid.random())) + repo.loadAll() + + assertTrue(repo.isLoaded.value) + assertContentEquals( + listOf(a, b).sortedBy { it.toString() }, + repo.contacts.value.map { it.uniqueId }.sortedBy { it.toString() }, + ) + } + + @Test + fun loadAll_forgetsConfirmedDeletedButKeepsStillPresent() = runTest { + // #5: the query is authoritative. A deleted id the server no longer returns is forgotten + // (its guard is dropped); a deleted id the server STILL returns keeps its guard. + val cm = credentialsManager() + val dbm = memoryDb() + val gone = Uuid.parse("aaaaaaaa-0000-0000-0000-000000000001") // never seeded → "delete synced" + val present = Uuid.parse("bbbbbbbb-0000-0000-0000-000000000002") // seeded → delete not yet honored + seedContact(dbm, present, named("Bob"), created = 200L) + + val eventBus = EventBus() + val repo = repo(cm, dbm, eventBus, writeEngine(gone)) + advanceUntilIdle() + + // Both ids enter the resurrection guard. + assertTrue(repo.delete(gone)) + assertTrue(repo.delete(present)) + + repo.loadAll() // authoritative: returns only `present` + assertTrue(repo.contacts.value.isEmpty(), "present is still guarded, so list is empty") + + // `gone` was pruned from the guard -> a batch now reconciles it... + eventBus.emit(batch(contactFile(gone, named("Back")))) + advanceUntilIdle() + assertEquals(listOf(gone), repo.contacts.value.map { it.uniqueId }) + + // ...but `present` is still guarded -> a batch for it stays suppressed. + eventBus.emit(batch(contactFile(present, named("Still gone")))) + advanceUntilIdle() + assertEquals(listOf(gone), repo.contacts.value.map { it.uniqueId }) + } + + @Test + fun loadAll_failureLeavesIsLoadedFalseSoEnsureLoadedRetries() = runTest { + // #6: a transient query failure must not latch isLoaded=true with an empty list. + val cm = credentialsManager() + val dbm = memoryDb() + val repo = repo(cm, dbm, EventBus(), writeEngine(Uuid.random())) + advanceUntilIdle() + + dbm.close() // break the driver so the QueryBatch throws + repo.loadAll() + + assertFalse(repo.isLoaded.value, "failed load must leave isLoaded false for a retry") + assertTrue(repo.contacts.value.isEmpty()) + } +} From 3b92fddc37fab9c8450c79efcdd01ed8034f019b Mon Sep 17 00:00:00 2001 From: toddmitchell Date: Mon, 22 Jun 2026 13:39:06 -0500 Subject: [PATCH 10/23] Contacts: full content-blob fields + on-demand ext_data bios Extend ContactContent with the remaining header fields (shortBio, nickname, status, link, social, isEmergencyContact). social and isEmergencyContact are nullable so they omit-on-null and don't clobber stored values through the server's field-level UPDATE merge. Add the ext_data payload read path: ContactExtData (raw JsonElement map, forward-compatible) with lazily-decoded Experience and Bio types, disambiguating the short_bio string-vs-richtext collision by attribute type id. Contact now carries fileId/keyHeader/hasExtData so the bios are fetched on demand without re-reading the file header; the fetch goes through a narrow ContactPayloadReader seam (mirrors ContactHeaderReader) backed by DriveFileProvider.getPayloadBytesDecrypted. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../homebase/api/client/contacts/Contact.kt | 15 ++++ .../api/client/contacts/ContactContent.kt | 14 ++++ .../api/client/contacts/ContactExtData.kt | 76 +++++++++++++++++++ .../client/contacts/ContactPayloadReader.kt | 25 ++++++ .../api/client/contacts/ContactRepository.kt | 38 +++++++++- .../api/client/contacts/ContactsProvider.kt | 3 + .../kotlin/id/homebase/api/di/ApiModule.kt | 17 +++++ .../api/client/contacts/ContactExtDataTest.kt | 73 ++++++++++++++++++ .../client/contacts/ContactRepositoryTest.kt | 4 +- 9 files changed, 263 insertions(+), 2 deletions(-) create mode 100644 homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactExtData.kt create mode 100644 homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactPayloadReader.kt create mode 100644 homebase-api/src/jvmTest/kotlin/id/homebase/api/client/contacts/ContactExtDataTest.kt diff --git a/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/Contact.kt b/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/Contact.kt index a4e96516b..d84c2ec58 100644 --- a/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/Contact.kt +++ b/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/Contact.kt @@ -27,6 +27,15 @@ data class Contact( val versionTag: Uuid?, val content: ContactContent, val image: ContactImageRef? = null, + /** + * File identity + key needed to fetch the on-demand `ext_data` payload (see + * [ContactRepository.loadExtData]). Null on an optimistic, not-yet-synced row written locally; + * the authoritative row that lands via drive sync carries them. + */ + val fileId: Uuid? = null, + val keyHeader: KeyHeader? = null, + /** Whether this contact has an `ext_data` payload at all — false skips a pointless fetch. */ + val hasExtData: Boolean = false, ) /** Everything needed to render a contact's stored avatar (`prfl_pic`) without a second drive read. */ @@ -66,10 +75,16 @@ fun HomebaseFile.toContact(): Contact? { ) } + val hasExtData = fileMetadata.payloads + ?.any { it.key == ContactsProvider.CONTACT_EXT_DATA_PAYLOAD_KEY } == true + return Contact( uniqueId = uniqueId, versionTag = fileMetadata.versionTag, content = content, image = image, + fileId = fileId, + keyHeader = keyHeader, + hasExtData = hasExtData, ) } diff --git a/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactContent.kt b/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactContent.kt index 1d44c2c9f..460402b25 100644 --- a/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactContent.kt +++ b/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactContent.kt @@ -28,6 +28,20 @@ data class ContactContent( val phone: ContactPhone? = null, val email: ContactEmail? = null, val birthday: ContactBirthday? = null, + /** Short header tagline (~<=160 chars). Distinct from the ext_data bios (see [ContactExtData]). */ + val shortBio: String? = null, + val nickname: String? = null, + /** Free-text status/tagline — NOT connection state (derive that live from connection/circle APIs). */ + val status: String? = null, + /** Bare URL value; render the link yourself. */ + val link: String? = null, + // social/isEmergencyContact stay nullable (not `emptyMap()`/`false`) for the same reason as the + // fields above: the serializer encodes defaults, so a non-null default would emit on every write + // and the server's merge would treat it as "set this", clobbering a stored value. Null omits. + /** Social handles keyed by attribute-type-id GUID (hyphenated). Values are bare handles, not URLs. */ + val social: Map? = null, + /** Owner-only flag. */ + val isEmergencyContact: Boolean? = null, ) @Serializable diff --git a/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactExtData.kt b/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactExtData.kt new file mode 100644 index 000000000..2dc2fcb35 --- /dev/null +++ b/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactExtData.kt @@ -0,0 +1,76 @@ +package id.homebase.api.client.contacts + +import id.homebase.api.client.drives.files.RichText +import id.homebase.api.client.drives.files.getPlainTextFromRichText +import id.homebase.api.serialization.OdinSystemSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.decodeFromJsonElement + +/** + * The **entire** `ext_data` payload of a contact file — large rich-text fields fetched on demand + * (see [ContactRepository.loadExtData]), separate from the small header fields in [ContactContent]. + * + * The root is always `{ "attributes": { … } }` — one wrapper key, nothing else. [attributes] maps an + * attribute-type id (32-char lowercase hex, NO dashes) to that type's data object, stored **verbatim** + * from the peer (the server never parses or reshapes it). The key set is open and inner fields can + * change, so values are kept as raw [JsonElement] and known types are decoded lazily and defensively — + * unknown ids and unknown inner fields are tolerated and ignored (forward-compatible). + * + * "Payload absent" (a contact with no extended data has no `ext_data` payload at all) is represented + * by [ContactRepository.loadExtData] returning null — treat it as empty, not an error. + */ +@Serializable +data class ContactExtData( + val attributes: Map = emptyMap(), +) { + /** Experience attribute, or null if this contact has none. */ + val experience: ContactExperience? get() = decode(EXPERIENCE_TYPE_ID) + + /** Bio attribute, or null if this contact has none. */ + val bio: ContactBio? get() = decode(BIO_TYPE_ID) + + private inline fun decode(typeId: String): T? { + val element = attributes[typeId] ?: return null + return runCatching { OdinSystemSerializer.json.decodeFromJsonElement(element) }.getOrNull() + } + + companion object { + const val EXPERIENCE_TYPE_ID = "65635623682c2fadd2767d424f53690f" + const val BIO_TYPE_ID = "2cd30a58568dc333237944481aeb9ff1" + } +} + +/** + * Experience attribute (`65635623682c2fadd2767d424f53690f`). + * + * ⚠️ Its [title] (`short_bio`) is a **plain string**; the [ContactBio.shortBio] of the same field name + * on the Bio type is a rich-text array instead. Disambiguate by the attribute type id (the map key), + * never by the field name. The top-level [ContactContent.shortBio] is a third, separate thing. + */ +@Serializable +data class ContactExperience( + /** Plain-string title. */ + @SerialName("short_bio") val title: String? = null, + /** Rich-text node array. */ + @SerialName("full_bio") val fullBio: RichText? = null, + @SerialName("experience_link") val link: String? = null, + /** Reference to an image payload key — the image bytes are not in this JSON. */ + @SerialName("experience_image") val imageKey: String? = null, +) { + /** [fullBio] flattened to plain text for simple rendering, or null if empty. */ + val fullBioText: String? get() = getPlainTextFromRichText(fullBio, keepNewLines = true) +} + +/** + * Bio attribute (`2cd30a58568dc333237944481aeb9ff1`). Its [shortBio] is a **rich-text array** + * (unlike Experience's plain-string `short_bio`). + */ +@Serializable +data class ContactBio( + @SerialName("short_bio") val shortBio: RichText? = null, +) { + /** [shortBio] flattened to plain text for simple rendering, or null if empty. */ + val shortBioText: String? get() = getPlainTextFromRichText(shortBio, keepNewLines = true) +} diff --git a/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactPayloadReader.kt b/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactPayloadReader.kt new file mode 100644 index 000000000..2993f4cde --- /dev/null +++ b/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactPayloadReader.kt @@ -0,0 +1,25 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package id.homebase.api.client.contacts + +import id.homebase.api.client.KeyHeader +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +/** + * Narrow capability [ContactRepository] needs to read a contact's on-demand `ext_data` payload: + * fetch and decrypt a named payload from a contact file under the file's [KeyHeader]. Backed by + * `DriveFileProvider.getPayloadBytesDecrypted` in DI — depending on this instead of the concrete + * provider keeps the repository off the heavier drive-file/caching/platform graph (mirrors + * [ContactHeaderReader]). + * + * Returns the decrypted UTF-8 JSON bytes, or null when the payload is absent (404). + */ +fun interface ContactPayloadReader { + suspend fun getPayloadBytes( + driveId: Uuid, + fileId: Uuid, + key: String, + keyHeader: KeyHeader, + ): ByteArray? +} diff --git a/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactRepository.kt b/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactRepository.kt index 3e62a0bd6..fba5172f0 100644 --- a/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactRepository.kt +++ b/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactRepository.kt @@ -5,7 +5,6 @@ package id.homebase.api.client.contacts import co.touchlab.kermit.Logger import id.homebase.api.client.ForbiddenException import id.homebase.api.client.auth.CredentialsManager -import id.homebase.api.client.drives.HomebaseFile import id.homebase.api.client.drives.QueryBatchSortField import id.homebase.api.client.drives.QueryBatchSortOrder import id.homebase.api.client.drives.SystemDriveConstants @@ -13,6 +12,7 @@ import id.homebase.api.client.eventbus.BackendEvent import id.homebase.api.client.eventbus.EventBus import id.homebase.api.common.OdinId import id.homebase.api.crypto.Md5 +import id.homebase.api.serialization.OdinSystemSerializer import id.homebase.api.sync.database.DatabaseManager import id.homebase.api.sync.database.QueryBatch import kotlinx.coroutines.CoroutineScope @@ -42,6 +42,7 @@ private const val TAG = "ContactRepository" */ class ContactRepository( private val contactsProvider: ContactsProvider, + private val contactPayloadReader: ContactPayloadReader, private val databaseManager: DatabaseManager, private val credentialsManager: CredentialsManager, private val eventBus: EventBus, @@ -186,6 +187,41 @@ class ContactRepository( } } + /** + * Fetches and parses a contact's on-demand `ext_data` payload (bios / rich text) — call only + * when actually showing the bios; it is not part of the list/detail read. + * + * Returns null when there is nothing to show: the contact has no `ext_data` payload + * ([Contact.hasExtData] is false, or the fetch 404s), the row is optimistic (no [Contact.fileId] + * yet), or the fetch/parse fails. Callers treat null as "empty extended data". + */ + suspend fun loadExtData(contact: Contact): ContactExtData? { + if (!contact.hasExtData) return null + val fileId = contact.fileId ?: return null + val keyHeader = contact.keyHeader ?: return null + + val bytes = try { + contactPayloadReader.getPayloadBytes( + driveId = driveId, + fileId = fileId, + key = ContactsProvider.CONTACT_EXT_DATA_PAYLOAD_KEY, + keyHeader = keyHeader, + ) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Logger.w(e, TAG) { "loadExtData fetch failed for ${contact.uniqueId}" } + return null + } ?: return null + + return runCatching { + OdinSystemSerializer.deserialize(bytes.decodeToString()) + }.getOrElse { + Logger.w(it, TAG) { "loadExtData parse failed for ${contact.uniqueId}" } + null + } + } + // ------------------------------------------------------------ // Write (V2 controller) — each applies an optimistic update // ------------------------------------------------------------ diff --git a/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactsProvider.kt b/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactsProvider.kt index 9ee423e85..84264bd1d 100644 --- a/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactsProvider.kt +++ b/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactsProvider.kt @@ -54,6 +54,9 @@ class ContactsProvider( /** Server payload key the contact image (and its thumbnails) are stored under. */ const val CONTACT_IMAGE_PAYLOAD_KEY: String = "prfl_pic" + + /** On-demand payload key the contact's large rich-text fields ([ContactExtData]) ride on. */ + const val CONTACT_EXT_DATA_PAYLOAD_KEY: String = "ext_data" } // Caches the contact file's AES key by uniqueId. The key is stable across content/image updates, diff --git a/homebase-api/src/commonMain/kotlin/id/homebase/api/di/ApiModule.kt b/homebase-api/src/commonMain/kotlin/id/homebase/api/di/ApiModule.kt index bef56204e..bee9929af 100644 --- a/homebase-api/src/commonMain/kotlin/id/homebase/api/di/ApiModule.kt +++ b/homebase-api/src/commonMain/kotlin/id/homebase/api/di/ApiModule.kt @@ -9,6 +9,7 @@ import id.homebase.api.client.connections.ConnectionNetworkProvider import id.homebase.api.client.connections.ConnectionRequestProvider import id.homebase.api.client.connections.IntroductionSender import id.homebase.api.client.contacts.ContactHeaderReader +import id.homebase.api.client.contacts.ContactPayloadReader import id.homebase.api.client.contacts.ContactRepository import id.homebase.api.client.contacts.ContactsProvider import id.homebase.api.client.liverelay.LiveRelayProvider @@ -121,6 +122,22 @@ val apiModule = module { driveFileProvider.getFileHeaderByUid(driveId, uniqueId) } } + // Reads a contact's on-demand ext_data payload (bios), decrypting under the file's key. Same + // narrow-seam pattern as ContactHeaderReader so ContactRepository stays off the drive-file graph. + single { + val driveFileProvider = get() + ContactPayloadReader { driveId, fileId, key, keyHeader -> + driveFileProvider.getPayloadBytesDecrypted( + driveId = driveId, + fileId = fileId, + key = key, + keyHeader = keyHeader, + chunkStart = null, + chunkLength = null, + onDownloadProgress = null, + )?.bytes + } + } singleOf(::ContactsProvider) singleOf(::ContactRepository) factoryOf(::IdentityUpgradeProvider) diff --git a/homebase-api/src/jvmTest/kotlin/id/homebase/api/client/contacts/ContactExtDataTest.kt b/homebase-api/src/jvmTest/kotlin/id/homebase/api/client/contacts/ContactExtDataTest.kt new file mode 100644 index 000000000..3c4f57186 --- /dev/null +++ b/homebase-api/src/jvmTest/kotlin/id/homebase/api/client/contacts/ContactExtDataTest.kt @@ -0,0 +1,73 @@ +package id.homebase.api.client.contacts + +import id.homebase.api.serialization.OdinSystemSerializer +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ContactExtDataTest { + + private fun parse(json: String) = OdinSystemSerializer.deserialize(json) + + @Test + fun parsesExperienceWithSnakeCaseFieldsAndRichTextFullBio() { + val ext = parse( + """ + {"attributes":{"65635623682c2fadd2767d424f53690f":{ + "short_bio":"experience-title", + "full_bio":[{"type":"p","id":"NF4tmpq6sK","children":[{"text":"experience description"}]}], + "experience_link":"https://experince.link", + "experience_image":"xprnc_key" + }}} + """.trimIndent(), + ) + + val exp = ext.experience + assertEquals("experience-title", exp?.title) + assertEquals("https://experince.link", exp?.link) + assertEquals("xprnc_key", exp?.imageKey) + assertEquals("experience description", exp?.fullBioText) + } + + @Test + fun bioShortBioIsRichText_notAPlainString_disambiguatedByTypeId() { + // Experience's short_bio is a plain string; Bio's short_bio is a rich-text array. The only + // thing that tells them apart is the attribute type id (the map key), never the field name. + val ext = parse( + """ + {"attributes":{"2cd30a58568dc333237944481aeb9ff1":{ + "short_bio":[{"type":"paragraph","id":"NXHtACYXHc","children":[{"text":"born born"}]}] + }}} + """.trimIndent(), + ) + + assertEquals("born born", ext.bio?.shortBioText) + assertNull(ext.experience, "Bio attribute must not surface as an Experience") + } + + @Test + fun toleratesUnknownTypeIdsAndUnknownInnerFields() { + // Forward-compatible: an unknown attribute id and an unknown inner field on a known type must + // not blow up parsing — they are simply ignored. + val ext = parse( + """ + {"attributes":{ + "ffffffffffffffffffffffffffffffff":{"whatever":123}, + "65635623682c2fadd2767d424f53690f":{"short_bio":"t","future_field":{"nested":true}} + }} + """.trimIndent(), + ) + + assertEquals("t", ext.experience?.title) + assertTrue(ext.attributes.containsKey("ffffffffffffffffffffffffffffffff")) + } + + @Test + fun missingAttributesParsesAsEmpty() { + val ext = parse("""{"attributes":{}}""") + assertNull(ext.experience) + assertNull(ext.bio) + assertTrue(ext.attributes.isEmpty()) + } +} diff --git a/homebase-api/src/jvmTest/kotlin/id/homebase/api/client/contacts/ContactRepositoryTest.kt b/homebase-api/src/jvmTest/kotlin/id/homebase/api/client/contacts/ContactRepositoryTest.kt index b40f0b24f..6e1a1aa8b 100644 --- a/homebase-api/src/jvmTest/kotlin/id/homebase/api/client/contacts/ContactRepositoryTest.kt +++ b/homebase-api/src/jvmTest/kotlin/id/homebase/api/client/contacts/ContactRepositoryTest.kt @@ -121,8 +121,10 @@ class ContactRepositoryTest { ): ContactRepository { // No image tests here, so the header reader is never invoked. val provider = ContactsProvider(HttpClient(engine), cm, { _, _ -> null }) + // No ext_data tests here, so the payload reader is never invoked. + val payloadReader = ContactPayloadReader { _, _, _, _ -> null } val scope = backgroundScope + UnconfinedTestDispatcher(testScheduler) - return ContactRepository(provider, dbm, cm, eventBus, scope) + return ContactRepository(provider, payloadReader, dbm, cm, eventBus, scope) } // ------------------------------------------------------------ From 3826b5d0ae64a54d097d6267c3c3f2a1326290d6 Mon Sep 17 00:00:00 2001 From: toddmitchell Date: Mon, 22 Jun 2026 15:34:15 -0500 Subject: [PATCH 11/23] Contacts: emergencyContacts flow + non-null isEmergencyContact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ContactRepository.emergencyContacts, a StateFlow subset of contacts flagged as emergency contacts, derived from the live list so it tracks the same optimistic writes and sync reconciliation. Make isEmergencyContact a plain non-null Boolean (you either are one or you're not) with @EncodeDefault(NEVER) so the false default is omitted — keeping the field-level UPDATE merge non-destructive — while true is written. Clearing the flag is not expressible through the merge and needs a dedicated write. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../api/client/contacts/ContactContent.kt | 21 ++++++++++++++----- .../api/client/contacts/ContactRepository.kt | 11 ++++++++++ .../ContactContentSerializationTest.kt | 16 ++++++++++++++ 3 files changed, 43 insertions(+), 5 deletions(-) diff --git a/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactContent.kt b/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactContent.kt index 460402b25..4fb99c45a 100644 --- a/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactContent.kt +++ b/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactContent.kt @@ -1,5 +1,9 @@ +@file:OptIn(ExperimentalSerializationApi::class) + package id.homebase.api.client.contacts +import kotlinx.serialization.EncodeDefault +import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable /** @@ -35,13 +39,20 @@ data class ContactContent( val status: String? = null, /** Bare URL value; render the link yourself. */ val link: String? = null, - // social/isEmergencyContact stay nullable (not `emptyMap()`/`false`) for the same reason as the - // fields above: the serializer encodes defaults, so a non-null default would emit on every write - // and the server's merge would treat it as "set this", clobbering a stored value. Null omits. + // social stays nullable (not `emptyMap()`): the serializer encodes defaults, so a non-null empty + // default would emit on every write and the server's merge would treat it as "set this", + // clobbering stored handles. Null omits. /** Social handles keyed by attribute-type-id GUID (hyphenated). Values are bare handles, not URLs. */ val social: Map? = null, - /** Owner-only flag. */ - val isEmergencyContact: Boolean? = null, + /** + * Owner-only flag — a contact either is an emergency contact or isn't, so this is a plain + * non-null [Boolean]. [EncodeDefault.Mode.NEVER] keeps the merge contract intact: the `false` + * default is omitted (so a write doesn't clobber the stored value), while `true` is emitted. + * Note this can set the flag but not clear it through the merge — a `false` reads as "leave + * alone"; clearing needs a dedicated write. + */ + @EncodeDefault(EncodeDefault.Mode.NEVER) + val isEmergencyContact: Boolean = false, ) @Serializable diff --git a/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactRepository.kt b/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactRepository.kt index fba5172f0..d274b1986 100644 --- a/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactRepository.kt +++ b/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactRepository.kt @@ -18,8 +18,11 @@ import id.homebase.api.sync.database.QueryBatch import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex @@ -54,6 +57,14 @@ class ContactRepository( /** Live contacts, freshest-row-per-uniqueId, in drive order (NewestFirst). Consumers sort. */ val contacts: StateFlow> = _contacts.asStateFlow() + /** + * Live subset of [contacts] flagged as emergency contacts. Derived from [contacts], so it tracks + * the same optimistic writes and sync reconciliation; consumers sort. + */ + val emergencyContacts: StateFlow> = _contacts + .map { list -> list.filter { it.content.isEmergencyContact } } + .stateIn(scope, SharingStarted.Eagerly, emptyList()) + private val _isLoaded = MutableStateFlow(false) val isLoaded: StateFlow = _isLoaded.asStateFlow() diff --git a/homebase-api/src/jvmTest/kotlin/id/homebase/api/client/contacts/ContactContentSerializationTest.kt b/homebase-api/src/jvmTest/kotlin/id/homebase/api/client/contacts/ContactContentSerializationTest.kt index 016ac3079..6b2445131 100644 --- a/homebase-api/src/jvmTest/kotlin/id/homebase/api/client/contacts/ContactContentSerializationTest.kt +++ b/homebase-api/src/jvmTest/kotlin/id/homebase/api/client/contacts/ContactContentSerializationTest.kt @@ -63,6 +63,22 @@ class ContactContentSerializationTest { assertFalse(OdinSystemSerializer.serialize(ContactContent(odinId = "x")).contains("source")) } + @Test + fun isEmergencyContact_omittedWhenFalse_emittedWhenTrue() { + // @EncodeDefault(NEVER): the false default is omitted so an UPDATE doesn't clobber a stored + // flag, while an explicit true is written. Reading an absent flag decodes back to false. + assertFalse( + OdinSystemSerializer.serialize(ContactContent(odinId = "x")).contains("isEmergencyContact"), + ) + assertEquals( + """{"isEmergencyContact":true}""", + OdinSystemSerializer.serialize(ContactContent(isEmergencyContact = true)), + ) + assertFalse( + OdinSystemSerializer.deserialize("""{"odinId":"x"}""").isEmergencyContact, + ) + } + @Test fun createRequestWrapsContentUnderContentKey() { val json = OdinSystemSerializer.serialize( From ed2015b84dbf516111cc4fcdeb9867aca5241b58 Mon Sep 17 00:00:00 2001 From: toddmitchell Date: Mon, 22 Jun 2026 16:40:42 -0500 Subject: [PATCH 12/23] Contacts: detail status/bio/social sections + per-app data tiers Contact detail UI: - Show the free-text status under the contact's odinId in the header. - New "Bio" (shortBio) and "Social" sections under contact details. - ContactAttributes.kt: well-known attribute-type GUIDs + ContactSocialNetwork enum; socialHandles() resolves ContactContent.social, normalizing the dashless 32-hex stored key form against the hyphenated constants. ContactSocialTest pins it. Per-app contact data: - setAppData/deleteAppData (inline tier) and setAppExtData/deleteAppExtData + loadAppExtData (bulk ext_data tier) on ContactRepository, over the new ContactsProvider app-data/app-ext-data endpoints (SetContactAppDataRequest). - ContactContent.appData inline map; Contact.payloadKeys replaces hasExtData so a reader can skip a guaranteed-404 fetch for any absent payload. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../homebase/api/client/contacts/Contact.kt | 13 +- .../api/client/contacts/ContactAppData.kt | 67 ++++++++ .../api/client/contacts/ContactAttributes.kt | 97 +++++++++++ .../api/client/contacts/ContactContent.kt | 14 +- .../api/client/contacts/ContactRepository.kt | 161 +++++++++++++++++- .../api/client/contacts/ContactRequests.kt | 15 ++ .../api/client/contacts/ContactsProvider.kt | 91 ++++++++++ .../api/client/contacts/ContactSocialTest.kt | 61 +++++++ .../composeResources/values/strings.xml | 2 + .../contactbook/detail/ContactDetailScreen.kt | 14 ++ .../detail/ContactDetailSections.kt | 44 +++++ .../contactbook/model/ContactBookEntry.kt | 11 ++ 12 files changed, 580 insertions(+), 10 deletions(-) create mode 100644 homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactAppData.kt create mode 100644 homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactAttributes.kt create mode 100644 homebase-api/src/jvmTest/kotlin/id/homebase/api/client/contacts/ContactSocialTest.kt diff --git a/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/Contact.kt b/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/Contact.kt index d84c2ec58..9e4187a9c 100644 --- a/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/Contact.kt +++ b/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/Contact.kt @@ -34,8 +34,12 @@ data class Contact( */ val fileId: Uuid? = null, val keyHeader: KeyHeader? = null, - /** Whether this contact has an `ext_data` payload at all — false skips a pointless fetch. */ - val hasExtData: Boolean = false, + /** + * Keys of the on-demand payloads attached to this contact file (e.g. `ext_data`, `appextdata`). + * Lets a reader skip a guaranteed-404 fetch for a payload that isn't there. Empty on an + * optimistic, not-yet-synced row. + */ + val payloadKeys: Set = emptySet(), ) /** Everything needed to render a contact's stored avatar (`prfl_pic`) without a second drive read. */ @@ -75,8 +79,7 @@ fun HomebaseFile.toContact(): Contact? { ) } - val hasExtData = fileMetadata.payloads - ?.any { it.key == ContactsProvider.CONTACT_EXT_DATA_PAYLOAD_KEY } == true + val payloadKeys = fileMetadata.payloads?.mapTo(HashSet()) { it.key } ?: emptySet() return Contact( uniqueId = uniqueId, @@ -85,6 +88,6 @@ fun HomebaseFile.toContact(): Contact? { image = image, fileId = fileId, keyHeader = keyHeader, - hasExtData = hasExtData, + payloadKeys = payloadKeys, ) } diff --git a/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactAppData.kt b/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactAppData.kt new file mode 100644 index 000000000..2a6d5a8bc --- /dev/null +++ b/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactAppData.kt @@ -0,0 +1,67 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package id.homebase.api.client.contacts + +import kotlinx.serialization.Serializable +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +/** + * Per-app contact "app-data" — an app's own private slot on a contact record, in two tiers stored + * under the same contact file: + * + * - **Inline tier** (≤ 200 bytes): rides in the contact content as [ContactContent.appData], so the + * contacts list query already returns it. Read with [ContactContent.appDataFor]; written via + * [ContactsProvider.setContactAppData] / [ContactsProvider.deleteContactAppData]. + * - **Bulk tier** (≤ 256 KB): the on-demand [ContactsProvider.CONTACT_APP_EXT_DATA_PAYLOAD_KEY] + * payload, shaped as [ContactAppExtData]. Read with [ContactRepository.loadAppExtData]; written via + * [ContactsProvider.setContactAppExtData] / [ContactsProvider.deleteContactAppExtData]. + * + * Pick a tier by size and never store the same value in both. + * + * ⚠️ This is **not per-app isolated and not zero-knowledge**: every slot is encrypted under the + * contact **file** key, so any app with read access to the contact drive can read every app's data. + * For genuinely sensitive values the app must encrypt them itself before writing (the server stores + * the bytes verbatim). + * + * ⚠️ The slot value is an **opaque string**. Structured data is double-encoded: serialize to JSON on + * write, parse from JSON on read. + */ + +/** + * The entire bulk-tier (`appextdata`) payload: one object mapping appId → that app's opaque string. + * Keys are canonical lowercase hyphenated UUID strings (e.g. `11111111-2222-3333-4444-555555555555`). + */ +@Serializable +data class ContactAppExtData( + val appData: Map = emptyMap(), +) + +/** + * Inline-tier read: this app's opaque string, or null when nothing has been written for [appId]. + * [appId] may be given dashless or hyphenated; it is normalized to the canonical map-key form. + */ +fun ContactContent.appDataFor(appId: String): String? = + appData?.get(appId.toCanonicalAppId()) + +/** Convenience: inline-tier read straight off a [Contact]. */ +fun Contact.appDataFor(appId: String): String? = content.appDataFor(appId) + +/** + * Normalizes an appId to the server's map-key form: a canonical lowercase hyphenated UUID. App + * registrations carry the id dashless (e.g. `AppConfig.APP_ID = "2d78140138044b57b4aad8e4e2ef39f4"`), + * but app-data maps are keyed hyphenated (`2d781401-3804-4b57-b4aa-d8e4e2ef39f4`). Accepts either + * form; falls back to a lowercased copy if it isn't a parseable UUID. + */ +fun String.toCanonicalAppId(): String = + (runCatching { Uuid.parseHex(this) }.getOrNull() + ?: runCatching { Uuid.parse(this) }.getOrNull()) + ?.toString() + ?: lowercase() + +/** + * Thrown when a write exceeds its tier's size cap (server `MaxContentLengthExceeded`, HTTP 400): the + * inline tier caps at 200 bytes UTF-8, the bulk tier at 256 KB. The remedy is to use the bulk tier + * (or, if already bulk, to shrink the value). + */ +class ContactAppDataTooLargeException(message: String) : Exception(message) diff --git a/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactAttributes.kt b/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactAttributes.kt new file mode 100644 index 000000000..fdf87a173 --- /dev/null +++ b/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactAttributes.kt @@ -0,0 +1,97 @@ +package id.homebase.api.client.contacts + +/** + * Well-known contact attribute-type ids. Each is `toGuidId(name)` (= md5 of the attribute name) on + * the canonical attribute name, hyphenated lowercase — the same ids the server/odin-js use. They key + * the entries in [ContactContent.social] (and identify the typed fields, kept here for reference even + * though those already have first-class [ContactContent] properties). + * + * The comment after each id is the string fed to `toGuidId` to derive it. + */ +object ContactAttributeId { + // -- Core fields (also modeled as typed ContactContent properties) ------------------------------- + const val NAME = "b068931c-c450-442b-63f5-b3d276ea4297" // toGuidId("name") + const val NICKNAME = "e8067417-0aae-0390-9a55-625e9cc9cf97" // toGuidId("nickname") + const val PHOTO = "5ae0c1c8-a526-0bc7-b664-8f6fbd115c35" // toGuidId("photo") + const val ADDRESS = "d5189de0-2792-2f81-0059-51e6efe0efd5" // toGuidId("location") + const val BIRTHDAY = "cf673f7e-e888-28c9-fb8f-6acf2cb08403" // toGuidId("birthday") + const val PHONE_NUMBER = "c5754f96-3780-6a28-30ca-2a957c2ac198" // toGuidId("phonenumber") + const val EMAIL = "0c83f57c-786a-0b4a-39ef-ab23731c7ebc" // toGuidId("email") + const val STATUS = "9acb4454-9b41-5636-97bb-490144ec6258" // toGuidId("status") + const val LINK = "2a304a13-4845-6ccd-2234-cd71a81bd338" // toGuidId("link") + const val SHORT_BIO = "2cd30a58-568d-c333-2379-44481aeb9ff1" // toGuidId("short_bio") + + // -- Social -------------------------------------------------------------------------------------- + const val HOMEBASE_IDENTITY = "0eb220c0-9268-57bd-3e31-4a0b9374e1ff" // toGuidId("dot_you_identity") + const val TWITTER = "54ecbdc0-35fd-1a44-d052-4303cd104411" // toGuidId("twitter_username") + const val FACEBOOK = "ccda59a7-03e9-4acc-daab-95b58f7c20b6" // toGuidId("facebook_username") + const val INSTAGRAM = "345fef7b-ada5-b100-001e-4c78111c86de" // toGuidId("instagram_username") + const val TIKTOK = "d58890b2-f156-a0b9-413b-388773b1b0a7" // toGuidId("tiktok_username") + const val LINKEDIN = "a050c5ee-4b51-39b7-30cd-7eb44e7db69a" // toGuidId("linkedin_username") + const val YOUTUBE = "90de1008-ca7d-a7a6-272b-2a3235c66989" // toGuidId("youtube_username") + const val DISCORD = "967c88ca-98b3-50eb-126c-199dd28f49cb" // toGuidId("discord_username") + const val SNAPCHAT = "6d65f3ba-48fc-06ff-edce-f170133577f0" // toGuidId("snapchat_username") + const val GITHUB = "9f1ea770-fb88-720c-4886-1df0f277fcea" // toGuidId("github_username") + const val STACK_OVERFLOW = "6b801187-7a10-443d-0d41-2dcfad398d06" // toGuidId("stackoverflow_username") + + // -- Games --------------------------------------------------------------------------------------- + const val EPIC = "138ce2df-9047-e6f0-4080-a0c870de5bac" // toGuidId("epic_username") + const val RIOT = "5c603ef7-e053-d069-10f8-c74618e7ab43" // toGuidId("riot_username") + const val STEAM = "e4f27af4-a80d-ff11-caac-432e4c97d79a" // toGuidId("steam_username") + const val MINECRAFT = "f37a742d-738e-ea92-3a7c-793dc72f8064" // toGuidId("minecraft_username") +} + +/** + * A known social/gaming network that a contact can carry a handle for, identified by its + * attribute-type id ([attributeId]) — the key under which the bare handle is stored in + * [ContactContent.social]. [label] is the brand name for display (not a localized string). + * + * Resolve a raw social-map key with [fromId]; unknown keys (networks we don't model yet) return null + * so callers can skip them. Order here is the order networks should render in. + */ +enum class ContactSocialNetwork(val attributeId: String, val label: String) { + HomebaseIdentity(ContactAttributeId.HOMEBASE_IDENTITY, "Homebase"), + Twitter(ContactAttributeId.TWITTER, "Twitter"), + Facebook(ContactAttributeId.FACEBOOK, "Facebook"), + Instagram(ContactAttributeId.INSTAGRAM, "Instagram"), + Tiktok(ContactAttributeId.TIKTOK, "TikTok"), + LinkedIn(ContactAttributeId.LINKEDIN, "LinkedIn"), + Youtube(ContactAttributeId.YOUTUBE, "YouTube"), + Discord(ContactAttributeId.DISCORD, "Discord"), + Snapchat(ContactAttributeId.SNAPCHAT, "Snapchat"), + Github(ContactAttributeId.GITHUB, "GitHub"), + StackOverflow(ContactAttributeId.STACK_OVERFLOW, "Stack Overflow"), + Epic(ContactAttributeId.EPIC, "Epic Games"), + Riot(ContactAttributeId.RIOT, "Riot"), + Steam(ContactAttributeId.STEAM, "Steam"), + Minecraft(ContactAttributeId.MINECRAFT, "Minecraft"), + ; + + companion object { + private val byId = entries.associateBy { normalizeId(it.attributeId) } + + /** Resolve a [ContactContent.social] key (attribute-type id) to a known network, or null. */ + fun fromId(attributeId: String): ContactSocialNetwork? = byId[normalizeId(attributeId)] + } +} + +/** + * Canonicalizes an attribute-type id for comparison: lowercased, dashes stripped. Stored keys are + * the dashless 32-hex form (e.g. `d5189de027922f81005951e6efe0efd5`) while the [ContactAttributeId] + * constants are hyphenated, so both sides are normalized before lookup. + */ +internal fun normalizeId(id: String): String = id.lowercase().replace("-", "") + +/** + * The contact's social handles resolved to known networks and rendered in [ContactSocialNetwork] + * order. Each pair is the network and its bare handle (blank handles and unknown networks dropped). + */ +fun ContactContent.socialHandles(): List> { + val map = social ?: return emptyList() + val byKey = map.entries.associate { (k, v) -> normalizeId(k) to v } + return ContactSocialNetwork.entries.mapNotNull { network -> + val handle = byKey[normalizeId(network.attributeId)]?.takeIf { it.isNotBlank() } + ?: return@mapNotNull null + network to handle + } +} diff --git a/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactContent.kt b/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactContent.kt index 4fb99c45a..55b65504b 100644 --- a/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactContent.kt +++ b/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactContent.kt @@ -42,7 +42,11 @@ data class ContactContent( // social stays nullable (not `emptyMap()`): the serializer encodes defaults, so a non-null empty // default would emit on every write and the server's merge would treat it as "set this", // clobbering stored handles. Null omits. - /** Social handles keyed by attribute-type-id GUID (hyphenated). Values are bare handles, not URLs. */ + /** + * Social handles keyed by attribute-type-id GUID in the dashless 32-hex form (e.g. + * `54ecbdc035fd1a44d0524303cd104411`). Values are bare handles, not URLs. Resolve known + * networks with [socialHandles] / [ContactSocialNetwork]. + */ val social: Map? = null, /** * Owner-only flag — a contact either is an emergency contact or isn't, so this is a plain @@ -53,6 +57,14 @@ data class ContactContent( */ @EncodeDefault(EncodeDefault.Mode.NEVER) val isEmergencyContact: Boolean = false, + /** + * Inline per-app data (the ≤200-byte tier), keyed by appId as a canonical lowercase hyphenated + * UUID string. Populated by the server on read (it rides in the contact content, so the contacts + * list query already returns it); absent when nothing has been written. Read it via + * [appDataFor]. We never write this through a contact UPDATE — it has its own endpoints + * ([ContactsProvider.setContactAppData]) — so it stays nullable to omit on our writes. + */ + val appData: Map? = null, ) @Serializable diff --git a/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactRepository.kt b/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactRepository.kt index d274b1986..65d865d60 100644 --- a/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactRepository.kt +++ b/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactRepository.kt @@ -3,7 +3,9 @@ package id.homebase.api.client.contacts import co.touchlab.kermit.Logger +import id.homebase.api.client.ClientException import id.homebase.api.client.ForbiddenException +import id.homebase.api.client.OdinClientErrorCode import id.homebase.api.client.auth.CredentialsManager import id.homebase.api.client.drives.QueryBatchSortField import id.homebase.api.client.drives.QueryBatchSortOrder @@ -202,12 +204,12 @@ class ContactRepository( * Fetches and parses a contact's on-demand `ext_data` payload (bios / rich text) — call only * when actually showing the bios; it is not part of the list/detail read. * - * Returns null when there is nothing to show: the contact has no `ext_data` payload - * ([Contact.hasExtData] is false, or the fetch 404s), the row is optimistic (no [Contact.fileId] - * yet), or the fetch/parse fails. Callers treat null as "empty extended data". + * Returns null when there is nothing to show: the contact has no `ext_data` payload (its key is + * absent from [Contact.payloadKeys], or the fetch 404s), the row is optimistic (no + * [Contact.fileId] yet), or the fetch/parse fails. Callers treat null as "empty extended data". */ suspend fun loadExtData(contact: Contact): ContactExtData? { - if (!contact.hasExtData) return null + if (ContactsProvider.CONTACT_EXT_DATA_PAYLOAD_KEY !in contact.payloadKeys) return null val fileId = contact.fileId ?: return null val keyHeader = contact.keyHeader ?: return null @@ -329,4 +331,155 @@ class ContactRepository( Logger.w(e, TAG) { "setContactImage failed for $uniqueId" } false } + + // ------------------------------------------------------------ + // Per-app app-data (two tiers) — write-through + bulk read + // ------------------------------------------------------------ + // + // [appId] is this app's id; it is used only to address the local slot (the inline optimistic + // patch / the bulk-read map key) — it is never sent on the wire (the server stamps it from the + // token). Pass it dashless or hyphenated; it's normalized to the canonical map-key form. + + /** + * Inline-tier write (≤ 200 bytes). On success optimistically patches the live contact's + * [ContactContent.appData] and adopts the returned versionTag; the authoritative row lands via + * drive sync. Returns the new id/versionTag, or null on a generic failure. Rethrows + * [ForbiddenException] (403) and [ContactAppDataTooLargeException] (blob over the tier cap — use + * [setAppExtData] instead). + */ + suspend fun setAppData( + uniqueId: Uuid, + appId: String, + content: String, + versionTag: Uuid, + ): ContactWriteResponse? { + val response = runAppDataWrite("setAppData", uniqueId) { + contactsProvider.setContactAppData(uniqueId, content, versionTag) + } ?: return null + patchInlineAppData(uniqueId, appId, content, response.versionTag) + return response + } + + /** Inline-tier delete. Optimistically removes this app's slot. Same error contract as [setAppData]. */ + suspend fun deleteAppData( + uniqueId: Uuid, + appId: String, + versionTag: Uuid, + ): ContactWriteResponse? { + val response = runAppDataWrite("deleteAppData", uniqueId) { + contactsProvider.deleteContactAppData(uniqueId, versionTag) + } ?: return null + patchInlineAppData(uniqueId, appId, null, response.versionTag) + return response + } + + /** + * Bulk-tier write (≤ 256 KB), stored as the `appextdata` payload. No optimistic list patch — the + * bulk slot isn't in the contacts list; read it back with [loadAppExtData]. Same error contract as + * [setAppData]. + */ + suspend fun setAppExtData( + uniqueId: Uuid, + content: String, + versionTag: Uuid, + ): ContactWriteResponse? = runAppDataWrite("setAppExtData", uniqueId) { + contactsProvider.setContactAppExtData(uniqueId, content, versionTag) + } + + /** Bulk-tier delete. Same error contract as [setAppData]. */ + suspend fun deleteAppExtData( + uniqueId: Uuid, + versionTag: Uuid, + ): ContactWriteResponse? = runAppDataWrite("deleteAppExtData", uniqueId) { + contactsProvider.deleteContactAppExtData(uniqueId, versionTag) + } + + /** + * Bulk-tier read: fetches + decrypts the `appextdata` payload and returns this app's opaque + * string, or null when absent (no payload, the row is optimistic, or fetch/parse fails). Mirrors + * [loadExtData]. + */ + suspend fun loadAppExtData(contact: Contact, appId: String): String? { + if (ContactsProvider.CONTACT_APP_EXT_DATA_PAYLOAD_KEY !in contact.payloadKeys) return null + val fileId = contact.fileId ?: return null + val keyHeader = contact.keyHeader ?: return null + + val bytes = try { + contactPayloadReader.getPayloadBytes( + driveId = driveId, + fileId = fileId, + key = ContactsProvider.CONTACT_APP_EXT_DATA_PAYLOAD_KEY, + keyHeader = keyHeader, + ) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Logger.w(e, TAG) { "loadAppExtData fetch failed for ${contact.uniqueId}" } + return null + } ?: return null + + return runCatching { + OdinSystemSerializer.deserialize(bytes.decodeToString()) + .appData[appId.toCanonicalAppId()] + }.getOrElse { + Logger.w(it, TAG) { "loadAppExtData parse failed for ${contact.uniqueId}" } + null + } + } + + /** + * Runs a provider app-data write and unwraps it. Returns the [ContactWriteResponse] on success, + * null on a generic failure / 404 / exhausted contention. Rethrows [ForbiddenException] and maps + * the size-cap 400 ([ClientException] with [OdinClientErrorCode.MaxContentLengthExceeded]) to + * [ContactAppDataTooLargeException]. + */ + private suspend fun runAppDataWrite( + op: String, + uniqueId: Uuid, + call: suspend () -> ContactWriteResult, + ): ContactWriteResponse? { + val result = try { + call() + } catch (e: CancellationException) { + throw e + } catch (e: ForbiddenException) { + throw e + } catch (e: ClientException) { + if (e.errorCode == OdinClientErrorCode.MaxContentLengthExceeded) { + throw ContactAppDataTooLargeException( + e.message ?: "app-data blob exceeds the tier size cap", + ) + } + Logger.w(e, TAG) { "$op failed for $uniqueId (400 ${e.errorCode})" } + return null + } catch (e: Exception) { + Logger.w(e, TAG) { "$op failed for $uniqueId" } + return null + } + return when (result) { + is ContactWriteResult.Ok -> result.body + // retryVersionGated only surfaces Ok/NotFound; Conflict can't reach here, but the `when` + // must be exhaustive. Both non-Ok cases are "nothing written" → null. + ContactWriteResult.NotFound -> null + is ContactWriteResult.Conflict -> null + } + } + + /** Optimistically sets ([content] != null) or clears this app's inline slot on the live contact. */ + private fun patchInlineAppData(uniqueId: Uuid, appId: String, content: String?, newTag: Uuid) { + val key = appId.toCanonicalAppId() + _contacts.update { current -> + val idx = current.indexOfFirst { it.uniqueId == uniqueId } + if (idx < 0) return@update current + val existing = current[idx] + val map = existing.content.appData ?: emptyMap() + val newMap = if (content == null) map - key else map + (key to content) + current.toMutableList().apply { + this[idx] = existing.copy( + content = existing.content.copy(appData = newMap.ifEmpty { null }), + versionTag = newTag, + ) + } + } + } } diff --git a/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactRequests.kt b/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactRequests.kt index 85b2e5f7b..3976deb38 100644 --- a/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactRequests.kt +++ b/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactRequests.kt @@ -19,3 +19,18 @@ data class UpdateContactRequest( val content: ContactContent, @Serializable(with = UuidSerializer::class) val versionTag: Uuid, ) + +/** + * Body for both per-app app-data PUTs — the inline tier (`/app-data`) and the bulk tier + * (`/app-ext-data`) share the identical shape. The server stamps the app's id from the auth token, + * so [appId] is never part of the body. + * + * [content] is an **opaque** plaintext string sent over the normal shared-secret transport (the + * server encrypts at rest — no client-side encryption on write). Structured data must be + * JSON-serialized into this single string by the caller and parsed back on read. + */ +@Serializable +data class SetContactAppDataRequest( + val content: String, + @Serializable(with = UuidSerializer::class) val versionTag: Uuid, +) diff --git a/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactsProvider.kt b/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactsProvider.kt index 84264bd1d..47daf43b5 100644 --- a/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactsProvider.kt +++ b/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactsProvider.kt @@ -57,6 +57,9 @@ class ContactsProvider( /** On-demand payload key the contact's large rich-text fields ([ContactExtData]) ride on. */ const val CONTACT_EXT_DATA_PAYLOAD_KEY: String = "ext_data" + + /** On-demand payload key the bulk-tier per-app data ([ContactAppExtData]) rides on. */ + const val CONTACT_APP_EXT_DATA_PAYLOAD_KEY: String = "appextdata" } // Caches the contact file's AES key by uniqueId. The key is stable across content/image updates, @@ -242,6 +245,94 @@ class ContactsProvider( } } + // ------------------------------------------------------------ + // PER-APP APP-DATA (two tiers — inline header vs bulk payload) + // ------------------------------------------------------------ + // + // Both tiers share the same write contract: PUT a { content, versionTag } body and DELETE with a + // ?versionTag= query, returning the standard [ContactWriteResponse]. The appId is NOT sent — the + // server stamps it from the auth token. [content] is plaintext (server-encrypted at rest), opaque, + // and capped server-side (200 bytes inline / 256 KB bulk); a too-large blob comes back as 400 + // `MaxContentLengthExceeded` via [throwForFailure]'s [ClientException] — callers map that to "use + // the bulk tier" (see [ContactRepository.setAppData]). Writes are version-gated and self-retrying + // server-side, so the bounded [retryVersionGated] (which re-sends with the authoritative + // `conflict.versionTag`) is all the conflict handling needed. + + /** + * PUT /api/v2/contacts/{uniqueId}/app-data — sets this app's **inline-tier** slot (≤ 200 bytes). + * Returns [ContactWriteResult.NotFound] on 404 (no such contact). + */ + suspend fun setContactAppData( + uniqueId: Uuid, + content: String, + versionTag: Uuid, + maxAttempts: Int = 3, + ): ContactWriteResult = putAppData("app-data", uniqueId, content, versionTag, maxAttempts) + + /** DELETE /api/v2/contacts/{uniqueId}/app-data — clears this app's inline-tier slot. */ + suspend fun deleteContactAppData( + uniqueId: Uuid, + versionTag: Uuid, + maxAttempts: Int = 3, + ): ContactWriteResult = deleteAppData("app-data", uniqueId, versionTag, maxAttempts) + + /** + * PUT /api/v2/contacts/{uniqueId}/app-ext-data — sets this app's **bulk-tier** slot (≤ 256 KB), + * stored as the [CONTACT_APP_EXT_DATA_PAYLOAD_KEY] payload. Returns [ContactWriteResult.NotFound] + * on 404. + */ + suspend fun setContactAppExtData( + uniqueId: Uuid, + content: String, + versionTag: Uuid, + maxAttempts: Int = 3, + ): ContactWriteResult = putAppData("app-ext-data", uniqueId, content, versionTag, maxAttempts) + + /** DELETE /api/v2/contacts/{uniqueId}/app-ext-data — clears this app's bulk-tier slot. */ + suspend fun deleteContactAppExtData( + uniqueId: Uuid, + versionTag: Uuid, + maxAttempts: Int = 3, + ): ContactWriteResult = deleteAppData("app-ext-data", uniqueId, versionTag, maxAttempts) + + private suspend fun putAppData( + pathSuffix: String, + uniqueId: Uuid, + content: String, + versionTag: Uuid, + maxAttempts: Int, + ): ContactWriteResult { + require(maxAttempts >= 1) { "maxAttempts must be >= 1" } + return retryVersionGated(versionTag, maxAttempts) { tag -> + val creds = requireCreds() + val response = encryptedPutJson( + url = apiUrl(creds.domain, "$BASE/$uniqueId/$pathSuffix"), + token = creds.accessToken, + jsonBody = OdinSystemSerializer.serialize(SetContactAppDataRequest(content, tag)), + secret = creds.secret, + ) + toWriteResult(response, allowNotFound = true) + } + } + + private suspend fun deleteAppData( + pathSuffix: String, + uniqueId: Uuid, + versionTag: Uuid, + maxAttempts: Int, + ): ContactWriteResult { + require(maxAttempts >= 1) { "maxAttempts must be >= 1" } + return retryVersionGated(versionTag, maxAttempts) { tag -> + val creds = requireCreds() + val response = encryptedDelete( + url = apiUrl(creds.domain, "$BASE/$uniqueId/$pathSuffix?versionTag=$tag"), + token = creds.accessToken, + secret = creds.secret, + ) + toWriteResult(response, allowNotFound = true) + } + } + // ------------------------------------------------------------ // MERGE-AND-RETRY // ------------------------------------------------------------ diff --git a/homebase-api/src/jvmTest/kotlin/id/homebase/api/client/contacts/ContactSocialTest.kt b/homebase-api/src/jvmTest/kotlin/id/homebase/api/client/contacts/ContactSocialTest.kt new file mode 100644 index 000000000..a295291a6 --- /dev/null +++ b/homebase-api/src/jvmTest/kotlin/id/homebase/api/client/contacts/ContactSocialTest.kt @@ -0,0 +1,61 @@ +package id.homebase.api.client.contacts + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Pins social-handle resolution. The critical contract is the key format: stored attribute-type ids + * are the DASHLESS 32-hex form, while [ContactAttributeId] constants are hyphenated — [normalizeId] + * must bridge the two. + */ +class ContactSocialTest { + + // Dashless forms of the stored keys (the way they actually arrive in ContactContent.social). + private val twitterDashless = "54ecbdc035fd1a44d0524303cd104411" + private val githubDashless = "9f1ea770fb88720c48861df0f277fcea" + + @Test + fun fromId_resolvesDashlessAndHyphenatedAndIsCaseInsensitive() { + assertEquals(ContactSocialNetwork.Twitter, ContactSocialNetwork.fromId(twitterDashless)) + assertEquals(ContactSocialNetwork.Twitter, ContactSocialNetwork.fromId(ContactAttributeId.TWITTER)) + assertEquals( + ContactSocialNetwork.Twitter, + ContactSocialNetwork.fromId(twitterDashless.uppercase()), + ) + } + + @Test + fun fromId_unknownIsNull() { + assertNull(ContactSocialNetwork.fromId("00000000000000000000000000000000")) + } + + @Test + fun socialHandles_resolvesDashlessKeysInNetworkOrderDroppingBlankAndUnknown() { + val content = ContactContent( + social = mapOf( + githubDashless to "octocat", // GitHub comes AFTER Twitter in enum order + twitterDashless to "@jack", + "00000000000000000000000000000000" to "ignored", // unknown network -> dropped + ContactAttributeId.DISCORD.replace("-", "") to " ", // blank handle -> dropped + ), + ) + + val resolved = content.socialHandles() + + // Order follows the enum (Twitter before GitHub), regardless of map insertion order. + assertEquals( + listOf( + ContactSocialNetwork.Twitter to "@jack", + ContactSocialNetwork.Github to "octocat", + ), + resolved, + ) + } + + @Test + fun socialHandles_nullMapIsEmpty() { + assertTrue(ContactContent().socialHandles().isEmpty()) + } +} diff --git a/homebase-common/src/commonMain/composeResources/values/strings.xml b/homebase-common/src/commonMain/composeResources/values/strings.xml index 3d8ea837f..79835c02f 100644 --- a/homebase-common/src/commonMain/composeResources/values/strings.xml +++ b/homebase-common/src/commonMain/composeResources/values/strings.xml @@ -1379,6 +1379,8 @@ Recent media No recent media yet Contact details + Bio + Social Location Circles Not in any of your circles yet. diff --git a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailScreen.kt b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailScreen.kt index 0a6a1d859..2cd8cacc7 100644 --- a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailScreen.kt +++ b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailScreen.kt @@ -197,6 +197,9 @@ fun ContactDetailScreen( onToggleMore = { detailsExpanded = !detailsExpanded }, ) + BioSection(entry.shortBio) + SocialSection(entry.socialHandles) + Spacer(modifier = Modifier.height(28.dp)) RecentMediaSection( overview = uiState.overview, @@ -316,6 +319,17 @@ private fun DetailHeader( ) } + // Free-text status/tagline the contact set, under the odinId. + entry.status?.takeIf { it.isNotBlank() }?.let { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + } + // Connection status line (only for Homebase contacts). if (uiState.hasOdinId) { val statusColor = when { diff --git a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailSections.kt b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailSections.kt index d001a5ac4..4abfe6073 100644 --- a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailSections.kt +++ b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailSections.kt @@ -49,8 +49,11 @@ import id.homebase.core.avatars.AvatarOptions import id.homebase.core.avatars.ConversationAvatar import id.homebase.core.config.chatTargetDrive import id.homebase.core.image.ImageSize +import id.homebase.api.client.contacts.ContactSocialNetwork import id.homebase.core.ui.screens.contactbook.model.ContactBookEntry import id.homebase.resources.MR +import id.homebase.resources.contactbook_detail_bio +import id.homebase.resources.contactbook_detail_social import id.homebase.resources.contactbook_detail_block import id.homebase.resources.contactbook_detail_circles import id.homebase.resources.contactbook_detail_circles_connect @@ -291,6 +294,47 @@ fun ContactFieldsSection( } } +/** Short free-text bio/tagline, in its own section. Renders nothing when the contact has none. */ +@Composable +fun BioSection(shortBio: String?) { + val bio = shortBio?.takeIf { it.isNotBlank() } ?: return + Spacer(modifier = Modifier.height(20.dp)) + Text( + text = stringResource(MR.string.contactbook_detail_bio), + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = bio, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + ) +} + +/** + * The contact's social/gaming handles (resolved to known networks), in its own section. Each row + * shows the network name over the bare handle. Renders nothing when there are none. + */ +@Composable +fun SocialSection(handles: List>) { + if (handles.isEmpty()) return + Spacer(modifier = Modifier.height(20.dp)) + Text( + text = stringResource(MR.string.contactbook_detail_social), + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + ) + handles.forEach { (network, handle) -> + ListItem( + leadingContent = { Icon(Icons.Outlined.AlternateEmail, contentDescription = null) }, + overlineContent = { Text(network.label) }, + headlineContent = { Text(handle) }, + ) + } +} + /** Management actions (gated by contact type / connection status). */ @Composable fun ManagementSection( diff --git a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/model/ContactBookEntry.kt b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/model/ContactBookEntry.kt index 0956954de..b335455c3 100644 --- a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/model/ContactBookEntry.kt +++ b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/model/ContactBookEntry.kt @@ -11,7 +11,9 @@ import id.homebase.api.client.contacts.ContactEmail import id.homebase.api.client.contacts.ContactLocation import id.homebase.api.client.contacts.ContactName import id.homebase.api.client.contacts.ContactPhone +import id.homebase.api.client.contacts.ContactSocialNetwork import id.homebase.api.client.contacts.resolveDisplayName +import id.homebase.api.client.contacts.socialHandles import id.homebase.api.client.drives.files.PayloadDescriptor import id.homebase.api.client.drives.upload.EmbeddedThumb import id.homebase.core.image.HomebaseImageData @@ -49,6 +51,12 @@ data class ContactBookEntry( val city: String? = null, val country: String? = null, val birthday: String? = null, + /** Free-text status/tagline (NOT connection state); rendered under the odinId in the header. */ + val status: String? = null, + /** Short header tagline (~<=160 chars); rendered in its own "Bio" section. */ + val shortBio: String? = null, + /** Known social/gaming handles in render order, resolved from [ContactContent.social]. */ + val socialHandles: List> = emptyList(), val source: String? = null, /** Pending (optimistic, not yet confirmed by the drive). */ val isPending: Boolean = false, @@ -165,6 +173,9 @@ fun Contact.toContactBookEntry(): ContactBookEntry? { city = content.location?.city, country = content.location?.country, birthday = content.birthday?.date, + status = content.status, + shortBio = content.shortBio, + socialHandles = content.socialHandles(), source = content.source, driveId = image?.driveId, keyHeader = image?.keyHeader, From 8fec7374e03be4c77f6028842439a4f21324a418 Mon Sep 17 00:00:00 2001 From: toddmitchell Date: Mon, 22 Jun 2026 17:07:25 -0500 Subject: [PATCH 13/23] Contacts: tests for per-app app-data tiers Cover the per-app contact app-data client landed in ed2015b8: the four ContactsProvider writes (shared body with no appId, inline vs bulk tier paths, version-gated retry, size-cap 400 -> MaxContentLengthExceeded), the appId normalization + inline/bulk read helpers, and ContactRepository bulk read + the too-large -> ContactAppDataTooLargeException translation. Adds a ProblemDetails fixture for the size-cap case. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../api/client/contacts/ContactAppDataTest.kt | 253 ++++++++++++++++++ .../api/client/contacts/ContactFixtures.kt | 4 + 2 files changed, 257 insertions(+) create mode 100644 homebase-api/src/jvmTest/kotlin/id/homebase/api/client/contacts/ContactAppDataTest.kt diff --git a/homebase-api/src/jvmTest/kotlin/id/homebase/api/client/contacts/ContactAppDataTest.kt b/homebase-api/src/jvmTest/kotlin/id/homebase/api/client/contacts/ContactAppDataTest.kt new file mode 100644 index 000000000..aff82ea0a --- /dev/null +++ b/homebase-api/src/jvmTest/kotlin/id/homebase/api/client/contacts/ContactAppDataTest.kt @@ -0,0 +1,253 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package id.homebase.api.client.contacts + +import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver +import id.homebase.api.client.ClientException +import id.homebase.api.client.CryptoHelper +import id.homebase.api.client.KeyHeader +import id.homebase.api.client.OdinClientErrorCode +import id.homebase.api.client.auth.ApiCredentials +import id.homebase.api.client.auth.CredentialsManager +import id.homebase.api.client.eventbus.EventBus +import id.homebase.api.common.OdinId +import id.homebase.api.common.SecureByteArray +import id.homebase.api.serialization.OdinSystemSerializer +import id.homebase.api.sync.database.DatabaseManager +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode +import io.ktor.http.content.TextContent +import io.ktor.http.headersOf +import kotlinx.coroutines.plus +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +/** + * Covers the per-app contact app-data client: the wire contract of the four [ContactsProvider] + * writes (shared body, tier paths, version-gated retry, size-cap 400), the pure read/normalization + * helpers, and the [ContactRepository] bulk read + size-cap translation. + */ +class ContactAppDataTest { + + private val testDomain = OdinId("test.homebase.id") + private val secretBytes = "0123456789abcdef".encodeToByteArray() + private val uniqueId = Uuid.parse("11111111-1111-1111-1111-111111111111") + private val tagA = Uuid.parse("22222222-2222-2222-2222-222222222222") + private val tagB = Uuid.parse("33333333-3333-3333-3333-333333333333") + + // appId in both forms — the registration constant is dashless, the map key is hyphenated. + private val appIdHyphenated = "2d781401-3804-4b57-b4aa-d8e4e2ef39f4" + private val appIdDashless = "2d78140138044b57b4aad8e4e2ef39f4" + + private val jsonHeaders = + headersOf("Content-Type" to listOf(ContentType.Application.Json.toString())) + + private suspend fun provider(engine: MockEngine): ContactsProvider { + val cm = CredentialsManager() + val creds = ApiCredentials.create( + domain = testDomain, + clientAccessToken = "test-token", + sharedSecret = SecureByteArray(secretBytes), + ) + cm.storeCredentials(creds) + cm.setActiveCredentials(creds) + return ContactsProvider(HttpClient(engine), cm, { _, _ -> null }) + } + + /** Decrypts the shared-secret transport envelope back to the plaintext app-data request. */ + private suspend fun decryptRequest(envelope: String): SetContactAppDataRequest = + OdinSystemSerializer.deserialize(CryptoHelper.decryptContentAsString(envelope, secretBytes)) + + // ------------------------------------------------------------ + // Models / read helpers + // ------------------------------------------------------------ + + @Test + fun requestSerializes_contentAndVersionTag_neverAppId() { + val json = OdinSystemSerializer.serialize(SetContactAppDataRequest("payload", tagA)) + assertTrue(json.contains("\"content\":\"payload\""), json) + assertTrue(json.contains("\"versionTag\":\"$tagA\""), json) + assertFalse(json.contains("appId"), "appId is stamped server-side, never sent: $json") + } + + @Test + fun appDataFor_readsByCanonicalKey_acceptingEitherAppIdForm() { + // Stored map is keyed hyphenated (the server's canonical form); lookups by either form hit. + val content = ContactContent(appData = mapOf(appIdHyphenated to "v")) + assertEquals("v", content.appDataFor(appIdHyphenated)) + assertEquals("v", content.appDataFor(appIdDashless)) + assertNull(content.appDataFor("99999999-9999-9999-9999-999999999999")) + assertNull(ContactContent().appDataFor(appIdHyphenated)) // absent → null + } + + @Test + fun toCanonicalAppId_normalizesBothFormsToHyphenatedLowercase() { + assertEquals(appIdHyphenated, appIdDashless.toCanonicalAppId()) + assertEquals(appIdHyphenated, appIdHyphenated.uppercase().toCanonicalAppId()) + } + + @Test + fun contactAppExtData_parsesAppDataMap() { + val ext = OdinSystemSerializer.deserialize( + """{"appData":{"$appIdHyphenated":"bulk-value"}}""", + ) + assertEquals("bulk-value", ext.appData[appIdHyphenated]) + } + + // ------------------------------------------------------------ + // Provider wire contract + // ------------------------------------------------------------ + + @Test + fun setContactAppData_putsInlinePath_sendsContentAndTag_returnsNewTag() = runTest { + var envelope: String? = null + val engine = MockEngine { request -> + assertTrue(request.url.encodedPath.endsWith("/contacts/$uniqueId/app-data"), request.url.toString()) + envelope = (request.body as TextContent).text + respond(ContactFixtures.okBody("$uniqueId", "$tagB"), HttpStatusCode.OK, jsonHeaders) + } + + val result = provider(engine).setContactAppData(uniqueId, "hello", tagA) + + assertEquals(tagB, assertIs(result).body.versionTag) + val sent = decryptRequest(envelope!!) + assertEquals("hello", sent.content) + assertEquals(tagA, sent.versionTag) + } + + @Test + fun setContactAppExtData_putsBulkPath() = runTest { + val engine = MockEngine { request -> + assertTrue(request.url.encodedPath.endsWith("/contacts/$uniqueId/app-ext-data"), request.url.toString()) + respond(ContactFixtures.okBody("$uniqueId", "$tagB"), HttpStatusCode.OK, jsonHeaders) + } + assertIs(provider(engine).setContactAppExtData(uniqueId, "big", tagA)) + } + + @Test + fun deleteContactAppData_sendsVersionTagQuery() = runTest { + val engine = MockEngine { request -> + assertTrue(request.url.encodedPath.endsWith("/contacts/$uniqueId/app-data"), request.url.toString()) + assertTrue(request.url.encodedQuery.contains("versionTag=$tagA"), request.url.toString()) + respond(ContactFixtures.okBody("$uniqueId", "$tagB"), HttpStatusCode.OK, jsonHeaders) + } + assertEquals(tagB, assertIs(provider(engine).deleteContactAppData(uniqueId, tagA)).body.versionTag) + } + + @Test + fun setContactAppData_404_returnsNotFound() = runTest { + val engine = MockEngine { respond("{}", HttpStatusCode.NotFound, jsonHeaders) } + assertEquals(ContactWriteResult.NotFound, provider(engine).setContactAppData(uniqueId, "x", tagA)) + } + + @Test + fun setContactAppData_409_retriesWithFreshTag() = runTest { + val tags = mutableListOf() + val responses = listOf( + HttpStatusCode.Conflict to ContactFixtures.conflictBody("$uniqueId", "$tagB"), + HttpStatusCode.OK to ContactFixtures.okBody("$uniqueId", "$tagB"), + ) + var i = 0 + val engine = MockEngine { request -> + tags += decryptRequest((request.body as TextContent).text).versionTag + val (status, body) = responses[i++] + respond(body, status, jsonHeaders) + } + + assertIs(provider(engine).setContactAppData(uniqueId, "x", tagA)) + assertEquals(listOf(tagA, tagB), tags) // first attempt last-seen tag, retry with authoritative tag + } + + @Test + fun setContactAppData_tooLarge_throwsMaxContentLength() = runTest { + val engine = MockEngine { + respond( + ContactFixtures.problemBody(OdinClientErrorCode.MaxContentLengthExceeded.value), + HttpStatusCode.BadRequest, + jsonHeaders, + ) + } + val ex = assertFailsWith { provider(engine).setContactAppData(uniqueId, "toobig", tagA) } + assertEquals(OdinClientErrorCode.MaxContentLengthExceeded, ex.errorCode) + } + + // ------------------------------------------------------------ + // Repository: bulk read + size-cap translation + // ------------------------------------------------------------ + + private suspend fun TestScope.repo( + engine: MockEngine, + payloadReader: ContactPayloadReader = ContactPayloadReader { _, _, _, _ -> null }, + ): ContactRepository { + val cm = CredentialsManager() + val creds = ApiCredentials.create( + domain = testDomain, + clientAccessToken = "test-token", + sharedSecret = SecureByteArray(secretBytes), + ) + cm.storeCredentials(creds) + cm.setActiveCredentials(creds) + val provider = ContactsProvider(HttpClient(engine), cm, { _, _ -> null }) + val dbm = DatabaseManager({ JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY) }) + val scope = backgroundScope + UnconfinedTestDispatcher(testScheduler) + return ContactRepository(provider, payloadReader, dbm, cm, EventBus(), scope) + } + + /** A Contact carrying the file id + key needed to fetch its bulk payload. */ + private fun bulkContact(hasPayload: Boolean) = Contact( + uniqueId = uniqueId, + versionTag = tagA, + content = ContactContent(), + fileId = Uuid.parse("99999999-9999-9999-9999-999999999999"), + keyHeader = KeyHeader(iv = ByteArray(16), aesKey = SecureByteArray(ByteArray(16))), + payloadKeys = + if (hasPayload) setOf(ContactsProvider.CONTACT_APP_EXT_DATA_PAYLOAD_KEY) else emptySet(), + ) + + private val okEngine get() = MockEngine { respond("{}", HttpStatusCode.OK, jsonHeaders) } + + @Test + fun loadAppExtData_fetchesAppExtDataKey_decodesAppIdValue() = runTest { + val reader = ContactPayloadReader { _, _, key, _ -> + assertEquals(ContactsProvider.CONTACT_APP_EXT_DATA_PAYLOAD_KEY, key) + """{"appData":{"$appIdHyphenated":"bulk"}}""".encodeToByteArray() + } + // appId given dashless; the lookup normalizes to the canonical hyphenated map key. + assertEquals("bulk", repo(okEngine, reader).loadAppExtData(bulkContact(true), appIdDashless)) + } + + @Test + fun loadAppExtData_skipsFetch_whenPayloadAbsent() = runTest { + var fetched = false + val reader = ContactPayloadReader { _, _, _, _ -> fetched = true; null } + assertNull(repo(okEngine, reader).loadAppExtData(bulkContact(false), appIdHyphenated)) + assertFalse(fetched, "no payload key → no fetch") + } + + @Test + fun setAppData_tooLarge_translatesToTooLargeException() = runTest { + val engine = MockEngine { + respond( + ContactFixtures.problemBody(OdinClientErrorCode.MaxContentLengthExceeded.value), + HttpStatusCode.BadRequest, + jsonHeaders, + ) + } + assertFailsWith { + repo(engine).setAppData(uniqueId, appIdHyphenated, "big", tagA) + } + } +} diff --git a/homebase-api/src/jvmTest/kotlin/id/homebase/api/client/contacts/ContactFixtures.kt b/homebase-api/src/jvmTest/kotlin/id/homebase/api/client/contacts/ContactFixtures.kt index 67d47bde4..cde28fbf8 100644 --- a/homebase-api/src/jvmTest/kotlin/id/homebase/api/client/contacts/ContactFixtures.kt +++ b/homebase-api/src/jvmTest/kotlin/id/homebase/api/client/contacts/ContactFixtures.kt @@ -7,6 +7,10 @@ internal object ContactFixtures { fun okBody(uniqueId: String, versionTag: String): String = """{"uniqueId":"$uniqueId","versionTag":"$versionTag"}""" + /** A 400 ProblemDetails body carrying [errorCode] (e.g. 4167 = MaxContentLengthExceeded). */ + fun problemBody(errorCode: Int, title: String = "Bad request"): String = + """{"status":400,"title":"$title","extensions":{"errorCode":$errorCode}}""" + /** * A 409 ContactWriteConflict body whose `current` is a minimal but valid [ServerFile]-shaped * file header. `fileMetadata.versionTag` mirrors the top-level [versionTag] (the server keeps From d689e17169d53e984e375693c7231445a947b43e Mon Sep 17 00:00:00 2001 From: toddmitchell Date: Mon, 22 Jun 2026 17:59:54 -0500 Subject: [PATCH 14/23] Contacts: emergency-contact designation over chat + setEmergencyContact Receive-side of the emergency-contact handshake: - New StatusMessage.EmergencyContactDesignated discriminator. - ConversationStream gains onEmergencyContactDesignated + a dispatchEmergencyDesignations pre-pass (live BatchReceived only, receiver-side filtered) mirroring the GroupHealRequested hook. - AppModule wires it to ContactRepository.setEmergencyContact on the sender's contact (md5(odinId)) on our own drive: ensureLoaded, idempotent, sync-if-absent, Forbidden-safe. - MessageMapper renders a system line "X added you as an emergency contact" (self/subject variants); 3 new strings. ContactRepository.setEmergencyContact(uniqueId, versionTag): minimal-delta write that sends only {"isEmergencyContact": true} and optimistically copies the flag onto the live contact's existing content (set-only; the merge can't clear it). Covered by a new ContactRepositoryTest. Also: pre-normalize ContactSocialNetwork attribute ids via normalizeId at construction. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../api/client/contacts/ContactAttributes.kt | 30 ++++++------ .../api/client/contacts/ContactRepository.kt | 48 +++++++++++++++++++ .../client/contacts/ContactRepositoryTest.kt | 29 +++++++++++ .../homebase/chat/services/MessageMapper.kt | 13 +++++ .../homebase/chat/services/StatusMessage.kt | 7 +++ .../chat/services/convo/ConversationStream.kt | 30 ++++++++++++ .../composeResources/values/strings.xml | 3 ++ .../kotlin/id/homebase/core/di/AppModule.kt | 32 +++++++++++++ 8 files changed, 177 insertions(+), 15 deletions(-) diff --git a/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactAttributes.kt b/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactAttributes.kt index fdf87a173..525011dfb 100644 --- a/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactAttributes.kt +++ b/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactAttributes.kt @@ -50,21 +50,21 @@ object ContactAttributeId { * so callers can skip them. Order here is the order networks should render in. */ enum class ContactSocialNetwork(val attributeId: String, val label: String) { - HomebaseIdentity(ContactAttributeId.HOMEBASE_IDENTITY, "Homebase"), - Twitter(ContactAttributeId.TWITTER, "Twitter"), - Facebook(ContactAttributeId.FACEBOOK, "Facebook"), - Instagram(ContactAttributeId.INSTAGRAM, "Instagram"), - Tiktok(ContactAttributeId.TIKTOK, "TikTok"), - LinkedIn(ContactAttributeId.LINKEDIN, "LinkedIn"), - Youtube(ContactAttributeId.YOUTUBE, "YouTube"), - Discord(ContactAttributeId.DISCORD, "Discord"), - Snapchat(ContactAttributeId.SNAPCHAT, "Snapchat"), - Github(ContactAttributeId.GITHUB, "GitHub"), - StackOverflow(ContactAttributeId.STACK_OVERFLOW, "Stack Overflow"), - Epic(ContactAttributeId.EPIC, "Epic Games"), - Riot(ContactAttributeId.RIOT, "Riot"), - Steam(ContactAttributeId.STEAM, "Steam"), - Minecraft(ContactAttributeId.MINECRAFT, "Minecraft"), + HomebaseIdentity(normalizeId(ContactAttributeId.HOMEBASE_IDENTITY), "Homebase"), + Twitter(normalizeId(ContactAttributeId.TWITTER), "Twitter"), + Facebook(normalizeId(ContactAttributeId.FACEBOOK), "Facebook"), + Instagram(normalizeId(ContactAttributeId.INSTAGRAM), "Instagram"), + Tiktok(normalizeId(ContactAttributeId.TIKTOK), "TikTok"), + LinkedIn(normalizeId(ContactAttributeId.LINKEDIN), "LinkedIn"), + Youtube(normalizeId(ContactAttributeId.YOUTUBE), "YouTube"), + Discord(normalizeId(ContactAttributeId.DISCORD), "Discord"), + Snapchat(normalizeId(ContactAttributeId.SNAPCHAT), "Snapchat"), + Github(normalizeId(ContactAttributeId.GITHUB), "GitHub"), + StackOverflow(normalizeId(ContactAttributeId.STACK_OVERFLOW), "Stack Overflow"), + Epic(normalizeId(ContactAttributeId.EPIC), "Epic Games"), + Riot(normalizeId(ContactAttributeId.RIOT), "Riot"), + Steam(normalizeId(ContactAttributeId.STEAM), "Steam"), + Minecraft(normalizeId(ContactAttributeId.MINECRAFT), "Minecraft"), ; companion object { diff --git a/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactRepository.kt b/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactRepository.kt index 65d865d60..096769447 100644 --- a/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactRepository.kt +++ b/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactRepository.kt @@ -267,6 +267,54 @@ class ContactRepository( return response } + /** + * Minimal-delta write of the owner-only [ContactContent.isEmergencyContact] flag: sends ONLY + * `{"isEmergencyContact": true}` so the server's field-level merge flips just this flag and + * leaves every other stored field untouched — no need to resend the whole contact. On success it + * optimistically copies the flag onto the live contact's existing content (so the rest of the UI + * model is preserved — unlike [save], which replaces the content wholesale) and adopts the + * returned versionTag; the authoritative row lands later via drive sync. + * + * Returns the new id/versionTag, or null on a generic failure. Rethrows [ForbiddenException] + * (403). + * + * Sets the flag only — it cannot be CLEARED this way: `isEmergencyContact` is + * `@EncodeDefault(NEVER)`, so a `false` is omitted from the JSON and the merge reads an omitted + * field as "leave alone" (clearing needs dedicated server support). + */ + suspend fun setEmergencyContact( + uniqueId: Uuid, + versionTag: Uuid, + ): ContactWriteResponse? { + val response = try { + contactsProvider.saveContact( + content = ContactContent(isEmergencyContact = true), + knownUniqueId = uniqueId, + knownVersionTag = versionTag, + ) + } catch (e: CancellationException) { + throw e + } catch (e: ForbiddenException) { + throw e + } catch (e: Exception) { + Logger.w(e, TAG) { "setEmergencyContact failed for $uniqueId" } + return null + } + + _contacts.update { current -> + val idx = current.indexOfFirst { it.uniqueId == uniqueId } + if (idx < 0) return@update current + val existing = current[idx] + current.toMutableList().apply { + this[idx] = existing.copy( + content = existing.content.copy(isEmergencyContact = true), + versionTag = response.versionTag, + ) + } + } + return response + } + /** * Soft-delete. Optimistically removes the contact; on a generic failure it reloads to restore * truth. Returns true on success (or already-gone). Rethrows [ForbiddenException] (403). diff --git a/homebase-api/src/jvmTest/kotlin/id/homebase/api/client/contacts/ContactRepositoryTest.kt b/homebase-api/src/jvmTest/kotlin/id/homebase/api/client/contacts/ContactRepositoryTest.kt index 6e1a1aa8b..cc174276c 100644 --- a/homebase-api/src/jvmTest/kotlin/id/homebase/api/client/contacts/ContactRepositoryTest.kt +++ b/homebase-api/src/jvmTest/kotlin/id/homebase/api/client/contacts/ContactRepositoryTest.kt @@ -326,6 +326,35 @@ class ContactRepositoryTest { assertEquals(1, repo.contacts.value.size, "sync must lift the guard for md5(odinId)") } + @Test + fun setEmergencyContact_flipsFlagOptimisticallyPreservingOtherFields() = runTest { + val cm = credentialsManager() + val eventBus = EventBus() + val uid = Uuid.parse("11111111-1111-1111-1111-111111111111") + val repo = repo(cm, memoryDb(), eventBus, writeEngine(uid)) + advanceUntilIdle() + + // A fully-populated contact arrives, not yet an emergency contact. + val content = ContactContent( + name = ContactName(displayName = "Sam"), + phone = ContactPhone(number = "+15550100"), + isEmergencyContact = false, + ) + eventBus.emit(batch(contactFile(uid, content))) + advanceUntilIdle() + assertTrue(repo.emergencyContacts.value.isEmpty()) + + val response = repo.setEmergencyContact(uid, versionTag = tag) + advanceUntilIdle() + assertNotNull(response) + + val after = repo.contacts.value.single() + assertTrue(after.content.isEmergencyContact, "flag must be set") + assertEquals("Sam", after.content.name?.displayName, "other fields must be preserved") + assertEquals("+15550100", after.content.phone?.number, "other fields must be preserved") + assertEquals(listOf(uid), repo.emergencyContacts.value.map { it.uniqueId }) + } + // ------------------------------------------------------------ // loadAll (real DB) // ------------------------------------------------------------ diff --git a/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/MessageMapper.kt b/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/MessageMapper.kt index ad8d0cb13..4196a469c 100644 --- a/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/MessageMapper.kt +++ b/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/MessageMapper.kt @@ -44,6 +44,9 @@ import id.homebase.resources.system_group_heal_local_cleanup_both import id.homebase.resources.system_group_heal_local_cleanup_main import id.homebase.resources.system_group_heal_requested import id.homebase.resources.system_group_heal_requested_you +import id.homebase.resources.system_emergency_contact_designated +import id.homebase.resources.system_emergency_contact_designated_you +import id.homebase.resources.system_emergency_contact_designated_you_unknown import id.homebase.resources.chat_poll_ended_other import id.homebase.resources.chat_poll_ended_self import kotlinx.collections.immutable.toPersistentList @@ -524,5 +527,15 @@ internal suspend fun renderStatusMessage( if (authorIsYou) TranslationUtil.getString(MR.string.chat_poll_ended_self, q) else TranslationUtil.getString(MR.string.chat_poll_ended_other, name, q) } + + StatusMessage.EmergencyContactDesignated -> + when { + authorIsYou && subject != null -> + TranslationUtil.getString(MR.string.system_emergency_contact_designated_you, subject) + authorIsYou -> + TranslationUtil.getString(MR.string.system_emergency_contact_designated_you_unknown) + else -> + TranslationUtil.getString(MR.string.system_emergency_contact_designated, name) + } } } diff --git a/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/StatusMessage.kt b/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/StatusMessage.kt index f1f0b8f98..4e1dce1d1 100644 --- a/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/StatusMessage.kt +++ b/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/StatusMessage.kt @@ -36,4 +36,11 @@ enum class StatusMessage() { * Carries [StatusMessageData.pollQuestion] and [StatusMessageData.pollMessageId] so * the system line can reference the question. */ PollEnded, + + /** Sent by a user (the message's `originalAuthor`) when they designate the recipient as one of + * their emergency contacts. Renders a system line ("X added you as an emergency contact") AND + * drives a receive-side side-effect (wired in AppModule) that marks the SENDER as an emergency + * contact on the recipient's own contact drive — see + * [id.homebase.api.client.contacts.ContactRepository.setEmergencyContact]. */ + EmergencyContactDesignated, } diff --git a/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/convo/ConversationStream.kt b/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/convo/ConversationStream.kt index ba427be25..ad60344f8 100644 --- a/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/convo/ConversationStream.kt +++ b/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/convo/ConversationStream.kt @@ -128,6 +128,14 @@ class ConversationStream( * self-destruct it (soft-delete + hard-delete) once cleanup runs. */ var onIncomingHealRequest: (suspend (status: StatusMessageData, sender: OdinId, messageFile: HomebaseFile) -> Unit)? = null + + /** + * Hook invoked when the live receive stream observes an incoming + * [StatusMessage.EmergencyContactDesignated] status — i.e. the [sender] designated us as one of + * their emergency contacts. Wired in AppModule to mark the [sender] as an emergency contact on + * our own contact drive. Side effect only — does not affect message-list dispatch. + */ + var onEmergencyContactDesignated: (suspend (sender: OdinId, messageFile: HomebaseFile) -> Unit)? = null // endregion // region Orphan-recovery: read-path dedup of recover attempts @@ -401,6 +409,26 @@ class ConversationStream( } } + private suspend fun dispatchEmergencyDesignations(messageFiles: List) { + val handler = onEmergencyContactDesignated ?: return + for (file in messageFiles) { + val appData = file.fileMetadata.appData + if (appData.dataType != ChatProtocol.ChatStatusMessageDataType) continue + // originalAuthor is null on our own synced copy, so this only fires on the receiver side. + val sender = file.fileMetadata.originalAuthor ?: file.fileMetadata.senderOdinId ?: continue + val content = appData.content ?: continue + val status = runCatching { + OdinSystemSerializer.deserialize(content) + }.getOrNull() ?: continue + if (status.statusMessage != StatusMessage.EmergencyContactDesignated) continue + try { + handler(sender, file) + } catch (e: Exception) { + Logger.e(e) { "ConversationStream: emergency-designation handler threw for sender=${sender.domainName}: ${e.message}" } + } + } + } + private suspend fun processMessageBatchIncrementally(messageFiles: List) { if (messageFiles.isEmpty()) throw IllegalArgumentException("It can't be empty") @@ -408,6 +436,8 @@ class ConversationStream( // handler. Done here (live BatchReceived only — never on cold reads or // searches) so the side effects fire exactly once per arrival. dispatchGroupHealRequests(messageFiles) + // Same live-only contract: mark the sender as an emergency contact when they designate us. + dispatchEmergencyDesignations(messageFiles) // For each file in the batch, map to model (fetch last message from DB if needed). // Keep the original HomebaseFile alongside the mapped MessageUiModel so we can diff --git a/homebase-common/src/commonMain/composeResources/values/strings.xml b/homebase-common/src/commonMain/composeResources/values/strings.xml index 79835c02f..0d60a0667 100644 --- a/homebase-common/src/commonMain/composeResources/values/strings.xml +++ b/homebase-common/src/commonMain/composeResources/values/strings.xml @@ -482,6 +482,9 @@ You declined to rejoin the conversation %1$s requested a group heal You requested a group heal + %1$s added you as an emergency contact + You added %1$s as an emergency contact + You added an emergency contact Removed broken local copies (group + admins) as part of heal Removed broken local copy (group) as part of heal Removed broken local copy (admins) as part of heal diff --git a/homebase-core/src/commonMain/kotlin/id/homebase/core/di/AppModule.kt b/homebase-core/src/commonMain/kotlin/id/homebase/core/di/AppModule.kt index b3727fdf9..1e65d3f2a 100644 --- a/homebase-core/src/commonMain/kotlin/id/homebase/core/di/AppModule.kt +++ b/homebase-core/src/commonMain/kotlin/id/homebase/core/di/AppModule.kt @@ -71,7 +71,9 @@ import id.homebase.core.auth.AuthConnectionCoordinator import id.homebase.core.util.PlatformInfo import id.homebase.core.vault.VaultPreferences import id.homebase.core.contactbook.ContactBookPreferences +import id.homebase.api.client.ForbiddenException import id.homebase.api.client.contacts.ContactRepository +import id.homebase.api.crypto.Md5 import id.homebase.core.ui.screens.contactbook.ContactBookViewModel import id.homebase.core.ui.screens.contactbook.detail.ContactDetailViewModel import id.homebase.core.ui.screens.contactbook.settings.ContactBookSettingsViewModel @@ -451,6 +453,36 @@ val appModule = module { } // endregion + // region Emergency contact: incoming EmergencyContactDesignated status + // The sender designated us as their emergency contact; mark THEM as an emergency + // contact on our own contact drive. Best-effort and idempotent. + val contactRepository = get() + conversationStream.onEmergencyContactDesignated = { sender, _ -> + try { + contactRepository.ensureLoaded() + val uniqueId = Md5.toGuidId(sender.domainName) + val contact = contactRepository.contacts.value + .firstOrNull { it.uniqueId == uniqueId } + val versionTag = contact?.versionTag + when { + // Not a contact on our drive yet — create/enrich it from the sender's + // profile. The flag isn't applied on this delivery; a later designation + // (or a manual mark) sets it once the row exists. + contact == null -> contactRepository.sync(sender) + // Already flagged — idempotent no-op (status messages can re-deliver). + contact.content.isEmergencyContact -> Unit + versionTag != null -> + contactRepository.setEmergencyContact(uniqueId, versionTag) + else -> Unit + } + } catch (e: ForbiddenException) { + Logger.w(e) { "emergency designation: missing ManageContacts permission" } + } catch (e: Exception) { + Logger.w(e) { "emergency designation handling failed for ${sender.domainName}" } + } + } + // endregion + // region Auto-unarchive: incoming message for archived conversation conversationStream.onUnarchiveConversation = { conversationId -> conversationService.unarchiveConversation(conversationId) From 0bdd4e6735161f5f5b3563f06efd2e1ce6c45d25 Mon Sep 17 00:00:00 2001 From: toddmitchell Date: Mon, 22 Jun 2026 20:43:53 -0500 Subject: [PATCH 15/23] Contacts: send-side EmergencyContactDesignated status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ConversationService.sendEmergencyContactDesignation(recipient): gets-or-creates the 1:1 and posts a StatusMessage.EmergencyContactDesignated status (subject = recipient, so the sender's own copy reads "You added X…"). Mirrors the ConversationStarted send pattern; best-effort, returns the conversation id or null. Pairs with the receive-side hook in ConversationStream/AppModule and ContactRepository.setEmergencyContact. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../services/convo/ConversationService.kt | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/convo/ConversationService.kt b/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/convo/ConversationService.kt index 88f374718..647538634 100644 --- a/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/convo/ConversationService.kt +++ b/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/convo/ConversationService.kt @@ -381,6 +381,43 @@ class ConversationService( val wasNewlyCreated: Boolean ) + /** + * Notifies [recipient] that the local user has designated them as an emergency contact by + * posting a [StatusMessage.EmergencyContactDesignated] status into their 1:1 conversation + * (created if it doesn't exist yet). [StatusMessageData.subject] carries [recipient] so the + * sender's own copy renders "You added {recipient}…"; the receiver's copy renders "{sender} + * added you…" and drives the receive-side bit (see + * [id.homebase.chat.services.convo.ConversationStream.onEmergencyContactDesignated]). + * + * Unlike the "conversation started" status this is posted on every designation (not just a + * freshly-created thread). Best-effort: returns the conversation id on success, or null on + * failure (logged). Rethrows cancellation. + */ + suspend fun sendEmergencyContactDesignation(recipient: OdinId): Uuid? { + return try { + val result = createConversation( + recipients = listOf(recipient), + title = null, + payloadBundle = null, + ) + chatMessageSenderService.sendStatusMessage( + messageUniqueId = Uuid.random(), + conversationId = result.conversationId, + previousMessageUniqueId = result.conversationId, + statusMessage = StatusMessageData( + statusMessage = StatusMessage.EmergencyContactDesignated, + subject = recipient, + ), + ) + result.conversationId + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Logger.w(e) { "Failed to send emergency-contact designation to ${recipient.domainName}" } + null + } + } + /** * Creates a conversation file locally and enqueues it for server upload. * Shared by [createConversation] and [ensureNoteToSelfExists]. From ed0e5090bfebafa3712e569b889760224fbebeba Mon Sep 17 00:00:00 2001 From: toddmitchell Date: Tue, 23 Jun 2026 09:17:10 -0500 Subject: [PATCH 16/23] Contacts: emergency-contact toggle in detail + dedicated clear write MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detail screen: - "Emergency contact" indicator under the avatar/odinId when the contact is one of our emergency contacts. - Management action toggles between "Mark as emergency contact" (sets the flag + notifies the contact via EmergencyContactDesignated) and "Remove as emergency contact" (clears it, local-only). Shown for a synced Homebase identity. - ContactDetailViewModel.handleMakeEmergencyContact / handleRemoveEmergencyContact; new actions/events + snackbars; ContactBookEntry.isEmergencyContact. API: - ContactRepository.clearEmergencyContact + setEmergencyContact refactored onto a shared writeEmergencyFlag helper over the new version-gated ContactsProvider.writeEmergencyContact. - EmergencyContactDelta/SetEmergencyContactRequest: a dedicated wire shape that always emits the bool, so a clear (isEmergencyContact=false) can be expressed — ContactContent omits a false (@EncodeDefault NEVER). Assumes the server honors an explicit false as a clear. Covered by a new clearEmergencyContact test. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../api/client/contacts/ContactRepository.kt | 51 +++++++++------- .../api/client/contacts/ContactRequests.kt | 18 ++++++ .../api/client/contacts/ContactsProvider.kt | 29 +++++++++ .../client/contacts/ContactRepositoryTest.kt | 29 +++++++++ .../composeResources/values/strings.xml | 5 ++ .../contactbook/detail/ContactDetailScreen.kt | 30 ++++++++++ .../detail/ContactDetailSections.kt | 20 +++++++ .../detail/ContactDetailUiState.kt | 6 ++ .../detail/ContactDetailViewModel.kt | 60 +++++++++++++++++++ .../contactbook/model/ContactBookEntry.kt | 3 + 10 files changed, 228 insertions(+), 23 deletions(-) diff --git a/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactRepository.kt b/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactRepository.kt index 096769447..fe621fb98 100644 --- a/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactRepository.kt +++ b/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactRepository.kt @@ -268,38 +268,43 @@ class ContactRepository( } /** - * Minimal-delta write of the owner-only [ContactContent.isEmergencyContact] flag: sends ONLY - * `{"isEmergencyContact": true}` so the server's field-level merge flips just this flag and - * leaves every other stored field untouched — no need to resend the whole contact. On success it - * optimistically copies the flag onto the live contact's existing content (so the rest of the UI - * model is preserved — unlike [save], which replaces the content wholesale) and adopts the - * returned versionTag; the authoritative row lands later via drive sync. + * Marks this contact as one of our emergency contacts — a minimal-delta, version-gated write + * that sends only the flag (via [ContactsProvider.writeEmergencyContact]) so the server's field + * merge leaves every other stored field untouched. Optimistically copies the flag onto the live + * contact's existing content (unlike [save], which replaces it wholesale) and adopts the returned + * versionTag; the authoritative row lands later via drive sync. * - * Returns the new id/versionTag, or null on a generic failure. Rethrows [ForbiddenException] - * (403). - * - * Sets the flag only — it cannot be CLEARED this way: `isEmergencyContact` is - * `@EncodeDefault(NEVER)`, so a `false` is omitted from the JSON and the merge reads an omitted - * field as "leave alone" (clearing needs dedicated server support). + * Returns the new id/versionTag, or null on a generic failure / no-such-contact. Rethrows + * [ForbiddenException] (403). See [clearEmergencyContact] to remove the flag. + */ + suspend fun setEmergencyContact(uniqueId: Uuid, versionTag: Uuid): ContactWriteResponse? = + writeEmergencyFlag(uniqueId, isEmergencyContact = true, versionTag = versionTag) + + /** + * Removes this contact as an emergency contact. Same minimal-delta, version-gated path as + * [setEmergencyContact] but explicitly clears the flag — the dedicated [EmergencyContactDelta] + * write can express a `false`, which the normal [ContactContent] merge (which omits a `false`) + * can't. Rethrows [ForbiddenException] (403). */ - suspend fun setEmergencyContact( + suspend fun clearEmergencyContact(uniqueId: Uuid, versionTag: Uuid): ContactWriteResponse? = + writeEmergencyFlag(uniqueId, isEmergencyContact = false, versionTag = versionTag) + + private suspend fun writeEmergencyFlag( uniqueId: Uuid, + isEmergencyContact: Boolean, versionTag: Uuid, ): ContactWriteResponse? { - val response = try { - contactsProvider.saveContact( - content = ContactContent(isEmergencyContact = true), - knownUniqueId = uniqueId, - knownVersionTag = versionTag, - ) + val result = try { + contactsProvider.writeEmergencyContact(uniqueId, isEmergencyContact, versionTag) } catch (e: CancellationException) { throw e } catch (e: ForbiddenException) { throw e } catch (e: Exception) { - Logger.w(e, TAG) { "setEmergencyContact failed for $uniqueId" } + Logger.w(e, TAG) { "writeEmergencyFlag($isEmergencyContact) failed for $uniqueId" } return null } + val body = (result as? ContactWriteResult.Ok)?.body ?: return null _contacts.update { current -> val idx = current.indexOfFirst { it.uniqueId == uniqueId } @@ -307,12 +312,12 @@ class ContactRepository( val existing = current[idx] current.toMutableList().apply { this[idx] = existing.copy( - content = existing.content.copy(isEmergencyContact = true), - versionTag = response.versionTag, + content = existing.content.copy(isEmergencyContact = isEmergencyContact), + versionTag = body.versionTag, ) } } - return response + return body } /** diff --git a/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactRequests.kt b/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactRequests.kt index 3976deb38..b8d62b6bf 100644 --- a/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactRequests.kt +++ b/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactRequests.kt @@ -20,6 +20,24 @@ data class UpdateContactRequest( @Serializable(with = UuidSerializer::class) val versionTag: Uuid, ) +/** + * A content delta carrying only the emergency-contact flag, with the field **always** emitted (no + * default, so it serializes for both `true` and `false`). [ContactContent.isEmergencyContact] is + * `@EncodeDefault(NEVER)` and so omits a `false`, which can't express a *clear*; this dedicated + * shape can — it serializes to `{"isEmergencyContact":true|false}`, a normal partial-content merge. + */ +@Serializable +data class EmergencyContactDelta( + val isEmergencyContact: Boolean, +) + +/** PUT /api/v2/contacts/{uniqueId} body that sets OR clears only the emergency-contact flag. */ +@Serializable +data class SetEmergencyContactRequest( + val content: EmergencyContactDelta, + @Serializable(with = UuidSerializer::class) val versionTag: Uuid, +) + /** * Body for both per-app app-data PUTs — the inline tier (`/app-data`) and the bulk tier * (`/app-ext-data`) share the identical shape. The server stamps the app's id from the auth token, diff --git a/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactsProvider.kt b/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactsProvider.kt index 47daf43b5..f2620ccaa 100644 --- a/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactsProvider.kt +++ b/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactsProvider.kt @@ -117,6 +117,35 @@ class ContactsProvider( return toWriteResult(response, allowNotFound = true) } + /** + * PUT /api/v2/contacts/{uniqueId} — sets ([isEmergencyContact] = true) or clears (= false) ONLY + * the emergency-contact flag via [EmergencyContactDelta], which always emits the bool (unlike + * [ContactContent], whose `@EncodeDefault(NEVER)` omits a `false` and so can't express a clear). + * Version-gated with the same bounded merge-and-retry as the image writes: on 409 it takes the + * authoritative tag and resends. Returns [ContactWriteResult.NotFound] if there's no such contact. + */ + suspend fun writeEmergencyContact( + uniqueId: Uuid, + isEmergencyContact: Boolean, + versionTag: Uuid, + maxAttempts: Int = 3, + ): ContactWriteResult { + require(maxAttempts >= 1) { "maxAttempts must be >= 1" } + + return retryVersionGated(versionTag, maxAttempts) { tag -> + val creds = requireCreds() + val response = encryptedPutJson( + url = apiUrl(creds.domain, "$BASE/$uniqueId"), + token = creds.accessToken, + jsonBody = OdinSystemSerializer.serialize( + SetEmergencyContactRequest(EmergencyContactDelta(isEmergencyContact), tag), + ), + secret = creds.secret, + ) + toWriteResult(response, allowNotFound = true) + } + } + // ------------------------------------------------------------ // DELETE // ------------------------------------------------------------ diff --git a/homebase-api/src/jvmTest/kotlin/id/homebase/api/client/contacts/ContactRepositoryTest.kt b/homebase-api/src/jvmTest/kotlin/id/homebase/api/client/contacts/ContactRepositoryTest.kt index cc174276c..cb47d4b29 100644 --- a/homebase-api/src/jvmTest/kotlin/id/homebase/api/client/contacts/ContactRepositoryTest.kt +++ b/homebase-api/src/jvmTest/kotlin/id/homebase/api/client/contacts/ContactRepositoryTest.kt @@ -355,6 +355,35 @@ class ContactRepositoryTest { assertEquals(listOf(uid), repo.emergencyContacts.value.map { it.uniqueId }) } + @Test + fun clearEmergencyContact_unflagsOptimisticallyPreservingOtherFields() = runTest { + val cm = credentialsManager() + val eventBus = EventBus() + val uid = Uuid.parse("11111111-1111-1111-1111-111111111111") + val repo = repo(cm, memoryDb(), eventBus, writeEngine(uid)) + advanceUntilIdle() + + // An existing emergency contact arrives. + val content = ContactContent( + name = ContactName(displayName = "Sam"), + phone = ContactPhone(number = "+15550100"), + isEmergencyContact = true, + ) + eventBus.emit(batch(contactFile(uid, content))) + advanceUntilIdle() + assertEquals(listOf(uid), repo.emergencyContacts.value.map { it.uniqueId }) + + val response = repo.clearEmergencyContact(uid, versionTag = tag) + advanceUntilIdle() + assertNotNull(response) + + val after = repo.contacts.value.single() + assertFalse(after.content.isEmergencyContact, "flag must be cleared") + assertEquals("Sam", after.content.name?.displayName, "other fields must be preserved") + assertEquals("+15550100", after.content.phone?.number, "other fields must be preserved") + assertTrue(repo.emergencyContacts.value.isEmpty()) + } + // ------------------------------------------------------------ // loadAll (real DB) // ------------------------------------------------------------ diff --git a/homebase-common/src/commonMain/composeResources/values/strings.xml b/homebase-common/src/commonMain/composeResources/values/strings.xml index 0d60a0667..7334336d4 100644 --- a/homebase-common/src/commonMain/composeResources/values/strings.xml +++ b/homebase-common/src/commonMain/composeResources/values/strings.xml @@ -1382,6 +1382,11 @@ Recent media No recent media yet Contact details + Mark as emergency contact + Remove as emergency contact + Emergency contact + Marked as emergency contact + Removed as emergency contact Bio Social Location diff --git a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailScreen.kt b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailScreen.kt index 2cd8cacc7..91f6a9533 100644 --- a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailScreen.kt +++ b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailScreen.kt @@ -19,6 +19,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.outlined.Message +import androidx.compose.material.icons.outlined.ContactEmergency import androidx.compose.material.icons.outlined.Edit import androidx.compose.material.icons.outlined.PersonAddAlt1 import androidx.compose.material3.AlertDialog @@ -62,6 +63,9 @@ import id.homebase.resources.cancel import id.homebase.resources.contactbook_action_blocked import id.homebase.resources.contactbook_action_sync_started import id.homebase.resources.contactbook_action_disconnected +import id.homebase.resources.contactbook_action_emergency_removed +import id.homebase.resources.contactbook_action_emergency_set +import id.homebase.resources.contactbook_detail_emergency_badge import id.homebase.resources.contactbook_action_unblocked import id.homebase.resources.contactbook_connected import id.homebase.resources.contactbook_detail_block @@ -112,6 +116,8 @@ fun ContactDetailScreen( val msgUnblocked = stringResource(MR.string.contactbook_action_unblocked) val msgDisconnected = stringResource(MR.string.contactbook_action_disconnected) val msgSyncStarted = stringResource(MR.string.contactbook_action_sync_started) + val msgEmergencySet = stringResource(MR.string.contactbook_action_emergency_set) + val msgEmergencyRemoved = stringResource(MR.string.contactbook_action_emergency_removed) LaunchedEffect(Unit) { viewModel.events.collect { event -> @@ -133,6 +139,10 @@ fun ContactDetailScreen( ContactDetailEvent.Unblocked -> snackbarHostState.showSnackbar(msgUnblocked) ContactDetailEvent.Disconnected -> snackbarHostState.showSnackbar(msgDisconnected) ContactDetailEvent.SyncStarted -> snackbarHostState.showSnackbar(msgSyncStarted) + ContactDetailEvent.EmergencyContactSet -> + snackbarHostState.showSnackbar(msgEmergencySet) + ContactDetailEvent.EmergencyContactRemoved -> + snackbarHostState.showSnackbar(msgEmergencyRemoved) } } } @@ -351,6 +361,26 @@ private fun DetailHeader( ) } + // Emergency-contact indicator — visible whenever this contact is one of our emergency + // contacts (independent of connection state). + if (entry.isEmergencyContact) { + Spacer(modifier = Modifier.height(8.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Outlined.ContactEmergency, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(16.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = stringResource(MR.string.contactbook_detail_emergency_badge), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.error, + ) + } + } + when { connected -> { Spacer(modifier = Modifier.height(12.dp)) diff --git a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailSections.kt b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailSections.kt index 4abfe6073..a2f1d583e 100644 --- a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailSections.kt +++ b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailSections.kt @@ -22,6 +22,7 @@ import androidx.compose.material.icons.outlined.AlternateEmail import androidx.compose.material.icons.outlined.Block import androidx.compose.material.icons.outlined.Cake import androidx.compose.material.icons.outlined.Call +import androidx.compose.material.icons.outlined.ContactEmergency import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.Email import androidx.compose.material.icons.outlined.LocationOn @@ -60,6 +61,8 @@ import id.homebase.resources.contactbook_detail_circles_connect import id.homebase.resources.contactbook_detail_circles_empty import id.homebase.resources.contactbook_detail_contact_details import id.homebase.resources.contactbook_detail_location +import id.homebase.resources.contactbook_detail_make_emergency +import id.homebase.resources.contactbook_detail_remove_emergency import id.homebase.resources.contactbook_edit_birthday import id.homebase.resources.contactbook_edit_email import id.homebase.resources.contactbook_edit_given_name @@ -349,6 +352,23 @@ fun ManagementSection( ) { onAction(ContactDetailAction.SyncClicked) } } + // Emergency-contact toggle — only for a synced Homebase identity. Marking notifies the contact + // and sets the flag; removing clears it (a dedicated delta write, since the normal content merge + // can't express a clear). + if (uiState.hasOdinId && uiState.entry?.versionTag != null) { + if (uiState.entry?.isEmergencyContact == true) { + ManagementAction( + Icons.Outlined.ContactEmergency, + stringResource(MR.string.contactbook_detail_remove_emergency), + ) { onAction(ContactDetailAction.RemoveEmergencyContactClicked) } + } else { + ManagementAction( + Icons.Outlined.ContactEmergency, + stringResource(MR.string.contactbook_detail_make_emergency), + ) { onAction(ContactDetailAction.MakeEmergencyContactClicked) } + } + } + // Danger zone — set apart with a divider so it's clearly separated from the rest. Spacer(modifier = Modifier.height(16.dp)) HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) diff --git a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailUiState.kt b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailUiState.kt index fda349dfe..93c288d54 100644 --- a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailUiState.kt +++ b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailUiState.kt @@ -42,6 +42,8 @@ data class ContactDetailUiState( sealed interface ContactDetailAction { data object MessageClicked : ContactDetailAction data object SyncClicked : ContactDetailAction + data object MakeEmergencyContactClicked : ContactDetailAction + data object RemoveEmergencyContactClicked : ContactDetailAction data object EditClicked : ContactDetailAction data class SaveContact(val draft: ContactDraft, val photo: io.github.vinceglb.filekit.PlatformFile?) : ContactDetailAction @@ -81,4 +83,8 @@ sealed interface ContactDetailEvent { data object Disconnected : ContactDetailEvent /** Best-effort profile sync was requested; the enriched contact lands later via drive sync. */ data object SyncStarted : ContactDetailEvent + /** The contact was marked as an emergency contact (and the designation sent). */ + data object EmergencyContactSet : ContactDetailEvent + /** The contact was removed as an emergency contact. */ + data object EmergencyContactRemoved : ContactDetailEvent } diff --git a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailViewModel.kt b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailViewModel.kt index 657831d9e..01a7f4e37 100644 --- a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailViewModel.kt +++ b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailViewModel.kt @@ -156,6 +156,8 @@ class ContactDetailViewModel( when (action) { ContactDetailAction.MessageClicked -> handleMessage() ContactDetailAction.SyncClicked -> handleSync() + ContactDetailAction.MakeEmergencyContactClicked -> handleMakeEmergencyContact() + ContactDetailAction.RemoveEmergencyContactClicked -> handleRemoveEmergencyContact() ContactDetailAction.EditClicked -> _uiState.update { it.copy(editOpen = true) } ContactDetailAction.CloseEdit -> _uiState.update { it.copy(editOpen = false) } is ContactDetailAction.SaveContact -> handleSave(action) @@ -193,6 +195,64 @@ class ContactDetailViewModel( } } + /** + * Marks this contact as one of our emergency contacts: sets the owner-only flag on our own + * contact record ([ContactRepository.setEmergencyContact]) and then notifies the contact by + * posting an EmergencyContactDesignated status into their 1:1. The notification is best-effort + * (the local flag is the source of truth); a failed flag write reports an error and skips the + * notification. Needs a synced contact (a versionTag) and an odinId to notify. + */ + private fun handleMakeEmergencyContact() { + val entry = _uiState.value.entry ?: return + val versionTag = entry.versionTag ?: return + if (entry.isEmergencyContact) return + val recipient = entry.odinId?.ifBlank { null } + ?.let { runCatching { OdinId(it) }.getOrNull() } ?: return + _uiState.update { it.copy(actionInProgress = true) } + viewModelScope.launch { + try { + val response = contactRepository.setEmergencyContact(entry.uniqueId, versionTag) + if (response == null) { + _events.tryEmit(ContactDetailEvent.Error) + return@launch + } + // Best-effort notify; the flag is already set locally regardless of this result. + conversationService.sendEmergencyContactDesignation(recipient) + _events.tryEmit(ContactDetailEvent.EmergencyContactSet) + } catch (e: ForbiddenException) { + _events.tryEmit(ContactDetailEvent.Forbidden) + } finally { + _uiState.update { it.copy(actionInProgress = false) } + } + } + } + + /** + * Removes this contact as an emergency contact: clears the owner-only flag on our own record + * ([ContactRepository.clearEmergencyContact]). Local-only — we don't notify the contact (the + * "designation" status is one-way; removal is a private bookkeeping change). Needs a synced + * contact (a versionTag). + */ + private fun handleRemoveEmergencyContact() { + val entry = _uiState.value.entry ?: return + val versionTag = entry.versionTag ?: return + if (!entry.isEmergencyContact) return + _uiState.update { it.copy(actionInProgress = true) } + viewModelScope.launch { + try { + val response = contactRepository.clearEmergencyContact(entry.uniqueId, versionTag) + _events.tryEmit( + if (response != null) ContactDetailEvent.EmergencyContactRemoved + else ContactDetailEvent.Error + ) + } catch (e: ForbiddenException) { + _events.tryEmit(ContactDetailEvent.Forbidden) + } finally { + _uiState.update { it.copy(actionInProgress = false) } + } + } + } + /** * Best-effort server-side enrichment from the identity's public profile. The endpoint is * fire-and-forget (202 Accepted) and the enriched contact lands later via drive sync, so we diff --git a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/model/ContactBookEntry.kt b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/model/ContactBookEntry.kt index b335455c3..8b4332f8c 100644 --- a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/model/ContactBookEntry.kt +++ b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/model/ContactBookEntry.kt @@ -57,6 +57,8 @@ data class ContactBookEntry( val shortBio: String? = null, /** Known social/gaming handles in render order, resolved from [ContactContent.social]. */ val socialHandles: List> = emptyList(), + /** Owner-only flag: this contact is one of our emergency contacts. */ + val isEmergencyContact: Boolean = false, val source: String? = null, /** Pending (optimistic, not yet confirmed by the drive). */ val isPending: Boolean = false, @@ -176,6 +178,7 @@ fun Contact.toContactBookEntry(): ContactBookEntry? { status = content.status, shortBio = content.shortBio, socialHandles = content.socialHandles(), + isEmergencyContact = content.isEmergencyContact, source = content.source, driveId = image?.driveId, keyHeader = image?.keyHeader, From 008331c501361b0b835c9801b0a49225d9d6424e Mon Sep 17 00:00:00 2001 From: toddmitchell Date: Tue, 23 Jun 2026 12:46:45 -0500 Subject: [PATCH 17/23] Chat: open peer (not owner) contact detail from conversation header ShowConversationSettings picked the peer via participants.firstOrNull(), but participants is the raw recipient list that includes the owner. When the owner sorted first, tapping the 1:1 conversation header opened the owner's own contact detail instead of the person being chatted with. Exclude the owner before picking the peer, mirroring handleShowContactInfo. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../chat/conversationlist/ConversationLifecycleHandler.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homebase-chat/src/commonMain/kotlin/id/homebase/chat/conversationlist/ConversationLifecycleHandler.kt b/homebase-chat/src/commonMain/kotlin/id/homebase/chat/conversationlist/ConversationLifecycleHandler.kt index 3a067cc02..521397675 100644 --- a/homebase-chat/src/commonMain/kotlin/id/homebase/chat/conversationlist/ConversationLifecycleHandler.kt +++ b/homebase-chat/src/commonMain/kotlin/id/homebase/chat/conversationlist/ConversationLifecycleHandler.kt @@ -165,7 +165,11 @@ internal class ConversationLifecycleHandler( // A 1:1 conversation's "info" is the contact — open the full contact-detail screen // (keyed by the peer's odinId) instead of a separate conversation-overview screen. // Groups go to group settings; note-to-self has no contact, so it keeps the settings screen. - val peerOdinId = conversation.participants.firstOrNull()?.domainName + // `participants` is the raw recipient list and includes the owner, so the peer is the + // first participant that is NOT the current user — otherwise tapping the header could + // open the owner's own contact detail instead of the person they're chatting with. + val ownerOdinId = uiState.value.ownerSession?.odinId + val peerOdinId = conversation.participants.firstOrNull { it != ownerOdinId }?.domainName if (conversation.isGroupConversation) { uiState.update { it.copy( From 450baf425c3e057c858fc8965dcfbbf8d348b533 Mon Sep 17 00:00:00 2001 From: toddmitchell Date: Tue, 23 Jun 2026 13:06:44 -0500 Subject: [PATCH 18/23] Contacts: store emergency flag as app-data; location "who can locate you" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the emergency-contact flag off the shared ContactContent into this app's private inline app-data slot (keyed by AppConfig.APP_ID): - New core EmergencyContact.kt: ChatContactAppData blob, Contact.isEmergencyContact(), ContactRepository.setEmergencyContact/clearEmergencyContact (clear just drops the slot — no merge-can't-express-false problem), and a derived emergencyContacts flow. - Removed ContactContent.isEmergencyContact + the dedicated delta write, the api ContactRepository emergency methods/flow, and their tests; added a core read-path test. ContactBookEntry/ContactDetail/AppModule repointed to the app-data extension. Location dashboard "who can locate you" now lists contacts marked as emergency contacts (reactive), replacing the Emergency Location Access circle members; keeps the owner-console manage link. Excludes the logged-in identity (you're not your own emergency contact), and the contact-detail mark/remove action is hidden for your own self-contact (isSelf) so it can't be flagged in the first place. Also bundles in-progress contact address/label fields (ContactContent location/phone/ email labels, ContactSaveHelper, ContactBookEntry). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../api/client/contacts/ContactContent.kt | 26 +++---- .../api/client/contacts/ContactRepository.kt | 64 ---------------- .../api/client/contacts/ContactRequests.kt | 18 ----- .../api/client/contacts/ContactsProvider.kt | 29 -------- .../ContactContentSerializationTest.kt | 41 +++++++---- .../client/contacts/ContactRepositoryTest.kt | 58 --------------- .../homebase/chat/services/StatusMessage.kt | 3 +- .../core/contactbook/EmergencyContact.kt | 73 +++++++++++++++++++ .../kotlin/id/homebase/core/di/AppModule.kt | 7 +- .../screens/contactbook/ContactSaveHelper.kt | 17 ++++- .../detail/ContactDetailSections.kt | 8 +- .../detail/ContactDetailUiState.kt | 2 + .../detail/ContactDetailViewModel.kt | 11 ++- .../contactbook/model/ContactBookEntry.kt | 41 +++++++++-- .../location/LocationDashboardContent.kt | 16 +--- .../ui/screens/location/LocationUiState.kt | 9 ++- .../ui/screens/location/LocationViewModel.kt | 62 ++++++++++------ .../core/contactbook/EmergencyContactTest.kt | 52 +++++++++++++ 18 files changed, 285 insertions(+), 252 deletions(-) create mode 100644 homebase-core/src/commonMain/kotlin/id/homebase/core/contactbook/EmergencyContact.kt create mode 100644 homebase-core/src/jvmTest/kotlin/id/homebase/core/contactbook/EmergencyContactTest.kt diff --git a/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactContent.kt b/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactContent.kt index 55b65504b..54d5b4991 100644 --- a/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactContent.kt +++ b/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactContent.kt @@ -1,9 +1,5 @@ -@file:OptIn(ExperimentalSerializationApi::class) - package id.homebase.api.client.contacts -import kotlinx.serialization.EncodeDefault -import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable /** @@ -48,15 +44,6 @@ data class ContactContent( * networks with [socialHandles] / [ContactSocialNetwork]. */ val social: Map? = null, - /** - * Owner-only flag — a contact either is an emergency contact or isn't, so this is a plain - * non-null [Boolean]. [EncodeDefault.Mode.NEVER] keeps the merge contract intact: the `false` - * default is omitted (so a write doesn't clobber the stored value), while `true` is emitted. - * Note this can set the flag but not clear it through the merge — a `false` reads as "leave - * alone"; clearing needs a dedicated write. - */ - @EncodeDefault(EncodeDefault.Mode.NEVER) - val isEmergencyContact: Boolean = false, /** * Inline per-app data (the ≤200-byte tier), keyed by appId as a canonical lowercase hyphenated * UUID string. Populated by the server on read (it rides in the contact content, so the contacts @@ -75,19 +62,32 @@ data class ContactName( val surname: String? = null, ) +/** + * A postal address. Mirrors the server's `ContactLocation` (odin-js `AddressFields`): the wire keys + * are camelCase — `addressLine1`/`addressLine2` (odin-js calls them `address1`/`address2`). Every + * field is optional; [label] is a free-form name for the address such as "Home" / "Work". + */ @Serializable data class ContactLocation( + val label: String? = null, + val addressLine1: String? = null, + val addressLine2: String? = null, + val postcode: String? = null, val city: String? = null, val country: String? = null, ) @Serializable data class ContactPhone( + /** Free-form name for this number, e.g. "Mobile" / "Work" (odin-js `PhoneFields.label`). */ + val label: String? = null, val number: String? = null, ) @Serializable data class ContactEmail( + /** Free-form name for this email, e.g. "Personal" / "Work" (odin-js `EmailFields.label`). */ + val label: String? = null, val email: String? = null, ) diff --git a/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactRepository.kt b/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactRepository.kt index fe621fb98..7213496b8 100644 --- a/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactRepository.kt +++ b/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactRepository.kt @@ -20,11 +20,8 @@ import id.homebase.api.sync.database.QueryBatch import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.drop -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex @@ -59,14 +56,6 @@ class ContactRepository( /** Live contacts, freshest-row-per-uniqueId, in drive order (NewestFirst). Consumers sort. */ val contacts: StateFlow> = _contacts.asStateFlow() - /** - * Live subset of [contacts] flagged as emergency contacts. Derived from [contacts], so it tracks - * the same optimistic writes and sync reconciliation; consumers sort. - */ - val emergencyContacts: StateFlow> = _contacts - .map { list -> list.filter { it.content.isEmergencyContact } } - .stateIn(scope, SharingStarted.Eagerly, emptyList()) - private val _isLoaded = MutableStateFlow(false) val isLoaded: StateFlow = _isLoaded.asStateFlow() @@ -267,59 +256,6 @@ class ContactRepository( return response } - /** - * Marks this contact as one of our emergency contacts — a minimal-delta, version-gated write - * that sends only the flag (via [ContactsProvider.writeEmergencyContact]) so the server's field - * merge leaves every other stored field untouched. Optimistically copies the flag onto the live - * contact's existing content (unlike [save], which replaces it wholesale) and adopts the returned - * versionTag; the authoritative row lands later via drive sync. - * - * Returns the new id/versionTag, or null on a generic failure / no-such-contact. Rethrows - * [ForbiddenException] (403). See [clearEmergencyContact] to remove the flag. - */ - suspend fun setEmergencyContact(uniqueId: Uuid, versionTag: Uuid): ContactWriteResponse? = - writeEmergencyFlag(uniqueId, isEmergencyContact = true, versionTag = versionTag) - - /** - * Removes this contact as an emergency contact. Same minimal-delta, version-gated path as - * [setEmergencyContact] but explicitly clears the flag — the dedicated [EmergencyContactDelta] - * write can express a `false`, which the normal [ContactContent] merge (which omits a `false`) - * can't. Rethrows [ForbiddenException] (403). - */ - suspend fun clearEmergencyContact(uniqueId: Uuid, versionTag: Uuid): ContactWriteResponse? = - writeEmergencyFlag(uniqueId, isEmergencyContact = false, versionTag = versionTag) - - private suspend fun writeEmergencyFlag( - uniqueId: Uuid, - isEmergencyContact: Boolean, - versionTag: Uuid, - ): ContactWriteResponse? { - val result = try { - contactsProvider.writeEmergencyContact(uniqueId, isEmergencyContact, versionTag) - } catch (e: CancellationException) { - throw e - } catch (e: ForbiddenException) { - throw e - } catch (e: Exception) { - Logger.w(e, TAG) { "writeEmergencyFlag($isEmergencyContact) failed for $uniqueId" } - return null - } - val body = (result as? ContactWriteResult.Ok)?.body ?: return null - - _contacts.update { current -> - val idx = current.indexOfFirst { it.uniqueId == uniqueId } - if (idx < 0) return@update current - val existing = current[idx] - current.toMutableList().apply { - this[idx] = existing.copy( - content = existing.content.copy(isEmergencyContact = isEmergencyContact), - versionTag = body.versionTag, - ) - } - } - return body - } - /** * Soft-delete. Optimistically removes the contact; on a generic failure it reloads to restore * truth. Returns true on success (or already-gone). Rethrows [ForbiddenException] (403). diff --git a/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactRequests.kt b/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactRequests.kt index b8d62b6bf..3976deb38 100644 --- a/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactRequests.kt +++ b/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactRequests.kt @@ -20,24 +20,6 @@ data class UpdateContactRequest( @Serializable(with = UuidSerializer::class) val versionTag: Uuid, ) -/** - * A content delta carrying only the emergency-contact flag, with the field **always** emitted (no - * default, so it serializes for both `true` and `false`). [ContactContent.isEmergencyContact] is - * `@EncodeDefault(NEVER)` and so omits a `false`, which can't express a *clear*; this dedicated - * shape can — it serializes to `{"isEmergencyContact":true|false}`, a normal partial-content merge. - */ -@Serializable -data class EmergencyContactDelta( - val isEmergencyContact: Boolean, -) - -/** PUT /api/v2/contacts/{uniqueId} body that sets OR clears only the emergency-contact flag. */ -@Serializable -data class SetEmergencyContactRequest( - val content: EmergencyContactDelta, - @Serializable(with = UuidSerializer::class) val versionTag: Uuid, -) - /** * Body for both per-app app-data PUTs — the inline tier (`/app-data`) and the bulk tier * (`/app-ext-data`) share the identical shape. The server stamps the app's id from the auth token, diff --git a/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactsProvider.kt b/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactsProvider.kt index f2620ccaa..47daf43b5 100644 --- a/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactsProvider.kt +++ b/homebase-api/src/commonMain/kotlin/id/homebase/api/client/contacts/ContactsProvider.kt @@ -117,35 +117,6 @@ class ContactsProvider( return toWriteResult(response, allowNotFound = true) } - /** - * PUT /api/v2/contacts/{uniqueId} — sets ([isEmergencyContact] = true) or clears (= false) ONLY - * the emergency-contact flag via [EmergencyContactDelta], which always emits the bool (unlike - * [ContactContent], whose `@EncodeDefault(NEVER)` omits a `false` and so can't express a clear). - * Version-gated with the same bounded merge-and-retry as the image writes: on 409 it takes the - * authoritative tag and resends. Returns [ContactWriteResult.NotFound] if there's no such contact. - */ - suspend fun writeEmergencyContact( - uniqueId: Uuid, - isEmergencyContact: Boolean, - versionTag: Uuid, - maxAttempts: Int = 3, - ): ContactWriteResult { - require(maxAttempts >= 1) { "maxAttempts must be >= 1" } - - return retryVersionGated(versionTag, maxAttempts) { tag -> - val creds = requireCreds() - val response = encryptedPutJson( - url = apiUrl(creds.domain, "$BASE/$uniqueId"), - token = creds.accessToken, - jsonBody = OdinSystemSerializer.serialize( - SetEmergencyContactRequest(EmergencyContactDelta(isEmergencyContact), tag), - ), - secret = creds.secret, - ) - toWriteResult(response, allowNotFound = true) - } - } - // ------------------------------------------------------------ // DELETE // ------------------------------------------------------------ diff --git a/homebase-api/src/jvmTest/kotlin/id/homebase/api/client/contacts/ContactContentSerializationTest.kt b/homebase-api/src/jvmTest/kotlin/id/homebase/api/client/contacts/ContactContentSerializationTest.kt index 6b2445131..523d4e986 100644 --- a/homebase-api/src/jvmTest/kotlin/id/homebase/api/client/contacts/ContactContentSerializationTest.kt +++ b/homebase-api/src/jvmTest/kotlin/id/homebase/api/client/contacts/ContactContentSerializationTest.kt @@ -53,6 +53,31 @@ class ContactContentSerializationTest { assertEquals(original, decoded) } + @Test + fun fullAddress_usesCamelCaseWireKeysAndRoundTrips() { + val original = ContactContent( + location = ContactLocation( + label = "Home", + addressLine1 = "123 Main St", + addressLine2 = "Apt 4", + postcode = "12345", + city = "Springfield", + country = "US", + ), + phone = ContactPhone(label = "Mobile", number = "+1-555-0100"), + email = ContactEmail(label = "Personal", email = "sam@dotyou.cloud"), + ) + + val json = OdinSystemSerializer.serialize(original) + // Wire keys must be the server's camelCase form (odin-js calls these address1/address2). + assertTrue(json.contains("\"addressLine1\""), json) + assertTrue(json.contains("\"addressLine2\""), json) + assertTrue(json.contains("\"postcode\""), json) + assertTrue(json.contains("\"label\""), json) + + assertEquals(original, OdinSystemSerializer.deserialize(json)) + } + @Test fun sourceRoundTripsAndIsOmittedWhenNull() { assertEquals( @@ -63,22 +88,6 @@ class ContactContentSerializationTest { assertFalse(OdinSystemSerializer.serialize(ContactContent(odinId = "x")).contains("source")) } - @Test - fun isEmergencyContact_omittedWhenFalse_emittedWhenTrue() { - // @EncodeDefault(NEVER): the false default is omitted so an UPDATE doesn't clobber a stored - // flag, while an explicit true is written. Reading an absent flag decodes back to false. - assertFalse( - OdinSystemSerializer.serialize(ContactContent(odinId = "x")).contains("isEmergencyContact"), - ) - assertEquals( - """{"isEmergencyContact":true}""", - OdinSystemSerializer.serialize(ContactContent(isEmergencyContact = true)), - ) - assertFalse( - OdinSystemSerializer.deserialize("""{"odinId":"x"}""").isEmergencyContact, - ) - } - @Test fun createRequestWrapsContentUnderContentKey() { val json = OdinSystemSerializer.serialize( diff --git a/homebase-api/src/jvmTest/kotlin/id/homebase/api/client/contacts/ContactRepositoryTest.kt b/homebase-api/src/jvmTest/kotlin/id/homebase/api/client/contacts/ContactRepositoryTest.kt index cb47d4b29..6e1a1aa8b 100644 --- a/homebase-api/src/jvmTest/kotlin/id/homebase/api/client/contacts/ContactRepositoryTest.kt +++ b/homebase-api/src/jvmTest/kotlin/id/homebase/api/client/contacts/ContactRepositoryTest.kt @@ -326,64 +326,6 @@ class ContactRepositoryTest { assertEquals(1, repo.contacts.value.size, "sync must lift the guard for md5(odinId)") } - @Test - fun setEmergencyContact_flipsFlagOptimisticallyPreservingOtherFields() = runTest { - val cm = credentialsManager() - val eventBus = EventBus() - val uid = Uuid.parse("11111111-1111-1111-1111-111111111111") - val repo = repo(cm, memoryDb(), eventBus, writeEngine(uid)) - advanceUntilIdle() - - // A fully-populated contact arrives, not yet an emergency contact. - val content = ContactContent( - name = ContactName(displayName = "Sam"), - phone = ContactPhone(number = "+15550100"), - isEmergencyContact = false, - ) - eventBus.emit(batch(contactFile(uid, content))) - advanceUntilIdle() - assertTrue(repo.emergencyContacts.value.isEmpty()) - - val response = repo.setEmergencyContact(uid, versionTag = tag) - advanceUntilIdle() - assertNotNull(response) - - val after = repo.contacts.value.single() - assertTrue(after.content.isEmergencyContact, "flag must be set") - assertEquals("Sam", after.content.name?.displayName, "other fields must be preserved") - assertEquals("+15550100", after.content.phone?.number, "other fields must be preserved") - assertEquals(listOf(uid), repo.emergencyContacts.value.map { it.uniqueId }) - } - - @Test - fun clearEmergencyContact_unflagsOptimisticallyPreservingOtherFields() = runTest { - val cm = credentialsManager() - val eventBus = EventBus() - val uid = Uuid.parse("11111111-1111-1111-1111-111111111111") - val repo = repo(cm, memoryDb(), eventBus, writeEngine(uid)) - advanceUntilIdle() - - // An existing emergency contact arrives. - val content = ContactContent( - name = ContactName(displayName = "Sam"), - phone = ContactPhone(number = "+15550100"), - isEmergencyContact = true, - ) - eventBus.emit(batch(contactFile(uid, content))) - advanceUntilIdle() - assertEquals(listOf(uid), repo.emergencyContacts.value.map { it.uniqueId }) - - val response = repo.clearEmergencyContact(uid, versionTag = tag) - advanceUntilIdle() - assertNotNull(response) - - val after = repo.contacts.value.single() - assertFalse(after.content.isEmergencyContact, "flag must be cleared") - assertEquals("Sam", after.content.name?.displayName, "other fields must be preserved") - assertEquals("+15550100", after.content.phone?.number, "other fields must be preserved") - assertTrue(repo.emergencyContacts.value.isEmpty()) - } - // ------------------------------------------------------------ // loadAll (real DB) // ------------------------------------------------------------ diff --git a/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/StatusMessage.kt b/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/StatusMessage.kt index 4e1dce1d1..620508de1 100644 --- a/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/StatusMessage.kt +++ b/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/StatusMessage.kt @@ -40,7 +40,6 @@ enum class StatusMessage() { /** Sent by a user (the message's `originalAuthor`) when they designate the recipient as one of * their emergency contacts. Renders a system line ("X added you as an emergency contact") AND * drives a receive-side side-effect (wired in AppModule) that marks the SENDER as an emergency - * contact on the recipient's own contact drive — see - * [id.homebase.api.client.contacts.ContactRepository.setEmergencyContact]. */ + * contact in the recipient's own contact app-data (core `setEmergencyContact`). */ EmergencyContactDesignated, } diff --git a/homebase-core/src/commonMain/kotlin/id/homebase/core/contactbook/EmergencyContact.kt b/homebase-core/src/commonMain/kotlin/id/homebase/core/contactbook/EmergencyContact.kt new file mode 100644 index 000000000..8b7952207 --- /dev/null +++ b/homebase-core/src/commonMain/kotlin/id/homebase/core/contactbook/EmergencyContact.kt @@ -0,0 +1,73 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package id.homebase.core.contactbook + +import id.homebase.api.client.contacts.Contact +import id.homebase.api.client.contacts.ContactRepository +import id.homebase.api.client.contacts.ContactWriteResponse +import id.homebase.api.client.contacts.appDataFor +import id.homebase.api.serialization.OdinSystemSerializer +import id.homebase.core.config.AppConfig +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.serialization.Serializable +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +/** + * This chat app's private per-contact app-data blob, stored in the contact's inline app-data slot + * keyed by [AppConfig.APP_ID] — NOT on the shared [id.homebase.api.client.contacts.ContactContent]. + * + * Owner-only flags belong here so they can be set AND cleared independently of the contact's shared + * profile fields: a clear just removes the slot, which the ContactContent field merge can't express + * for a boolean (an omitted/`false` field reads as "leave alone"). Add new flags here (defaulting to + * their "unset" value) rather than minting new app-data slots; an all-default blob is dropped. + */ +@Serializable +data class ChatContactAppData( + val isEmergencyContact: Boolean = false, +) + +/** Whether this contact is one of our emergency contacts, read from our app-data slot. */ +fun Contact.isEmergencyContact(): Boolean = chatAppData()?.isEmergencyContact == true + +/** + * Live list of our emergency contacts, derived from [ContactRepository.contacts] via the app-data + * flag — so it tracks the same optimistic writes and sync reconciliation. Cold flow: collect it + * (e.g. `collectAsStateWithLifecycle`) or `stateIn` it yourself. Consumers sort. + */ +val ContactRepository.emergencyContacts: Flow> + get() = contacts.map { list -> list.filter { it.isEmergencyContact() } } + +private fun Contact.chatAppData(): ChatContactAppData? = + appDataFor(AppConfig.APP_ID)?.let { + runCatching { OdinSystemSerializer.deserialize(it) }.getOrNull() + } + +/** + * Marks the contact as an emergency contact in our app-data slot — a minimal-delta write that merges + * onto any existing blob ([ContactRepository.setAppData]). Returns the write response, or null on + * failure; rethrows the same exceptions as [ContactRepository.setAppData]. + */ +suspend fun ContactRepository.setEmergencyContact(uniqueId: Uuid, versionTag: Uuid): ContactWriteResponse? = + writeEmergencyFlag(uniqueId, versionTag, isEmergency = true) + +/** Clears the emergency-contact flag in our app-data slot (dropping the slot if it becomes empty). */ +suspend fun ContactRepository.clearEmergencyContact(uniqueId: Uuid, versionTag: Uuid): ContactWriteResponse? = + writeEmergencyFlag(uniqueId, versionTag, isEmergency = false) + +private suspend fun ContactRepository.writeEmergencyFlag( + uniqueId: Uuid, + versionTag: Uuid, + isEmergency: Boolean, +): ContactWriteResponse? { + val current = contacts.value.firstOrNull { it.uniqueId == uniqueId }?.chatAppData() + ?: ChatContactAppData() + val updated = current.copy(isEmergencyContact = isEmergency) + return if (updated == ChatContactAppData()) { + // All flags back to default — drop the whole slot rather than keep an empty blob. + deleteAppData(uniqueId, AppConfig.APP_ID, versionTag) + } else { + setAppData(uniqueId, AppConfig.APP_ID, OdinSystemSerializer.serialize(updated), versionTag) + } +} diff --git a/homebase-core/src/commonMain/kotlin/id/homebase/core/di/AppModule.kt b/homebase-core/src/commonMain/kotlin/id/homebase/core/di/AppModule.kt index f396dbca3..f11e0d1f8 100644 --- a/homebase-core/src/commonMain/kotlin/id/homebase/core/di/AppModule.kt +++ b/homebase-core/src/commonMain/kotlin/id/homebase/core/di/AppModule.kt @@ -74,6 +74,8 @@ import id.homebase.core.contactbook.ContactBookPreferences import id.homebase.api.client.ForbiddenException import id.homebase.api.client.contacts.ContactRepository import id.homebase.api.crypto.Md5 +import id.homebase.core.contactbook.isEmergencyContact +import id.homebase.core.contactbook.setEmergencyContact import id.homebase.core.ui.screens.contactbook.ContactBookViewModel import id.homebase.core.ui.screens.contactbook.detail.ContactDetailViewModel import id.homebase.core.ui.screens.contactbook.settings.ContactBookSettingsViewModel @@ -481,7 +483,7 @@ val appModule = module { // (or a manual mark) sets it once the row exists. contact == null -> contactRepository.sync(sender) // Already flagged — idempotent no-op (status messages can re-deliver). - contact.content.isEmergencyContact -> Unit + contact.isEmergencyContact() -> Unit versionTag != null -> contactRepository.setEmergencyContact(uniqueId, versionTag) else -> Unit @@ -776,8 +778,7 @@ val appModule = module { pointStore = get(), uploaderService = get(), deviceDirectory = get(), - connectionNetworkProvider = get(), - contactService = get(), + contactRepository = get(), credentialsManager = get(), tracker = get(), receiveStore = get(), diff --git a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/ContactSaveHelper.kt b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/ContactSaveHelper.kt index 0d06d2198..c006989b4 100644 --- a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/ContactSaveHelper.kt +++ b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/ContactSaveHelper.kt @@ -93,8 +93,21 @@ suspend fun saveContactDraft( surname = mergedSurname, ), source = editing?.source ?: ContactBookSource.MANUAL, - location = if (mergedCity != null || mergedCountry != null) { - ContactLocation(city = mergedCity, country = mergedCountry) + // The edit form only touches city/country; carry the rest of the address from the edited + // contact so the optimistic content matches what the per-leaf server merge syncs back (the + // omitted street/postcode/label fields are "leave alone", not "clear"). + location = if (mergedCity != null || mergedCountry != null || + !editing?.addressLine1.isNullOrBlank() || !editing?.addressLine2.isNullOrBlank() || + !editing?.postcode.isNullOrBlank() || !editing?.locationLabel.isNullOrBlank() + ) { + ContactLocation( + label = editing?.locationLabel?.ifBlank { null }, + addressLine1 = editing?.addressLine1?.ifBlank { null }, + addressLine2 = editing?.addressLine2?.ifBlank { null }, + postcode = editing?.postcode?.ifBlank { null }, + city = mergedCity, + country = mergedCountry, + ) } else null, phone = mergedPhone?.let { ContactPhone(it) }, email = mergedEmail?.let { ContactEmail(it) }, diff --git a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailSections.kt b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailSections.kt index a2f1d583e..7e772a5b0 100644 --- a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailSections.kt +++ b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailSections.kt @@ -266,7 +266,11 @@ fun ContactFieldsSection( entry.odinId?.takeIf { it.isNotBlank() }?.let { add(Triple(Icons.Outlined.AlternateEmail, lblId, it)) } entry.phone?.takeIf { it.isNotBlank() }?.let { add(Triple(Icons.Outlined.Call, lblPhone, it)) } entry.email?.takeIf { it.isNotBlank() }?.let { add(Triple(Icons.Outlined.Email, lblEmail, it)) } - entry.location?.takeIf { it.isNotBlank() }?.let { add(Triple(Icons.Outlined.LocationOn, lblLocation, it)) } + entry.location?.takeIf { it.isNotBlank() }?.let { + // Prefer the address's own label ("Home" / "Work") over the generic "Location". + val addressLabel = entry.locationLabel?.takeIf { l -> l.isNotBlank() } ?: lblLocation + add(Triple(Icons.Outlined.LocationOn, addressLabel, it)) + } entry.birthday?.takeIf { it.isNotBlank() }?.let { add(Triple(Icons.Outlined.Cake, lblBirthday, it)) } } if (fields.isEmpty()) { @@ -355,7 +359,7 @@ fun ManagementSection( // Emergency-contact toggle — only for a synced Homebase identity. Marking notifies the contact // and sets the flag; removing clears it (a dedicated delta write, since the normal content merge // can't express a clear). - if (uiState.hasOdinId && uiState.entry?.versionTag != null) { + if (uiState.hasOdinId && uiState.entry?.versionTag != null && !uiState.isSelf) { if (uiState.entry?.isEmergencyContact == true) { ManagementAction( Icons.Outlined.ContactEmergency, diff --git a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailUiState.kt b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailUiState.kt index 93c288d54..9583270ed 100644 --- a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailUiState.kt +++ b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailUiState.kt @@ -33,6 +33,8 @@ data class ContactDetailUiState( val confirm: ContactDetailConfirm? = null, /** A management action (delete/block/disconnect/unblock) is running — show a blocking spinner. */ val actionInProgress: Boolean = false, + /** True when this contact is the logged-in identity's own (self) contact. */ + val isSelf: Boolean = false, ) { val hasOdinId: Boolean get() = !entry?.odinId.isNullOrBlank() val isConnected: Boolean get() = connectionStatus == ConnectionStatus.Connected diff --git a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailViewModel.kt b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailViewModel.kt index 01a7f4e37..a3a68b263 100644 --- a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailViewModel.kt +++ b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailViewModel.kt @@ -8,6 +8,7 @@ import androidx.lifecycle.viewModelScope import androidx.navigation.toRoute import co.touchlab.kermit.Logger import id.homebase.api.client.ForbiddenException +import id.homebase.api.client.auth.CredentialsManager import id.homebase.api.client.auth.OwnerSessionRepository import id.homebase.api.client.connections.ConnectionNetworkProvider import id.homebase.api.common.OdinId @@ -18,6 +19,8 @@ import id.homebase.chat.services.convo.ConversationService import id.homebase.chat.services.convo.ConversationStream import id.homebase.chat.services.convo.contact.ConnectionService import id.homebase.api.client.contacts.ContactRepository +import id.homebase.core.contactbook.clearEmergencyContact +import id.homebase.core.contactbook.setEmergencyContact import id.homebase.core.config.AUTO_CONNECTIONS_CIRCLE_ID import id.homebase.core.config.CONFIRMED_CONNECTIONS_CIRCLE_ID import id.homebase.core.ui.navigation.Route @@ -53,6 +56,7 @@ class ContactDetailViewModel( private val connectionService: ConnectionService, private val connectionNetworkProvider: ConnectionNetworkProvider, private val ownerSessionRepository: OwnerSessionRepository, + private val credentialsManager: CredentialsManager, ) : ViewModel() { private val route = savedStateHandle.toRoute() @@ -72,6 +76,7 @@ class ContactDetailViewModel( viewModelScope.launch { contactRepository.ensureLoaded() } // Keep the entry + connection status live (an edit / block reflects immediately). viewModelScope.launch { + val selfDomain = runCatching { credentialsManager.getActiveDomain()?.domainName }.getOrNull() combine( contactRepository.contacts.map { list -> list.mapNotNull { it.toContactBookEntry() } }, connectionService.connections, @@ -82,6 +87,7 @@ class ContactDetailViewModel( val entry = contacts.find { it.uniqueId.toString() == route.uniqueId } ?: syntheticEntry() val domain = entry?.odinId + val isSelf = selfDomain != null && domain?.equals(selfDomain, ignoreCase = true) == true val status = domain?.let { d -> conn.map.entries.firstOrNull { it.key.domainName.equals(d, ignoreCase = true) } ?.value?.status @@ -106,6 +112,7 @@ class ContactDetailViewModel( connectionStatus = status, circles = circleNames, isLoading = false, + isSelf = isSelf, ) } } @@ -205,7 +212,7 @@ class ContactDetailViewModel( private fun handleMakeEmergencyContact() { val entry = _uiState.value.entry ?: return val versionTag = entry.versionTag ?: return - if (entry.isEmergencyContact) return + if (entry.isEmergencyContact || _uiState.value.isSelf) return val recipient = entry.odinId?.ifBlank { null } ?.let { runCatching { OdinId(it) }.getOrNull() } ?: return _uiState.update { it.copy(actionInProgress = true) } @@ -236,7 +243,7 @@ class ContactDetailViewModel( private fun handleRemoveEmergencyContact() { val entry = _uiState.value.entry ?: return val versionTag = entry.versionTag ?: return - if (!entry.isEmergencyContact) return + if (!entry.isEmergencyContact || _uiState.value.isSelf) return _uiState.update { it.copy(actionInProgress = true) } viewModelScope.launch { try { diff --git a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/model/ContactBookEntry.kt b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/model/ContactBookEntry.kt index 8b4332f8c..fb2e48cd0 100644 --- a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/model/ContactBookEntry.kt +++ b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/model/ContactBookEntry.kt @@ -14,6 +14,7 @@ import id.homebase.api.client.contacts.ContactPhone import id.homebase.api.client.contacts.ContactSocialNetwork import id.homebase.api.client.contacts.resolveDisplayName import id.homebase.api.client.contacts.socialHandles +import id.homebase.core.contactbook.isEmergencyContact import id.homebase.api.client.drives.files.PayloadDescriptor import id.homebase.api.client.drives.upload.EmbeddedThumb import id.homebase.core.image.HomebaseImageData @@ -48,6 +49,11 @@ data class ContactBookEntry( val surname: String? = null, val phone: String? = null, val email: String? = null, + /** Free-form name for the address, e.g. "Home" / "Work"; used as the field label when present. */ + val locationLabel: String? = null, + val addressLine1: String? = null, + val addressLine2: String? = null, + val postcode: String? = null, val city: String? = null, val country: String? = null, val birthday: String? = null, @@ -91,10 +97,18 @@ data class ContactBookEntry( /** Secondary line under the name in list rows. */ val subtitle: String? get() = odinId ?: phone ?: email + /** + * The full postal address formatted for display, one component per line: + * street lines, then "postcode city", then country. Null when nothing is set. + */ val location: String? - get() = listOfNotNull(city?.ifBlank { null }, country?.ifBlank { null }) - .joinToString(", ") - .ifBlank { null } + get() = listOfNotNull( + addressLine1?.ifBlank { null }, + addressLine2?.ifBlank { null }, + listOfNotNull(postcode?.ifBlank { null }, city?.ifBlank { null }) + .joinToString(" ").ifBlank { null }, + country?.ifBlank { null }, + ).joinToString("\n").ifBlank { null } fun matches(query: String): Boolean { if (query.isBlank()) return true @@ -139,8 +153,19 @@ data class ContactBookEntry( surname = surname?.ifBlank { null }, ), source = source, - location = if (city.isNullOrBlank() && country.isNullOrBlank()) null - else ContactLocation(city = city?.ifBlank { null }, country = country?.ifBlank { null }), + location = listOfNotNull( + locationLabel, addressLine1, addressLine2, postcode, city, country, + ).any { it.isNotBlank() }.let { hasAny -> + if (!hasAny) null + else ContactLocation( + label = locationLabel?.ifBlank { null }, + addressLine1 = addressLine1?.ifBlank { null }, + addressLine2 = addressLine2?.ifBlank { null }, + postcode = postcode?.ifBlank { null }, + city = city?.ifBlank { null }, + country = country?.ifBlank { null }, + ) + }, phone = phone?.ifBlank { null }?.let { ContactPhone(number = it) }, email = email?.ifBlank { null }?.let { ContactEmail(email = it) }, birthday = birthday?.ifBlank { null }?.let { ContactBirthday(date = it) }, @@ -172,13 +197,17 @@ fun Contact.toContactBookEntry(): ContactBookEntry? { surname = name?.surname, phone = content.phone?.number, email = content.email?.email, + locationLabel = content.location?.label, + addressLine1 = content.location?.addressLine1, + addressLine2 = content.location?.addressLine2, + postcode = content.location?.postcode, city = content.location?.city, country = content.location?.country, birthday = content.birthday?.date, status = content.status, shortBio = content.shortBio, socialHandles = content.socialHandles(), - isEmergencyContact = content.isEmergencyContact, + isEmergencyContact = isEmergencyContact(), source = content.source, driveId = image?.driveId, keyHeader = image?.keyHeader, diff --git a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/location/LocationDashboardContent.kt b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/location/LocationDashboardContent.kt index d75fcd41b..d6e134cec 100644 --- a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/location/LocationDashboardContent.kt +++ b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/location/LocationDashboardContent.kt @@ -78,7 +78,6 @@ import id.homebase.resources.location_device_this_device import id.homebase.resources.location_device_unnamed import id.homebase.resources.location_devices_section import id.homebase.resources.location_emergency_access_manage -import id.homebase.resources.location_emergency_access_missing import id.homebase.resources.location_emergency_access_more import id.homebase.resources.location_emergency_access_none import id.homebase.resources.location_emergency_access_section @@ -311,13 +310,13 @@ fun LocationDashboardContent( ) } - // ── Who can locate you (members of the Emergency Location Access circle) ── + // ── Who can locate you (contacts marked as emergency contacts) ── DashboardSection( title = stringResource(MR.string.location_emergency_access_section), onManage = onManageEmergencyAccess, ) { EmergencyAccessBody( - circleFound = uiState.emergencyCircleFound, + loaded = uiState.emergencyContactsLoaded, members = uiState.emergencyContacts, ) } @@ -436,25 +435,18 @@ private fun DashboardSection( */ @Composable private fun EmergencyAccessBody( - circleFound: Boolean?, + loaded: Boolean, members: List, ) { var expanded by remember { mutableStateOf(false) } when { - circleFound == null -> Box( + !loaded -> Box( modifier = Modifier.fillMaxWidth().padding(24.dp), contentAlignment = Alignment.Center, ) { CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp) } - !circleFound -> Text( - text = stringResource(MR.string.location_emergency_access_missing), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.fillMaxWidth().padding(16.dp), - ) - members.isEmpty() -> Text( text = stringResource(MR.string.location_emergency_access_none), style = MaterialTheme.typography.bodyMedium, diff --git a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/location/LocationUiState.kt b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/location/LocationUiState.kt index 60cf72d74..cf3de25d4 100644 --- a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/location/LocationUiState.kt +++ b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/location/LocationUiState.kt @@ -29,11 +29,12 @@ data class LocationUiState( // Dashboard state val devices: List = emptyList(), val todayTraces: List = emptyList(), - /** Resolved members of the "Emergency Location Access" circle (avatars on the dashboard). */ + /** Contacts marked as emergency contacts (the "who can locate you" list on the dashboard). */ val emergencyContacts: List = emptyList(), - /** null = still loading / couldn't load; true = circle present; false = circle doesn't exist. */ - val emergencyCircleFound: Boolean? = null, - /** Owner-console deep link to manage the circle's members; null until the identity is known. */ + /** False until the emergency-contacts list has loaded at least once (drives the loading spinner). */ + val emergencyContactsLoaded: Boolean = false, + /** Owner-console deep link to manage the Emergency Location Access circle (the actual location + * drive grant); null until the identity is known. */ val emergencyManageUrl: String? = null, val mapProvider: LocationMapProvider = LocationMapProvider.DEFAULT, /** Show the "Live location sharing" dashboard section: I'm sharing, or a recent inbound point exists. */ diff --git a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/location/LocationViewModel.kt b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/location/LocationViewModel.kt index 7a6b32d5c..12e6a4fb7 100644 --- a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/location/LocationViewModel.kt +++ b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/location/LocationViewModel.kt @@ -3,13 +3,15 @@ package id.homebase.core.ui.screens.location import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import id.homebase.api.client.auth.CredentialsManager -import id.homebase.api.client.connections.ConnectionNetworkProvider import id.homebase.api.common.OdinId +import id.homebase.api.client.contacts.ContactRepository import id.homebase.chat.conversationlist.ExtendPermissionUiState import id.homebase.chat.conversationlist.ExtendPermissionViewModel -import id.homebase.chat.services.convo.contact.ContactService +import id.homebase.chat.data.ContactUiModel +import id.homebase.chat.data.toContactUiModel import id.homebase.chat.services.livelocation.LiveLocationShareService import id.homebase.core.config.locationLabeledDrive +import id.homebase.core.contactbook.emergencyContacts import id.homebase.core.location.LocationPreferences import id.homebase.core.location.tracking.LocationPointStore import id.homebase.core.location.tracking.LocationTracker @@ -32,6 +34,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -52,8 +55,7 @@ class LocationViewModel( private val pointStore: LocationPointStore, private val uploaderService: LocationTrackUploaderService, private val deviceDirectory: LocationDeviceDirectory, - private val connectionNetworkProvider: ConnectionNetworkProvider, - private val contactService: ContactService, + private val contactRepository: ContactRepository, private val credentialsManager: CredentialsManager, private val receiveStore: LiveLocationReceiveStore, private val liveShareService: LiveLocationShareService, @@ -96,6 +98,26 @@ class LocationViewModel( private var activationKicked = false init { + // "Who can locate you" = the contacts you've marked as emergency contacts (the app-data + // flag), resolved to display models. Reactive so marking/unmarking a contact updates the + // dashboard live. (emergencyContacts is a cold Flow; collecting it here makes it hot for + // the lifetime of this ViewModel.) + viewModelScope.launch { + // Exclude the logged-in identity: a self-contact can carry the flag (e.g. you marked + // your own contact), but you are never your own emergency contact for "who can locate me". + val self = runCatching { credentialsManager.getActiveDomain() }.getOrNull() + contactRepository.emergencyContacts + .map { list -> + list.mapNotNull { it.toContactUiModel() } + .filterNot { it.odinId == self } + } + .collect { members -> + _uiState.update { + it.copy(emergencyContacts = members, emergencyContactsLoaded = true) + } + } + } + viewModelScope.launch { locationPermissionViewModel.permissionsGranted .filter { it } @@ -190,7 +212,7 @@ class LocationViewModel( .filter { it.endTimeMs > now } .groupBy { it.odinId } .map { (id, entries) -> - val contact = contactService.resolveByOdinId(OdinId(id)) + val contact = resolveContact(OdinId(id)) OutgoingShareRow( odinId = id, name = contact?.name?.ifEmpty { null } ?: id, @@ -206,7 +228,7 @@ class LocationViewModel( .filter { now - it.receivedAtMs <= LIVE_STALE_MS } .map { lp -> val id = lp.senderOdinId.domainName - val contact = contactService.resolveByOdinId(lp.senderOdinId) + val contact = resolveContact(lp.senderOdinId) IncomingShareRow( odinId = id, name = contact?.name?.ifEmpty { null } ?: id, @@ -230,6 +252,16 @@ class LocationViewModel( } } + /** + * Resolve a peer odinId to its display model via the contact repository, or null when the + * identity isn't a saved contact (the share rows fall back to the raw odinId / initials). + * Matching is by [OdinId] equality (normalized hash), mirroring ContactService's by-id map. + */ + private fun resolveContact(odinId: OdinId): ContactUiModel? = + contactRepository.contacts.value + .firstOrNull { it.content.odinId?.let(::OdinId) == odinId } + ?.toContactUiModel() + private fun liveTicker() = flow { while (true) { emit(Unit) @@ -361,19 +393,9 @@ class LocationViewModel( val devices = runCatching { deviceDirectory.loadDevices() } .getOrDefault(emptyList()) - // Members of the "Emergency Location Access" circle, resolved to contact - // models (the contact service owns avatar URLs + initials fallbacks). - val circlesResult = runCatching { connectionNetworkProvider.getCirclesWithMembers() } - val circle = circlesResult.getOrNull() - ?.firstOrNull { it.circle.id.equals(EMERGENCY_CIRCLE_ID, ignoreCase = true) } - val circleFound: Boolean? = when { - circlesResult.isFailure -> null // couldn't load — stay neutral, don't claim "missing" - circle != null -> true - else -> false // loaded, but no such circle - } - val members = circle?.members.orEmpty() - .mapNotNull { contactService.resolveByOdinId(it) } - + // The "who can locate you" list itself comes from the emergency-contact flag (collected + // reactively in init). Here we only resolve the owner-console deep link for managing the + // Emergency Location Access circle (the actual location-drive grant). val domain = runCatching { credentialsManager.getActiveCredentials()?.domain?.domainName } .getOrNull() val manageUrl = domain?.let { "https://$it/owner/circles/$EMERGENCY_CIRCLE_ID" } @@ -382,8 +404,6 @@ class LocationViewModel( it.copy( todayTraces = traces, devices = devices, - emergencyContacts = members, - emergencyCircleFound = circleFound, emergencyManageUrl = manageUrl, ) } diff --git a/homebase-core/src/jvmTest/kotlin/id/homebase/core/contactbook/EmergencyContactTest.kt b/homebase-core/src/jvmTest/kotlin/id/homebase/core/contactbook/EmergencyContactTest.kt new file mode 100644 index 000000000..5c5ec5ea7 --- /dev/null +++ b/homebase-core/src/jvmTest/kotlin/id/homebase/core/contactbook/EmergencyContactTest.kt @@ -0,0 +1,52 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package id.homebase.core.contactbook + +import id.homebase.api.client.contacts.Contact +import id.homebase.api.client.contacts.ContactContent +import id.homebase.api.client.contacts.toCanonicalAppId +import id.homebase.core.config.AppConfig +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +/** + * Pins the read side of the app-data emergency flag: [Contact.isEmergencyContact] decodes only THIS + * app's slot ([AppConfig.APP_ID]) and tolerates absent/foreign/malformed data. + */ +class EmergencyContactTest { + + private val uid = Uuid.parse("11111111-1111-1111-1111-111111111111") + private val ourSlot = AppConfig.APP_ID.toCanonicalAppId() + + private fun contact(appData: Map?) = + Contact(uniqueId = uid, versionTag = null, content = ContactContent(appData = appData)) + + @Test + fun absentAppData_isNotEmergency() { + assertFalse(contact(null).isEmergencyContact()) + } + + @Test + fun ourSlotTrue_isEmergency() { + assertTrue(contact(mapOf(ourSlot to """{"isEmergencyContact":true}""")).isEmergencyContact()) + } + + @Test + fun ourSlotFalse_isNotEmergency() { + assertFalse(contact(mapOf(ourSlot to """{"isEmergencyContact":false}""")).isEmergencyContact()) + } + + @Test + fun anotherAppsSlot_isNotReadAsOurs() { + val other = "99999999-9999-9999-9999-999999999999" + assertFalse(contact(mapOf(other to """{"isEmergencyContact":true}""")).isEmergencyContact()) + } + + @Test + fun malformedSlot_isNotEmergency() { + assertFalse(contact(mapOf(ourSlot to "not json {{{")).isEmergencyContact()) + } +} From bf6a16a4f40825731b87872dc29590a2cc2bd457 Mon Sep 17 00:00:00 2001 From: toddmitchell Date: Wed, 24 Jun 2026 12:36:22 -0500 Subject: [PATCH 19/23] Location: emergency-contact directionality via circle membership + iCanLocate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Splits the two conflated directions of the emergency-location feature. "Who can locate you" now reads our own emergency-circle membership (the source of truth) instead of an app-data flag; the per-contact flag is repurposed to the reverse direction — `iCanLocate`, "people we can locate" — and backs a new "Who you can locate" dashboard section. - EmergencyCircleNotifier: diffs emergency-circle membership (baseline-seeded to avoid login re-spam, reset on logout) and notifies peers on grant/revoke. - EmergencyContactRevoked status + sendEmergencyContactRevocation mirror the designation pair; MessageMapper renders the revoke system line. - EmergencyContactReceiveService: sets/clears iCanLocate on the receiver and silently consumes the status message (group-heal-style soft-delete) so a re-delivered designate-after-revoke can't re-flip a stale flag. Unknown senders are synced but not consumed, so the flag still lands on a later delivery. - EmergencyContactReconciler: on dashboard open, verifies each iCanLocate contact against verifyTemporalAccess and clears stale flags (lost revoke self-corrects); inconclusive checks leave the cache untouched. - Removes the contact-detail "mark as emergency contact" toggle (designation now flows from circle membership, not a button). - Moves EMERGENCY_LOCATION_CIRCLE_ID into AppConfig. Tests cover the membership-diff baseline-seed and the receive-side consume decision tables. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../homebase/chat/services/MessageMapper.kt | 13 ++ .../homebase/chat/services/StatusMessage.kt | 9 +- .../services/convo/ConversationService.kt | 32 ++++ .../chat/services/convo/ConversationStream.kt | 32 +++- .../composeResources/values/strings.xml | 9 +- .../id/homebase/core/config/AppConfig.kt | 8 + .../core/contactbook/EmergencyContact.kt | 40 ++--- .../EmergencyContactReceiveService.kt | 137 ++++++++++++++++++ .../contactbook/EmergencyContactReconciler.kt | 48 ++++++ .../kotlin/id/homebase/core/di/AppModule.kt | 52 +++---- .../core/location/EmergencyCircleNotifier.kt | 83 +++++++++++ .../contactbook/detail/ContactDetailScreen.kt | 10 +- .../detail/ContactDetailSections.kt | 20 --- .../detail/ContactDetailUiState.kt | 6 - .../detail/ContactDetailViewModel.kt | 62 -------- .../contactbook/model/ContactBookEntry.kt | 8 +- .../location/LocationDashboardContent.kt | 31 ++-- .../ui/screens/location/LocationUiState.kt | 13 +- .../ui/screens/location/LocationViewModel.kt | 62 +++++--- .../contactbook/EmergencyContactActionTest.kt | 82 +++++++++++ .../core/contactbook/EmergencyContactTest.kt | 22 +-- .../location/EmergencyMembershipDeltaTest.kt | 65 +++++++++ 22 files changed, 638 insertions(+), 206 deletions(-) create mode 100644 homebase-core/src/commonMain/kotlin/id/homebase/core/contactbook/EmergencyContactReceiveService.kt create mode 100644 homebase-core/src/commonMain/kotlin/id/homebase/core/contactbook/EmergencyContactReconciler.kt create mode 100644 homebase-core/src/commonMain/kotlin/id/homebase/core/location/EmergencyCircleNotifier.kt create mode 100644 homebase-core/src/jvmTest/kotlin/id/homebase/core/contactbook/EmergencyContactActionTest.kt create mode 100644 homebase-core/src/jvmTest/kotlin/id/homebase/core/location/EmergencyMembershipDeltaTest.kt diff --git a/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/MessageMapper.kt b/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/MessageMapper.kt index 4196a469c..4dac2b432 100644 --- a/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/MessageMapper.kt +++ b/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/MessageMapper.kt @@ -47,6 +47,9 @@ import id.homebase.resources.system_group_heal_requested_you import id.homebase.resources.system_emergency_contact_designated import id.homebase.resources.system_emergency_contact_designated_you import id.homebase.resources.system_emergency_contact_designated_you_unknown +import id.homebase.resources.system_emergency_contact_revoked +import id.homebase.resources.system_emergency_contact_revoked_you +import id.homebase.resources.system_emergency_contact_revoked_you_unknown import id.homebase.resources.chat_poll_ended_other import id.homebase.resources.chat_poll_ended_self import kotlinx.collections.immutable.toPersistentList @@ -537,5 +540,15 @@ internal suspend fun renderStatusMessage( else -> TranslationUtil.getString(MR.string.system_emergency_contact_designated, name) } + + StatusMessage.EmergencyContactRevoked -> + when { + authorIsYou && subject != null -> + TranslationUtil.getString(MR.string.system_emergency_contact_revoked_you, subject) + authorIsYou -> + TranslationUtil.getString(MR.string.system_emergency_contact_revoked_you_unknown) + else -> + TranslationUtil.getString(MR.string.system_emergency_contact_revoked, name) + } } } diff --git a/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/StatusMessage.kt b/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/StatusMessage.kt index 620508de1..aede4006b 100644 --- a/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/StatusMessage.kt +++ b/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/StatusMessage.kt @@ -39,7 +39,12 @@ enum class StatusMessage() { /** Sent by a user (the message's `originalAuthor`) when they designate the recipient as one of * their emergency contacts. Renders a system line ("X added you as an emergency contact") AND - * drives a receive-side side-effect (wired in AppModule) that marks the SENDER as an emergency - * contact in the recipient's own contact app-data (core `setEmergencyContact`). */ + * drives a receive-side side-effect (wired in AppModule) that records — in the recipient's own + * contact app-data — that the recipient can now locate the SENDER (core `setICanLocate`). */ EmergencyContactDesignated, + + /** Mirror of [EmergencyContactDesignated], sent when the author removes the recipient from their + * emergency circle. Renders "X removed you as an emergency contact" AND drives a receive-side + * side-effect that clears the recipient's can-locate flag for the SENDER (core `clearICanLocate`). */ + EmergencyContactRevoked, } diff --git a/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/convo/ConversationService.kt b/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/convo/ConversationService.kt index 647538634..40c2a8020 100644 --- a/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/convo/ConversationService.kt +++ b/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/convo/ConversationService.kt @@ -418,6 +418,38 @@ class ConversationService( } } + /** + * Mirror of [sendEmergencyContactDesignation]: notifies [recipient] that the local user has + * removed them from their emergency circle, by posting a [StatusMessage.EmergencyContactRevoked] + * status into their 1:1. Drives the receiver's [ConversationStream.onEmergencyContactRevoked] + * side-effect (clears the can-locate flag). Best-effort; returns the conversation id or null on + * failure (logged). Rethrows cancellation. + */ + suspend fun sendEmergencyContactRevocation(recipient: OdinId): Uuid? { + return try { + val result = createConversation( + recipients = listOf(recipient), + title = null, + payloadBundle = null, + ) + chatMessageSenderService.sendStatusMessage( + messageUniqueId = Uuid.random(), + conversationId = result.conversationId, + previousMessageUniqueId = result.conversationId, + statusMessage = StatusMessageData( + statusMessage = StatusMessage.EmergencyContactRevoked, + subject = recipient, + ), + ) + result.conversationId + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Logger.w(e) { "Failed to send emergency-contact revocation to ${recipient.domainName}" } + null + } + } + /** * Creates a conversation file locally and enqueues it for server upload. * Shared by [createConversation] and [ensureNoteToSelfExists]. diff --git a/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/convo/ConversationStream.kt b/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/convo/ConversationStream.kt index ad60344f8..4fd55e990 100644 --- a/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/convo/ConversationStream.kt +++ b/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/convo/ConversationStream.kt @@ -136,6 +136,14 @@ class ConversationStream( * our own contact drive. Side effect only — does not affect message-list dispatch. */ var onEmergencyContactDesignated: (suspend (sender: OdinId, messageFile: HomebaseFile) -> Unit)? = null + + /** + * Hook invoked when the live receive stream observes an incoming + * [StatusMessage.EmergencyContactRevoked] status — i.e. the [sender] removed us from their + * emergency circle. Wired in AppModule to clear our can-locate flag for the [sender]. Side effect + * only — does not affect message-list dispatch. + */ + var onEmergencyContactRevoked: (suspend (sender: OdinId, messageFile: HomebaseFile) -> Unit)? = null // endregion // region Orphan-recovery: read-path dedup of recover attempts @@ -429,6 +437,26 @@ class ConversationStream( } } + private suspend fun dispatchEmergencyRevocations(messageFiles: List) { + val handler = onEmergencyContactRevoked ?: return + for (file in messageFiles) { + val appData = file.fileMetadata.appData + if (appData.dataType != ChatProtocol.ChatStatusMessageDataType) continue + // originalAuthor is null on our own synced copy, so this only fires on the receiver side. + val sender = file.fileMetadata.originalAuthor ?: file.fileMetadata.senderOdinId ?: continue + val content = appData.content ?: continue + val status = runCatching { + OdinSystemSerializer.deserialize(content) + }.getOrNull() ?: continue + if (status.statusMessage != StatusMessage.EmergencyContactRevoked) continue + try { + handler(sender, file) + } catch (e: Exception) { + Logger.e(e) { "ConversationStream: emergency-revocation handler threw for sender=${sender.domainName}: ${e.message}" } + } + } + } + private suspend fun processMessageBatchIncrementally(messageFiles: List) { if (messageFiles.isEmpty()) throw IllegalArgumentException("It can't be empty") @@ -436,8 +464,10 @@ class ConversationStream( // handler. Done here (live BatchReceived only — never on cold reads or // searches) so the side effects fire exactly once per arrival. dispatchGroupHealRequests(messageFiles) - // Same live-only contract: mark the sender as an emergency contact when they designate us. + // Same live-only contract: mark the sender as an emergency contact when they designate us, + // and clear that mark when they revoke us. dispatchEmergencyDesignations(messageFiles) + dispatchEmergencyRevocations(messageFiles) // For each file in the batch, map to model (fetch last message from DB if needed). // Keep the original HomebaseFile alongside the mapped MessageUiModel so we can diff --git a/homebase-common/src/commonMain/composeResources/values/strings.xml b/homebase-common/src/commonMain/composeResources/values/strings.xml index f3c35180a..5b99289c9 100644 --- a/homebase-common/src/commonMain/composeResources/values/strings.xml +++ b/homebase-common/src/commonMain/composeResources/values/strings.xml @@ -485,6 +485,9 @@ %1$s added you as an emergency contact You added %1$s as an emergency contact You added an emergency contact + %1$s removed you as an emergency contact + You removed %1$s as an emergency contact + You removed an emergency contact Removed broken local copies (group + admins) as part of heal Removed broken local copy (group) as part of heal Removed broken local copy (admins) as part of heal @@ -1288,7 +1291,7 @@ Location History Find my device Who you can locate - Coming soon… + You can't locate anyone yet. People appear here when they add you to their emergency circle. Who can locate you Add emergency contacts No Emergency Location Access circle yet. Tap + to set one up in your owner console. @@ -1399,11 +1402,7 @@ Recent media No recent media yet Contact details - Mark as emergency contact - Remove as emergency contact Emergency contact - Marked as emergency contact - Removed as emergency contact Bio Social Location diff --git a/homebase-common/src/commonMain/kotlin/id/homebase/core/config/AppConfig.kt b/homebase-common/src/commonMain/kotlin/id/homebase/core/config/AppConfig.kt index e47138a31..999cb9739 100644 --- a/homebase-common/src/commonMain/kotlin/id/homebase/core/config/AppConfig.kt +++ b/homebase-common/src/commonMain/kotlin/id/homebase/core/config/AppConfig.kt @@ -69,6 +69,14 @@ expect fun dataUpgradeReturnUrl(): String const val CONFIRMED_CONNECTIONS_CIRCLE_ID = "bb2683fa402aff866e771a6495765a15" const val AUTO_CONNECTIONS_CIRCLE_ID = "9e22b42952f74d2580e11250b651d343" +/** + * Well-known GUID (N-format) of the circle whose members may see this identity's location in an + * emergency. Matching by id rather than name survives a rename; the owner-console "manage" deep link + * uses the same id. Granting it server-side gives the member `ConditionalTemporalRead` on the + * location drive. + */ +const val EMERGENCY_LOCATION_CIRCLE_ID = "8b5383a5927246f8a666f4f3fcb7392b" + // TypeIds const val OWNER_FOLLOWER_TYPE_ID = "2cc468af-109b-4216-8119-542401e32f4d" const val OWNER_CONNECTION_REQUEST_TYPE_ID = "8ee62e9e-c224-47ad-b663-21851207f768" diff --git a/homebase-core/src/commonMain/kotlin/id/homebase/core/contactbook/EmergencyContact.kt b/homebase-core/src/commonMain/kotlin/id/homebase/core/contactbook/EmergencyContact.kt index 8b7952207..7e163df66 100644 --- a/homebase-core/src/commonMain/kotlin/id/homebase/core/contactbook/EmergencyContact.kt +++ b/homebase-core/src/commonMain/kotlin/id/homebase/core/contactbook/EmergencyContact.kt @@ -25,19 +25,25 @@ import kotlin.uuid.Uuid */ @Serializable data class ChatContactAppData( - val isEmergencyContact: Boolean = false, + /** + * Whether *we* can locate this contact in an emergency — i.e. they added us to their emergency + * circle, granting our identity `ConditionalTemporalRead` on their location drive. Set on receipt + * of their `EmergencyContactDesignated` status message; this flag is the cheap reactive cache, + * the authoritative check is a temporal-access preflight against the peer. + */ + val iCanLocate: Boolean = false, ) -/** Whether this contact is one of our emergency contacts, read from our app-data slot. */ -fun Contact.isEmergencyContact(): Boolean = chatAppData()?.isEmergencyContact == true +/** Whether we can locate this contact (the cached [ChatContactAppData.iCanLocate] flag). */ +fun Contact.iCanLocate(): Boolean = chatAppData()?.iCanLocate == true /** - * Live list of our emergency contacts, derived from [ContactRepository.contacts] via the app-data + * Live list of the contacts we can locate, derived from [ContactRepository.contacts] via the app-data * flag — so it tracks the same optimistic writes and sync reconciliation. Cold flow: collect it * (e.g. `collectAsStateWithLifecycle`) or `stateIn` it yourself. Consumers sort. */ -val ContactRepository.emergencyContacts: Flow> - get() = contacts.map { list -> list.filter { it.isEmergencyContact() } } +val ContactRepository.locatableContacts: Flow> + get() = contacts.map { list -> list.filter { it.iCanLocate() } } private fun Contact.chatAppData(): ChatContactAppData? = appDataFor(AppConfig.APP_ID)?.let { @@ -45,25 +51,25 @@ private fun Contact.chatAppData(): ChatContactAppData? = } /** - * Marks the contact as an emergency contact in our app-data slot — a minimal-delta write that merges - * onto any existing blob ([ContactRepository.setAppData]). Returns the write response, or null on - * failure; rethrows the same exceptions as [ContactRepository.setAppData]. + * Marks that we can locate the contact in our app-data slot — a minimal-delta write that merges onto + * any existing blob ([ContactRepository.setAppData]). Returns the write response, or null on failure; + * rethrows the same exceptions as [ContactRepository.setAppData]. */ -suspend fun ContactRepository.setEmergencyContact(uniqueId: Uuid, versionTag: Uuid): ContactWriteResponse? = - writeEmergencyFlag(uniqueId, versionTag, isEmergency = true) +suspend fun ContactRepository.setICanLocate(uniqueId: Uuid, versionTag: Uuid): ContactWriteResponse? = + writeICanLocateFlag(uniqueId, versionTag, canLocate = true) -/** Clears the emergency-contact flag in our app-data slot (dropping the slot if it becomes empty). */ -suspend fun ContactRepository.clearEmergencyContact(uniqueId: Uuid, versionTag: Uuid): ContactWriteResponse? = - writeEmergencyFlag(uniqueId, versionTag, isEmergency = false) +/** Clears the can-locate flag in our app-data slot (dropping the slot if it becomes empty). */ +suspend fun ContactRepository.clearICanLocate(uniqueId: Uuid, versionTag: Uuid): ContactWriteResponse? = + writeICanLocateFlag(uniqueId, versionTag, canLocate = false) -private suspend fun ContactRepository.writeEmergencyFlag( +private suspend fun ContactRepository.writeICanLocateFlag( uniqueId: Uuid, versionTag: Uuid, - isEmergency: Boolean, + canLocate: Boolean, ): ContactWriteResponse? { val current = contacts.value.firstOrNull { it.uniqueId == uniqueId }?.chatAppData() ?: ChatContactAppData() - val updated = current.copy(isEmergencyContact = isEmergency) + val updated = current.copy(iCanLocate = canLocate) return if (updated == ChatContactAppData()) { // All flags back to default — drop the whole slot rather than keep an empty blob. deleteAppData(uniqueId, AppConfig.APP_ID, versionTag) diff --git a/homebase-core/src/commonMain/kotlin/id/homebase/core/contactbook/EmergencyContactReceiveService.kt b/homebase-core/src/commonMain/kotlin/id/homebase/core/contactbook/EmergencyContactReceiveService.kt new file mode 100644 index 000000000..5a63656cc --- /dev/null +++ b/homebase-core/src/commonMain/kotlin/id/homebase/core/contactbook/EmergencyContactReceiveService.kt @@ -0,0 +1,137 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package id.homebase.core.contactbook + +import co.touchlab.kermit.Logger +import id.homebase.api.client.ForbiddenException +import id.homebase.api.client.contacts.ContactRepository +import id.homebase.api.client.drives.HomebaseFile +import id.homebase.api.client.drives.files.DeleteLocalFilesByFileIdRequest +import id.homebase.api.common.OdinId +import id.homebase.api.crypto.Md5 +import id.homebase.api.sync.database.OutboxSync +import id.homebase.chat.services.outbox.OptimisticWriter +import id.homebase.core.config.chatTargetDrive +import kotlin.uuid.ExperimentalUuidApi + +/** + * Receive-side handling of emergency-contact designations / revocations — the mirror of + * [id.homebase.core.location.EmergencyCircleNotifier]'s send side. When a peer adds us to (or removes + * us from) their emergency circle they post us a status message; here we set/clear our `iCanLocate` + * flag for them and then CONSUME the message so a re-delivery can't re-apply a stale designation + * after a revoke (or vice-versa). + * + * Consume = soft-delete on our OWN identity only (local + server, recipients = null), mirroring + * [id.homebase.chat.services.convo.GroupHealService]. The ConversationStream dispatcher short-circuits + * on `content == null`, so a consumed message re-dispatches as a no-op. The flag is the cheap cache; + * the authoritative backstop is a temporal-access preflight (reconcile, step 8). + */ +class EmergencyContactReceiveService( + private val contactRepository: ContactRepository, + private val optimisticWriter: OptimisticWriter, + private val outboxSync: OutboxSync, +) { + private val chatDrive = chatTargetDrive.alias + + /** The [sender] designated us — record that we can locate them, then consume the message. */ + suspend fun onDesignated(sender: OdinId, messageFile: HomebaseFile) { + try { + contactRepository.ensureLoaded() + val uniqueId = Md5.toGuidId(sender.domainName) + val contact = contactRepository.contacts.value.firstOrNull { it.uniqueId == uniqueId } + val versionTag = contact?.versionTag + when (designationAction(contact != null, contact?.iCanLocate() == true, versionTag != null)) { + DesignationAction.SyncOnly -> contactRepository.sync(sender) + DesignationAction.Consume -> consume(messageFile) + DesignationAction.SetThenConsume -> { + contactRepository.setICanLocate(uniqueId, versionTag!!) + consume(messageFile) + } + DesignationAction.Ignore -> Unit + } + } catch (e: ForbiddenException) { + Logger.w(e) { "emergency designation: missing ManageContacts permission" } + } catch (e: Exception) { + Logger.w(e) { "emergency designation handling failed for ${sender.domainName}" } + } + } + + /** The [sender] revoked us — clear that we can locate them, then consume the message. */ + suspend fun onRevoked(sender: OdinId, messageFile: HomebaseFile) { + try { + contactRepository.ensureLoaded() + val uniqueId = Md5.toGuidId(sender.domainName) + val contact = contactRepository.contacts.value.firstOrNull { it.uniqueId == uniqueId } + val versionTag = contact?.versionTag + when (revocationAction(contact != null, contact?.iCanLocate() == true, versionTag != null)) { + RevocationAction.Consume -> consume(messageFile) + RevocationAction.ClearThenConsume -> { + contactRepository.clearICanLocate(uniqueId, versionTag!!) + consume(messageFile) + } + RevocationAction.Ignore -> Unit + } + } catch (e: ForbiddenException) { + Logger.w(e) { "emergency revocation: missing ManageContacts permission" } + } catch (e: Exception) { + Logger.w(e) { "emergency revocation handling failed for ${sender.domainName}" } + } + } + + /** + * Soft-deletes the status message on our own identity (local write + server, recipients = null) + * so the ConversationStream dispatcher's `content ?: continue` guard no-ops any re-delivery. + * Best-effort — a failure just means a re-delivery might re-run an already-idempotent handler. + */ + private suspend fun consume(messageFile: HomebaseFile) { + messageFile.fileMetadata.appData.uniqueId?.let { uniqueId -> + runCatching { optimisticWriter.writeDelete(chatDrive, uniqueId) } + .onFailure { Logger.w(it) { "emergency consume: local soft-delete failed" } } + } + runCatching { + outboxSync.tryEnqueue( + DeleteLocalFilesByFileIdRequest( + driveId = chatDrive, + fileIds = listOf(messageFile.fileId), + recipients = null, + hardDelete = false, + ) + ) + }.onFailure { Logger.w(it) { "emergency consume: server soft-delete enqueue failed" } } + } +} + +internal enum class DesignationAction { SyncOnly, Consume, SetThenConsume, Ignore } + +/** + * What to do for an incoming designation. The replay guard's key rule: when the sender isn't a + * contact yet we sync but do NOT consume, so the flag still gets applied on a later delivery once the + * row exists. Once set (or already set) we consume to neutralise re-deliveries. + */ +internal fun designationAction( + contactExists: Boolean, + alreadyICanLocate: Boolean, + hasVersionTag: Boolean, +): DesignationAction = when { + !contactExists -> DesignationAction.SyncOnly + alreadyICanLocate -> DesignationAction.Consume + hasVersionTag -> DesignationAction.SetThenConsume + else -> DesignationAction.Ignore +} + +internal enum class RevocationAction { Consume, ClearThenConsume, Ignore } + +/** + * What to do for an incoming revocation. Nothing to clear (not a contact, or already clear) → just + * consume; otherwise clear the flag then consume. Only blocked when the row exists and is flagged but + * has no versionTag to write against. + */ +internal fun revocationAction( + contactExists: Boolean, + currentlyICanLocate: Boolean, + hasVersionTag: Boolean, +): RevocationAction = when { + !contactExists || !currentlyICanLocate -> RevocationAction.Consume + hasVersionTag -> RevocationAction.ClearThenConsume + else -> RevocationAction.Ignore +} diff --git a/homebase-core/src/commonMain/kotlin/id/homebase/core/contactbook/EmergencyContactReconciler.kt b/homebase-core/src/commonMain/kotlin/id/homebase/core/contactbook/EmergencyContactReconciler.kt new file mode 100644 index 000000000..71158e6e5 --- /dev/null +++ b/homebase-core/src/commonMain/kotlin/id/homebase/core/contactbook/EmergencyContactReconciler.kt @@ -0,0 +1,48 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package id.homebase.core.contactbook + +import co.touchlab.kermit.Logger +import id.homebase.api.client.contacts.ContactRepository +import id.homebase.api.client.peer.temporal.TemporalDriveReadProvider +import id.homebase.api.common.OdinId +import id.homebase.core.config.locationLabeledDrive +import kotlin.uuid.ExperimentalUuidApi + +/** + * Reconciles the cached `iCanLocate` flag against the authoritative grant. The flag is set/cleared by + * best-effort status messages ([EmergencyContactReceiveService]); if a revocation is lost, the flag + * is left wrongly `true` and we'd claim we can locate someone we can't. This corrects that: for each + * contact we think we can locate, preflight the peer's location drive with + * [TemporalDriveReadProvider.verifyTemporalAccess] (reads no data, fires no notification on the peer) + * and clear the flag when the grant is gone. + * + * Scope: clears STALE flags only. A lost *designation* (grant exists but the flag never got set) + * isn't recovered here — that would require preflighting every connected contact on each open. The + * message + its re-delivery remain the path for the true→ direction; the read itself verifies access + * at locate time. + */ +class EmergencyContactReconciler( + private val contactRepository: ContactRepository, + private val temporalRead: TemporalDriveReadProvider, +) { + private val locationDrive = locationLabeledDrive.drive.alias + + /** Verify each can-locate contact still grants us access; clear the flag where it doesn't. */ + suspend fun reconcile() { + contactRepository.ensureLoaded() + val flagged = contactRepository.contacts.value.filter { it.iCanLocate() } + for (contact in flagged) { + val odinId = contact.content.odinId?.takeIf { it.isNotBlank() }?.let { OdinId(it) } ?: continue + val versionTag = contact.versionTag ?: continue + // A network/parse failure is inconclusive — leave the cache untouched rather than clear + // a flag that may still be valid. + val status = runCatching { temporalRead.verifyTemporalAccess(odinId, locationDrive) } + .getOrNull() ?: continue + if (!status.hasAccess) { + runCatching { contactRepository.clearICanLocate(contact.uniqueId, versionTag) } + .onFailure { Logger.w(it) { "reconcile: clearICanLocate failed for ${odinId.domainName}" } } + } + } + } +} diff --git a/homebase-core/src/commonMain/kotlin/id/homebase/core/di/AppModule.kt b/homebase-core/src/commonMain/kotlin/id/homebase/core/di/AppModule.kt index 29091f175..7aa1a1750 100644 --- a/homebase-core/src/commonMain/kotlin/id/homebase/core/di/AppModule.kt +++ b/homebase-core/src/commonMain/kotlin/id/homebase/core/di/AppModule.kt @@ -72,11 +72,9 @@ import id.homebase.core.auth.AuthConnectionCoordinator import id.homebase.core.util.PlatformInfo import id.homebase.core.vault.VaultPreferences import id.homebase.core.contactbook.ContactBookPreferences -import id.homebase.api.client.ForbiddenException import id.homebase.api.client.contacts.ContactRepository -import id.homebase.api.crypto.Md5 -import id.homebase.core.contactbook.isEmergencyContact -import id.homebase.core.contactbook.setEmergencyContact +import id.homebase.core.contactbook.EmergencyContactReceiveService +import id.homebase.core.contactbook.EmergencyContactReconciler import id.homebase.core.ui.screens.contactbook.ContactBookViewModel import id.homebase.core.ui.screens.contactbook.detail.ContactDetailViewModel import id.homebase.core.ui.screens.contactbook.settings.ContactBookSettingsViewModel @@ -150,6 +148,7 @@ import org.koin.dsl.bind import org.koin.dsl.module import id.homebase.core.config.getLocationPermissionExtensionConfig import id.homebase.core.config.getVaultPermissionExtensionConfig +import id.homebase.core.location.EmergencyCircleNotifier import id.homebase.core.location.LocationPreferences import id.homebase.core.location.tracking.LocationDeviceId import id.homebase.core.location.tracking.DeviceSensors @@ -463,6 +462,8 @@ val appModule = module { get().start() get().start() get().start() + // Notify peers when our emergency-location circle membership changes (grant/revoke). + get().start() // Let ChatMessageStream skip messages for left conversations get().isConversationLeft = { conversationId -> @@ -484,33 +485,16 @@ val appModule = module { } // endregion - // region Emergency contact: incoming EmergencyContactDesignated status - // The sender designated us as their emergency contact; mark THEM as an emergency - // contact on our own contact drive. Best-effort and idempotent. - val contactRepository = get() - conversationStream.onEmergencyContactDesignated = { sender, _ -> - try { - contactRepository.ensureLoaded() - val uniqueId = Md5.toGuidId(sender.domainName) - val contact = contactRepository.contacts.value - .firstOrNull { it.uniqueId == uniqueId } - val versionTag = contact?.versionTag - when { - // Not a contact on our drive yet — create/enrich it from the sender's - // profile. The flag isn't applied on this delivery; a later designation - // (or a manual mark) sets it once the row exists. - contact == null -> contactRepository.sync(sender) - // Already flagged — idempotent no-op (status messages can re-deliver). - contact.isEmergencyContact() -> Unit - versionTag != null -> - contactRepository.setEmergencyContact(uniqueId, versionTag) - else -> Unit - } - } catch (e: ForbiddenException) { - Logger.w(e) { "emergency designation: missing ManageContacts permission" } - } catch (e: Exception) { - Logger.w(e) { "emergency designation handling failed for ${sender.domainName}" } - } + // region Emergency contact: incoming designation / revocation status messages. + // A peer adding us to (designation) or removing us from (revocation) their emergency + // circle posts us a status; the receive service records/clears our can-locate flag for + // them and consumes the message so a re-delivery can't re-apply stale state. + val emergencyContactReceive = get() + conversationStream.onEmergencyContactDesignated = { sender, file -> + emergencyContactReceive.onDesignated(sender, file) + } + conversationStream.onEmergencyContactRevoked = { sender, file -> + emergencyContactReceive.onRevoked(sender, file) } // endregion @@ -568,6 +552,9 @@ val appModule = module { singleOf(::ConnectionCacheRepository) singleOf(::ConnectionService) + singleOf(::EmergencyCircleNotifier) + singleOf(::EmergencyContactReceiveService) + singleOf(::EmergencyContactReconciler) singleOf(::ContactService) singleOf(::ConversationStream) bind ConversationLoader::class single { get() } @@ -797,6 +784,9 @@ val appModule = module { uploaderService = get(), deviceDirectory = get(), contactRepository = get(), + connectionService = get(), + contactService = get(), + emergencyContactReconciler = get(), credentialsManager = get(), tracker = get(), receiveStore = get(), diff --git a/homebase-core/src/commonMain/kotlin/id/homebase/core/location/EmergencyCircleNotifier.kt b/homebase-core/src/commonMain/kotlin/id/homebase/core/location/EmergencyCircleNotifier.kt new file mode 100644 index 000000000..84bf34536 --- /dev/null +++ b/homebase-core/src/commonMain/kotlin/id/homebase/core/location/EmergencyCircleNotifier.kt @@ -0,0 +1,83 @@ +package id.homebase.core.location + +import co.touchlab.kermit.Logger +import id.homebase.api.common.OdinId +import id.homebase.chat.services.convo.ConversationService +import id.homebase.chat.services.convo.contact.ConnectionService +import id.homebase.core.config.EMERGENCY_LOCATION_CIRCLE_ID +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +/** + * Watches our emergency-location-access circle ([EMERGENCY_LOCATION_CIRCLE_ID]) and tells each peer + * when their membership changes, so their app can learn it can — or can no longer — locate us. + * + * The circle lives on our OWN identity; we grant/revoke it in the owner console (off-app), and the + * only app-side signal is the membership refresh [ConnectionService] performs on the + * `ConnectionChanged` websocket event. So rather than react to raw events (which "fan out to every + * session and echo our own mutations" — see ConnectionService), we diff the *member set*: a member + * appearing is a grant, disappearing is a revoke. The set transitions once per change, so a burst of + * echoed events collapses into a single notification. + * + * Best-effort and idempotent on the receiver — the designation/revocation status is the cheap cache; + * the authoritative check is a temporal-access preflight. + */ +class EmergencyCircleNotifier( + private val connectionService: ConnectionService, + private val conversationService: ConversationService, + private val scope: CoroutineScope, +) { + private var started = false + + // Last known member set for the current session; null = not yet seeded (don't notify until we + // have a baseline, else every member would be re-notified on login). Confined to the single + // collector coroutine below, so no synchronization is needed. + private var known: Set? = null + + fun start() { + if (started) return + started = true + scope.launch { + connectionService.circles.collect { state -> + // reset() (logout) drops circles to isLoaded=false; refresh() always lands on + // isLoaded=true. A non-loaded state means "no baseline" — clear it and re-seed from + // the next loaded state, which keeps us correct across identity switches. + if (!state.isLoaded) { + known = null + return@collect + } + reconcile(state.membersOf(EMERGENCY_LOCATION_CIRCLE_ID)) + } + } + } + + private fun reconcile(current: Set) { + val previous = known + known = current + val delta = emergencyMembershipDelta(previous, current) + + delta.granted.forEach { domain -> + Logger.i { "EmergencyCircleNotifier: granted $domain — sending designation" } + scope.launch { conversationService.sendEmergencyContactDesignation(OdinId(domain)) } + } + delta.revoked.forEach { domain -> + Logger.i { "EmergencyCircleNotifier: revoked $domain — sending revocation" } + scope.launch { conversationService.sendEmergencyContactRevocation(OdinId(domain)) } + } + } +} + +/** Membership change between two snapshots of the emergency circle. */ +internal data class EmergencyMembershipDelta( + val granted: Set, + val revoked: Set, +) + +/** + * Diffs [current] circle membership against the [previous] known set. A null [previous] means we have + * no baseline yet (first loaded state of the session) and yields an EMPTY delta — so we never + * re-notify the whole circle on login. An unchanged set yields empty granted/revoked sets. + */ +internal fun emergencyMembershipDelta(previous: Set?, current: Set): EmergencyMembershipDelta = + if (previous == null) EmergencyMembershipDelta(emptySet(), emptySet()) + else EmergencyMembershipDelta(granted = current - previous, revoked = previous - current) diff --git a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailScreen.kt b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailScreen.kt index 91f6a9533..9a332aa4a 100644 --- a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailScreen.kt +++ b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailScreen.kt @@ -63,8 +63,6 @@ import id.homebase.resources.cancel import id.homebase.resources.contactbook_action_blocked import id.homebase.resources.contactbook_action_sync_started import id.homebase.resources.contactbook_action_disconnected -import id.homebase.resources.contactbook_action_emergency_removed -import id.homebase.resources.contactbook_action_emergency_set import id.homebase.resources.contactbook_detail_emergency_badge import id.homebase.resources.contactbook_action_unblocked import id.homebase.resources.contactbook_connected @@ -116,8 +114,6 @@ fun ContactDetailScreen( val msgUnblocked = stringResource(MR.string.contactbook_action_unblocked) val msgDisconnected = stringResource(MR.string.contactbook_action_disconnected) val msgSyncStarted = stringResource(MR.string.contactbook_action_sync_started) - val msgEmergencySet = stringResource(MR.string.contactbook_action_emergency_set) - val msgEmergencyRemoved = stringResource(MR.string.contactbook_action_emergency_removed) LaunchedEffect(Unit) { viewModel.events.collect { event -> @@ -139,10 +135,6 @@ fun ContactDetailScreen( ContactDetailEvent.Unblocked -> snackbarHostState.showSnackbar(msgUnblocked) ContactDetailEvent.Disconnected -> snackbarHostState.showSnackbar(msgDisconnected) ContactDetailEvent.SyncStarted -> snackbarHostState.showSnackbar(msgSyncStarted) - ContactDetailEvent.EmergencyContactSet -> - snackbarHostState.showSnackbar(msgEmergencySet) - ContactDetailEvent.EmergencyContactRemoved -> - snackbarHostState.showSnackbar(msgEmergencyRemoved) } } } @@ -363,7 +355,7 @@ private fun DetailHeader( // Emergency-contact indicator — visible whenever this contact is one of our emergency // contacts (independent of connection state). - if (entry.isEmergencyContact) { + if (entry.iCanLocate) { Spacer(modifier = Modifier.height(8.dp)) Row(verticalAlignment = Alignment.CenterVertically) { Icon( diff --git a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailSections.kt b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailSections.kt index 7e772a5b0..5b4994bd8 100644 --- a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailSections.kt +++ b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailSections.kt @@ -22,7 +22,6 @@ import androidx.compose.material.icons.outlined.AlternateEmail import androidx.compose.material.icons.outlined.Block import androidx.compose.material.icons.outlined.Cake import androidx.compose.material.icons.outlined.Call -import androidx.compose.material.icons.outlined.ContactEmergency import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.Email import androidx.compose.material.icons.outlined.LocationOn @@ -61,8 +60,6 @@ import id.homebase.resources.contactbook_detail_circles_connect import id.homebase.resources.contactbook_detail_circles_empty import id.homebase.resources.contactbook_detail_contact_details import id.homebase.resources.contactbook_detail_location -import id.homebase.resources.contactbook_detail_make_emergency -import id.homebase.resources.contactbook_detail_remove_emergency import id.homebase.resources.contactbook_edit_birthday import id.homebase.resources.contactbook_edit_email import id.homebase.resources.contactbook_edit_given_name @@ -356,23 +353,6 @@ fun ManagementSection( ) { onAction(ContactDetailAction.SyncClicked) } } - // Emergency-contact toggle — only for a synced Homebase identity. Marking notifies the contact - // and sets the flag; removing clears it (a dedicated delta write, since the normal content merge - // can't express a clear). - if (uiState.hasOdinId && uiState.entry?.versionTag != null && !uiState.isSelf) { - if (uiState.entry?.isEmergencyContact == true) { - ManagementAction( - Icons.Outlined.ContactEmergency, - stringResource(MR.string.contactbook_detail_remove_emergency), - ) { onAction(ContactDetailAction.RemoveEmergencyContactClicked) } - } else { - ManagementAction( - Icons.Outlined.ContactEmergency, - stringResource(MR.string.contactbook_detail_make_emergency), - ) { onAction(ContactDetailAction.MakeEmergencyContactClicked) } - } - } - // Danger zone — set apart with a divider so it's clearly separated from the rest. Spacer(modifier = Modifier.height(16.dp)) HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) diff --git a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailUiState.kt b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailUiState.kt index 9583270ed..885e42b1d 100644 --- a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailUiState.kt +++ b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailUiState.kt @@ -44,8 +44,6 @@ data class ContactDetailUiState( sealed interface ContactDetailAction { data object MessageClicked : ContactDetailAction data object SyncClicked : ContactDetailAction - data object MakeEmergencyContactClicked : ContactDetailAction - data object RemoveEmergencyContactClicked : ContactDetailAction data object EditClicked : ContactDetailAction data class SaveContact(val draft: ContactDraft, val photo: io.github.vinceglb.filekit.PlatformFile?) : ContactDetailAction @@ -85,8 +83,4 @@ sealed interface ContactDetailEvent { data object Disconnected : ContactDetailEvent /** Best-effort profile sync was requested; the enriched contact lands later via drive sync. */ data object SyncStarted : ContactDetailEvent - /** The contact was marked as an emergency contact (and the designation sent). */ - data object EmergencyContactSet : ContactDetailEvent - /** The contact was removed as an emergency contact. */ - data object EmergencyContactRemoved : ContactDetailEvent } diff --git a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailViewModel.kt b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailViewModel.kt index a3a68b263..7a2dbd809 100644 --- a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailViewModel.kt +++ b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailViewModel.kt @@ -19,8 +19,6 @@ import id.homebase.chat.services.convo.ConversationService import id.homebase.chat.services.convo.ConversationStream import id.homebase.chat.services.convo.contact.ConnectionService import id.homebase.api.client.contacts.ContactRepository -import id.homebase.core.contactbook.clearEmergencyContact -import id.homebase.core.contactbook.setEmergencyContact import id.homebase.core.config.AUTO_CONNECTIONS_CIRCLE_ID import id.homebase.core.config.CONFIRMED_CONNECTIONS_CIRCLE_ID import id.homebase.core.ui.navigation.Route @@ -163,8 +161,6 @@ class ContactDetailViewModel( when (action) { ContactDetailAction.MessageClicked -> handleMessage() ContactDetailAction.SyncClicked -> handleSync() - ContactDetailAction.MakeEmergencyContactClicked -> handleMakeEmergencyContact() - ContactDetailAction.RemoveEmergencyContactClicked -> handleRemoveEmergencyContact() ContactDetailAction.EditClicked -> _uiState.update { it.copy(editOpen = true) } ContactDetailAction.CloseEdit -> _uiState.update { it.copy(editOpen = false) } is ContactDetailAction.SaveContact -> handleSave(action) @@ -202,64 +198,6 @@ class ContactDetailViewModel( } } - /** - * Marks this contact as one of our emergency contacts: sets the owner-only flag on our own - * contact record ([ContactRepository.setEmergencyContact]) and then notifies the contact by - * posting an EmergencyContactDesignated status into their 1:1. The notification is best-effort - * (the local flag is the source of truth); a failed flag write reports an error and skips the - * notification. Needs a synced contact (a versionTag) and an odinId to notify. - */ - private fun handleMakeEmergencyContact() { - val entry = _uiState.value.entry ?: return - val versionTag = entry.versionTag ?: return - if (entry.isEmergencyContact || _uiState.value.isSelf) return - val recipient = entry.odinId?.ifBlank { null } - ?.let { runCatching { OdinId(it) }.getOrNull() } ?: return - _uiState.update { it.copy(actionInProgress = true) } - viewModelScope.launch { - try { - val response = contactRepository.setEmergencyContact(entry.uniqueId, versionTag) - if (response == null) { - _events.tryEmit(ContactDetailEvent.Error) - return@launch - } - // Best-effort notify; the flag is already set locally regardless of this result. - conversationService.sendEmergencyContactDesignation(recipient) - _events.tryEmit(ContactDetailEvent.EmergencyContactSet) - } catch (e: ForbiddenException) { - _events.tryEmit(ContactDetailEvent.Forbidden) - } finally { - _uiState.update { it.copy(actionInProgress = false) } - } - } - } - - /** - * Removes this contact as an emergency contact: clears the owner-only flag on our own record - * ([ContactRepository.clearEmergencyContact]). Local-only — we don't notify the contact (the - * "designation" status is one-way; removal is a private bookkeeping change). Needs a synced - * contact (a versionTag). - */ - private fun handleRemoveEmergencyContact() { - val entry = _uiState.value.entry ?: return - val versionTag = entry.versionTag ?: return - if (!entry.isEmergencyContact || _uiState.value.isSelf) return - _uiState.update { it.copy(actionInProgress = true) } - viewModelScope.launch { - try { - val response = contactRepository.clearEmergencyContact(entry.uniqueId, versionTag) - _events.tryEmit( - if (response != null) ContactDetailEvent.EmergencyContactRemoved - else ContactDetailEvent.Error - ) - } catch (e: ForbiddenException) { - _events.tryEmit(ContactDetailEvent.Forbidden) - } finally { - _uiState.update { it.copy(actionInProgress = false) } - } - } - } - /** * Best-effort server-side enrichment from the identity's public profile. The endpoint is * fire-and-forget (202 Accepted) and the enriched contact lands later via drive sync, so we diff --git a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/model/ContactBookEntry.kt b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/model/ContactBookEntry.kt index fb2e48cd0..8baa2e287 100644 --- a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/model/ContactBookEntry.kt +++ b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/model/ContactBookEntry.kt @@ -14,7 +14,7 @@ import id.homebase.api.client.contacts.ContactPhone import id.homebase.api.client.contacts.ContactSocialNetwork import id.homebase.api.client.contacts.resolveDisplayName import id.homebase.api.client.contacts.socialHandles -import id.homebase.core.contactbook.isEmergencyContact +import id.homebase.core.contactbook.iCanLocate import id.homebase.api.client.drives.files.PayloadDescriptor import id.homebase.api.client.drives.upload.EmbeddedThumb import id.homebase.core.image.HomebaseImageData @@ -63,8 +63,8 @@ data class ContactBookEntry( val shortBio: String? = null, /** Known social/gaming handles in render order, resolved from [ContactContent.social]. */ val socialHandles: List> = emptyList(), - /** Owner-only flag: this contact is one of our emergency contacts. */ - val isEmergencyContact: Boolean = false, + /** Owner-only flag: we can locate this contact in an emergency (they designated us). */ + val iCanLocate: Boolean = false, val source: String? = null, /** Pending (optimistic, not yet confirmed by the drive). */ val isPending: Boolean = false, @@ -207,7 +207,7 @@ fun Contact.toContactBookEntry(): ContactBookEntry? { status = content.status, shortBio = content.shortBio, socialHandles = content.socialHandles(), - isEmergencyContact = isEmergencyContact(), + iCanLocate = iCanLocate(), source = content.source, driveId = image?.driveId, keyHeader = image?.keyHeader, diff --git a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/location/LocationDashboardContent.kt b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/location/LocationDashboardContent.kt index d6e134cec..9c921982f 100644 --- a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/location/LocationDashboardContent.kt +++ b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/location/LocationDashboardContent.kt @@ -81,7 +81,7 @@ import id.homebase.resources.location_emergency_access_manage import id.homebase.resources.location_emergency_access_more import id.homebase.resources.location_emergency_access_none import id.homebase.resources.location_emergency_access_section -import id.homebase.resources.location_locatable_coming_soon +import id.homebase.resources.location_locatable_none import id.homebase.resources.location_locatable_section import id.homebase.resources.location_status_pending import id.homebase.resources.location_status_points_today @@ -300,24 +300,24 @@ fun LocationDashboardContent( } } - // ── Who you can locate (placeholder — reciprocal list, not built yet) ── + // ── Who you can locate (contacts carrying our iCanLocate flag) ── DashboardSection(title = stringResource(MR.string.location_locatable_section)) { - Text( - text = stringResource(MR.string.location_locatable_coming_soon), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.fillMaxWidth().padding(16.dp), + PeopleListBody( + loaded = uiState.whoICanLocateLoaded, + members = uiState.whoICanLocate, + emptyText = stringResource(MR.string.location_locatable_none), ) } - // ── Who can locate you (contacts marked as emergency contacts) ── + // ── Who can locate you (members of our emergency-location-access circle) ── DashboardSection( title = stringResource(MR.string.location_emergency_access_section), onManage = onManageEmergencyAccess, ) { - EmergencyAccessBody( - loaded = uiState.emergencyContactsLoaded, - members = uiState.emergencyContacts, + PeopleListBody( + loaded = uiState.whoCanLocateMeLoaded, + members = uiState.whoCanLocateMe, + emptyText = stringResource(MR.string.location_emergency_access_none), ) } @@ -430,13 +430,14 @@ private fun DashboardSection( } /** - * Body of the "Who can locate you" section: the members of the "Emergency Location - * Access" circle. Collapses to an avatar stack that expands to a named list. + * Body of a people-list dashboard section ("Who can locate you" / "Who you can locate"): a loading + * spinner, an [emptyText] empty state, or an avatar stack that expands to a named list. */ @Composable -private fun EmergencyAccessBody( +private fun PeopleListBody( loaded: Boolean, members: List, + emptyText: String, ) { var expanded by remember { mutableStateOf(false) } when { @@ -448,7 +449,7 @@ private fun EmergencyAccessBody( } members.isEmpty() -> Text( - text = stringResource(MR.string.location_emergency_access_none), + text = emptyText, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.fillMaxWidth().padding(16.dp), diff --git a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/location/LocationUiState.kt b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/location/LocationUiState.kt index 5e6c2837d..9399a3ef7 100644 --- a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/location/LocationUiState.kt +++ b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/location/LocationUiState.kt @@ -29,10 +29,15 @@ data class LocationUiState( // Dashboard state val devices: List = emptyList(), val todayTraces: List = emptyList(), - /** Contacts marked as emergency contacts (the "who can locate you" list on the dashboard). */ - val emergencyContacts: List = emptyList(), - /** False until the emergency-contacts list has loaded at least once (drives the loading spinner). */ - val emergencyContactsLoaded: Boolean = false, + /** Members of our emergency-location-access circle (the "who can locate you" list on the + * dashboard) — read from circle membership, the source of truth, not an app-data flag. */ + val whoCanLocateMe: List = emptyList(), + /** False until circle membership has loaded at least once (drives the loading spinner). */ + val whoCanLocateMeLoaded: Boolean = false, + /** Contacts we can locate (the `iCanLocate` app-data flag) — the "who you can locate" list. */ + val whoICanLocate: List = emptyList(), + /** False until the locatable-contacts list has loaded at least once (drives the spinner). */ + val whoICanLocateLoaded: Boolean = false, /** Owner-console deep link to manage the Emergency Location Access circle (the actual location * drive grant); null until the identity is known. */ val emergencyManageUrl: String? = null, diff --git a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/location/LocationViewModel.kt b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/location/LocationViewModel.kt index 2c0978cac..f6b6506c7 100644 --- a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/location/LocationViewModel.kt +++ b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/location/LocationViewModel.kt @@ -9,9 +9,13 @@ import id.homebase.chat.conversationlist.ExtendPermissionUiState import id.homebase.chat.conversationlist.ExtendPermissionViewModel import id.homebase.chat.data.ContactUiModel import id.homebase.chat.data.toContactUiModel +import id.homebase.chat.services.convo.contact.ConnectionService +import id.homebase.chat.services.convo.contact.ContactService import id.homebase.chat.services.livelocation.LiveLocationShareService +import id.homebase.core.config.EMERGENCY_LOCATION_CIRCLE_ID import id.homebase.core.config.locationLabeledDrive -import id.homebase.core.contactbook.emergencyContacts +import id.homebase.core.contactbook.EmergencyContactReconciler +import id.homebase.core.contactbook.locatableContacts import id.homebase.core.location.LocationPreferences import id.homebase.core.location.tracking.LocationPointStore import id.homebase.core.location.tracking.LocationTracker @@ -40,13 +44,6 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlin.time.Clock -/** - * Well-known GUID (N-format) of the circle whose members may see this identity's location - * in an emergency. Matching by id rather than name survives a rename; the owner-console - * "manage" deep link uses the same id. - */ -private const val EMERGENCY_CIRCLE_ID = "8b5383a5927246f8a666f4f3fcb7392b" - class LocationViewModel( private val locationPreferences: LocationPreferences, private val locationPermissionViewModel: ExtendPermissionViewModel, @@ -56,6 +53,9 @@ class LocationViewModel( private val uploaderService: LocationTrackUploaderService, private val deviceDirectory: LocationDeviceDirectory, private val contactRepository: ContactRepository, + private val connectionService: ConnectionService, + private val contactService: ContactService, + private val emergencyContactReconciler: EmergencyContactReconciler, private val credentialsManager: CredentialsManager, private val receiveStore: LiveLocationReceiveStore, private val liveShareService: LiveLocationShareService, @@ -98,26 +98,50 @@ class LocationViewModel( private var activationKicked = false init { - // "Who can locate you" = the contacts you've marked as emergency contacts (the app-data - // flag), resolved to display models. Reactive so marking/unmarking a contact updates the - // dashboard live. (emergencyContacts is a cold Flow; collecting it here makes it hot for - // the lifetime of this ViewModel.) + // "Who can locate you" = the members of our emergency-location-access circle. We host this + // circle on our OWN identity, so its membership is the source of truth — no app-data flag. + // Reactive: ConnectionService refreshes circle membership on the ConnectionChanged websocket + // event, so the dashboard updates live when the owner-console grants/revokes the circle. viewModelScope.launch { - // Exclude the logged-in identity: a self-contact can carry the flag (e.g. you marked - // your own contact), but you are never your own emergency contact for "who can locate me". - val self = runCatching { credentialsManager.getActiveDomain() }.getOrNull() - contactRepository.emergencyContacts + // Exclude the logged-in identity: you are never your own emergency contact. + val self = runCatching { credentialsManager.getActiveDomain() } + .getOrNull()?.domainName?.lowercase() + connectionService.circles.collect { circleState -> + val members = circleState.membersOf(EMERGENCY_LOCATION_CIRCLE_ID) + .asSequence() + .filterNot { it == self } + .mapNotNull { contactService.resolveByOdinId(OdinId(it)) } + .sortedBy { it.name.lowercase() } + .toList() + _uiState.update { + it.copy(whoCanLocateMe = members, whoCanLocateMeLoaded = circleState.isLoaded) + } + } + } + + // "Who you can locate" = the contacts carrying our `iCanLocate` app-data flag (set when they + // designated us via their emergency circle). The flag is a reactive cache; step 8 reconciles + // it against a temporal-access preflight. Reactive so a new designation appears live. + viewModelScope.launch { + val self = runCatching { credentialsManager.getActiveDomain() } + .getOrNull()?.domainName?.lowercase() + contactRepository.locatableContacts .map { list -> list.mapNotNull { it.toContactUiModel() } - .filterNot { it.odinId == self } + .filterNot { it.odinId.domainName.lowercase() == self } + .sortedBy { it.name.lowercase() } } .collect { members -> _uiState.update { - it.copy(emergencyContacts = members, emergencyContactsLoaded = true) + it.copy(whoICanLocate = members, whoICanLocateLoaded = true) } } } + // On dashboard open, reconcile the iCanLocate cache against the authoritative temporal-access + // grant so a lost revocation (stale flag) self-corrects. Best-effort, one-shot per open. + viewModelScope.launch { runCatching { emergencyContactReconciler.reconcile() } } + viewModelScope.launch { locationPermissionViewModel.permissionsGranted .filter { it } @@ -398,7 +422,7 @@ class LocationViewModel( // Emergency Location Access circle (the actual location-drive grant). val domain = runCatching { credentialsManager.getActiveCredentials()?.domain?.domainName } .getOrNull() - val manageUrl = domain?.let { "https://$it/owner/circles/$EMERGENCY_CIRCLE_ID" } + val manageUrl = domain?.let { "https://$it/owner/circles/$EMERGENCY_LOCATION_CIRCLE_ID" } _uiState.update { it.copy( diff --git a/homebase-core/src/jvmTest/kotlin/id/homebase/core/contactbook/EmergencyContactActionTest.kt b/homebase-core/src/jvmTest/kotlin/id/homebase/core/contactbook/EmergencyContactActionTest.kt new file mode 100644 index 000000000..9e0173419 --- /dev/null +++ b/homebase-core/src/jvmTest/kotlin/id/homebase/core/contactbook/EmergencyContactActionTest.kt @@ -0,0 +1,82 @@ +package id.homebase.core.contactbook + +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Pins the receive-side decision tables for [EmergencyContactReceiveService] — in particular the + * replay-guard rule that an unknown sender is synced but NOT consumed (so the flag still lands on a + * later delivery), while a known sender is consumed to neutralise re-deliveries. + */ +class EmergencyContactActionTest { + + // ── Designation ───────────────────────────────────────────────────────────── + + @Test + fun designation_unknownSender_syncsWithoutConsuming() { + // contact doesn't exist yet → sync only; do NOT consume (flag applies on a later delivery). + assertEquals( + DesignationAction.SyncOnly, + designationAction(contactExists = false, alreadyICanLocate = false, hasVersionTag = false), + ) + } + + @Test + fun designation_alreadyFlagged_consumesOnly() { + assertEquals( + DesignationAction.Consume, + designationAction(contactExists = true, alreadyICanLocate = true, hasVersionTag = true), + ) + } + + @Test + fun designation_knownUnflaggedWithVersion_setsThenConsumes() { + assertEquals( + DesignationAction.SetThenConsume, + designationAction(contactExists = true, alreadyICanLocate = false, hasVersionTag = true), + ) + } + + @Test + fun designation_knownUnflaggedNoVersion_isIgnored() { + // Can't write without a versionTag; leave it for a later delivery / reconcile. + assertEquals( + DesignationAction.Ignore, + designationAction(contactExists = true, alreadyICanLocate = false, hasVersionTag = false), + ) + } + + // ── Revocation ────────────────────────────────────────────────────────────── + + @Test + fun revocation_unknownSender_consumesOnly() { + assertEquals( + RevocationAction.Consume, + revocationAction(contactExists = false, currentlyICanLocate = false, hasVersionTag = false), + ) + } + + @Test + fun revocation_alreadyClear_consumesOnly() { + assertEquals( + RevocationAction.Consume, + revocationAction(contactExists = true, currentlyICanLocate = false, hasVersionTag = true), + ) + } + + @Test + fun revocation_flaggedWithVersion_clearsThenConsumes() { + assertEquals( + RevocationAction.ClearThenConsume, + revocationAction(contactExists = true, currentlyICanLocate = true, hasVersionTag = true), + ) + } + + @Test + fun revocation_flaggedNoVersion_isIgnored() { + assertEquals( + RevocationAction.Ignore, + revocationAction(contactExists = true, currentlyICanLocate = true, hasVersionTag = false), + ) + } +} diff --git a/homebase-core/src/jvmTest/kotlin/id/homebase/core/contactbook/EmergencyContactTest.kt b/homebase-core/src/jvmTest/kotlin/id/homebase/core/contactbook/EmergencyContactTest.kt index 5c5ec5ea7..b40fffb20 100644 --- a/homebase-core/src/jvmTest/kotlin/id/homebase/core/contactbook/EmergencyContactTest.kt +++ b/homebase-core/src/jvmTest/kotlin/id/homebase/core/contactbook/EmergencyContactTest.kt @@ -13,8 +13,8 @@ import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid /** - * Pins the read side of the app-data emergency flag: [Contact.isEmergencyContact] decodes only THIS - * app's slot ([AppConfig.APP_ID]) and tolerates absent/foreign/malformed data. + * Pins the read side of the app-data can-locate flag: [Contact.iCanLocate] decodes only THIS app's + * slot ([AppConfig.APP_ID]) and tolerates absent/foreign/malformed data. */ class EmergencyContactTest { @@ -25,28 +25,28 @@ class EmergencyContactTest { Contact(uniqueId = uid, versionTag = null, content = ContactContent(appData = appData)) @Test - fun absentAppData_isNotEmergency() { - assertFalse(contact(null).isEmergencyContact()) + fun absentAppData_isNotLocatable() { + assertFalse(contact(null).iCanLocate()) } @Test - fun ourSlotTrue_isEmergency() { - assertTrue(contact(mapOf(ourSlot to """{"isEmergencyContact":true}""")).isEmergencyContact()) + fun ourSlotTrue_isLocatable() { + assertTrue(contact(mapOf(ourSlot to """{"iCanLocate":true}""")).iCanLocate()) } @Test - fun ourSlotFalse_isNotEmergency() { - assertFalse(contact(mapOf(ourSlot to """{"isEmergencyContact":false}""")).isEmergencyContact()) + fun ourSlotFalse_isNotLocatable() { + assertFalse(contact(mapOf(ourSlot to """{"iCanLocate":false}""")).iCanLocate()) } @Test fun anotherAppsSlot_isNotReadAsOurs() { val other = "99999999-9999-9999-9999-999999999999" - assertFalse(contact(mapOf(other to """{"isEmergencyContact":true}""")).isEmergencyContact()) + assertFalse(contact(mapOf(other to """{"iCanLocate":true}""")).iCanLocate()) } @Test - fun malformedSlot_isNotEmergency() { - assertFalse(contact(mapOf(ourSlot to "not json {{{")).isEmergencyContact()) + fun malformedSlot_isNotLocatable() { + assertFalse(contact(mapOf(ourSlot to "not json {{{")).iCanLocate()) } } diff --git a/homebase-core/src/jvmTest/kotlin/id/homebase/core/location/EmergencyMembershipDeltaTest.kt b/homebase-core/src/jvmTest/kotlin/id/homebase/core/location/EmergencyMembershipDeltaTest.kt new file mode 100644 index 000000000..b0f30b771 --- /dev/null +++ b/homebase-core/src/jvmTest/kotlin/id/homebase/core/location/EmergencyMembershipDeltaTest.kt @@ -0,0 +1,65 @@ +package id.homebase.core.location + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Pins the membership-diff that drives [EmergencyCircleNotifier]: a null baseline never notifies + * (no login re-spam), and grants/revocations are the set difference. + */ +class EmergencyMembershipDeltaTest { + + @Test + fun nullBaseline_yieldsEmptyDelta() { + val delta = emergencyMembershipDelta(previous = null, current = setOf("sam.example", "amy.example")) + assertTrue(delta.granted.isEmpty(), "first loaded set must not be treated as new grants") + assertTrue(delta.revoked.isEmpty()) + } + + @Test + fun unchangedSet_yieldsEmptyDelta() { + val members = setOf("sam.example") + val delta = emergencyMembershipDelta(previous = members, current = members) + assertTrue(delta.granted.isEmpty()) + assertTrue(delta.revoked.isEmpty()) + } + + @Test + fun addedMember_isGranted() { + val delta = emergencyMembershipDelta( + previous = setOf("sam.example"), + current = setOf("sam.example", "amy.example"), + ) + assertEquals(setOf("amy.example"), delta.granted) + assertTrue(delta.revoked.isEmpty()) + } + + @Test + fun removedMember_isRevoked() { + val delta = emergencyMembershipDelta( + previous = setOf("sam.example", "amy.example"), + current = setOf("sam.example"), + ) + assertTrue(delta.granted.isEmpty()) + assertEquals(setOf("amy.example"), delta.revoked) + } + + @Test + fun simultaneousAddAndRemove_areBothReported() { + val delta = emergencyMembershipDelta( + previous = setOf("sam.example"), + current = setOf("amy.example"), + ) + assertEquals(setOf("amy.example"), delta.granted) + assertEquals(setOf("sam.example"), delta.revoked) + } + + @Test + fun emptyBaseline_thenMembers_areAllGranted() { + // An empty (but non-null) baseline is a real seeded state: everyone added since is a grant. + val delta = emergencyMembershipDelta(previous = emptySet(), current = setOf("sam.example")) + assertEquals(setOf("sam.example"), delta.granted) + assertTrue(delta.revoked.isEmpty()) + } +} From 4cbdb2556d6d97190f7208bd6ed9a1b72c834fea Mon Sep 17 00:00:00 2001 From: toddmitchell Date: Thu, 25 Jun 2026 11:21:47 -0500 Subject: [PATCH 20/23] Contacts: verify temporal locate access on Sync, set emergency flag + show data freshness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tapping Sync on the contact detail screen now also preflights temporal read access to the contact's location drive (verifyTemporalAccess — reads no data, fires no peer notification). On access, sets the iCanLocate "emergency contact" flag (adding them to the emergency list) and surfaces the newest-file timestamp as "Location data as of " under the badge. A definitive no-access clears a stale flag, mirroring EmergencyContactReconciler; network/parse failures are inconclusive and leave state untouched. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../composeResources/values/strings.xml | 1 + .../contactbook/detail/ContactDetailScreen.kt | 13 +++++ .../detail/ContactDetailUiState.kt | 8 +++ .../detail/ContactDetailViewModel.kt | 53 ++++++++++++++++++- 4 files changed, 74 insertions(+), 1 deletion(-) diff --git a/homebase-common/src/commonMain/composeResources/values/strings.xml b/homebase-common/src/commonMain/composeResources/values/strings.xml index 1e0f852c0..54f8748df 100644 --- a/homebase-common/src/commonMain/composeResources/values/strings.xml +++ b/homebase-common/src/commonMain/composeResources/values/strings.xml @@ -1411,6 +1411,7 @@ No recent media yet Contact details Emergency contact + Location data as of %1$s Bio Social Location diff --git a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailScreen.kt b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailScreen.kt index 9a332aa4a..c10479071 100644 --- a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailScreen.kt +++ b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailScreen.kt @@ -58,12 +58,14 @@ import id.homebase.core.connections.ConnectRequestBottomSheet import id.homebase.core.connections.ConnectRequestViewModel import id.homebase.core.ui.screens.contactbook.components.ContactBookAvatar import id.homebase.core.ui.screens.contactbook.components.ContactEditSheet +import id.homebase.core.util.formatTimestamp import id.homebase.resources.MR import id.homebase.resources.cancel import id.homebase.resources.contactbook_action_blocked import id.homebase.resources.contactbook_action_sync_started import id.homebase.resources.contactbook_action_disconnected import id.homebase.resources.contactbook_detail_emergency_badge +import id.homebase.resources.contactbook_detail_location_data_as_of import id.homebase.resources.contactbook_action_unblocked import id.homebase.resources.contactbook_connected import id.homebase.resources.contactbook_detail_block @@ -90,6 +92,7 @@ import id.homebase.resources.contactbook_error_photo import id.homebase.resources.contactbook_error_save import id.homebase.resources.menu_back import org.jetbrains.compose.resources.stringResource +import kotlin.time.Instant import kotlin.uuid.Uuid @Composable @@ -371,6 +374,16 @@ private fun DetailHeader( color = MaterialTheme.colorScheme.error, ) } + // Freshness of the data we can see, from the last Sync-time temporal-access preflight. + uiState.locateNewestDataAt?.let { newest -> + val asOf = formatTimestamp(Instant.fromEpochMilliseconds(newest.milliseconds)) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = stringResource(MR.string.contactbook_detail_location_data_as_of, asOf), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } when { diff --git a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailUiState.kt b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailUiState.kt index 885e42b1d..3d4e42550 100644 --- a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailUiState.kt +++ b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailUiState.kt @@ -4,6 +4,7 @@ package id.homebase.core.ui.screens.contactbook.detail import androidx.compose.runtime.Immutable import id.homebase.api.client.connections.ConnectionStatus +import id.homebase.api.common.time.UnixTimeUtc import id.homebase.chat.conversationsettings.ConversationOverview import id.homebase.chat.conversationsettings.GroupInCommonItem import id.homebase.chat.conversationsettings.SharedMediaItem @@ -35,6 +36,13 @@ data class ContactDetailUiState( val actionInProgress: Boolean = false, /** True when this contact is the logged-in identity's own (self) contact. */ val isSelf: Boolean = false, + /** + * `modified` timestamp of the newest file on this contact's location drive, captured by the last + * temporal-access preflight (run on Sync). Non-null only after a successful verify that returned + * access AND real data — it's how the user judges "how fresh is the data I can see?". Null when we + * haven't verified, have no access, or the drive is empty. + */ + val locateNewestDataAt: UnixTimeUtc? = null, ) { val hasOdinId: Boolean get() = !entry?.odinId.isNullOrBlank() val isConnected: Boolean get() = connectionStatus == ConnectionStatus.Connected diff --git a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailViewModel.kt b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailViewModel.kt index 7a2dbd809..6499ccd98 100644 --- a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailViewModel.kt +++ b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailViewModel.kt @@ -11,6 +11,7 @@ import id.homebase.api.client.ForbiddenException import id.homebase.api.client.auth.CredentialsManager import id.homebase.api.client.auth.OwnerSessionRepository import id.homebase.api.client.connections.ConnectionNetworkProvider +import id.homebase.api.client.peer.temporal.TemporalDriveReadProvider import id.homebase.api.common.OdinId import id.homebase.chat.conversationsettings.GroupInCommonItem import id.homebase.chat.conversationsettings.collectConversationOverview @@ -21,6 +22,9 @@ import id.homebase.chat.services.convo.contact.ConnectionService import id.homebase.api.client.contacts.ContactRepository import id.homebase.core.config.AUTO_CONNECTIONS_CIRCLE_ID import id.homebase.core.config.CONFIRMED_CONNECTIONS_CIRCLE_ID +import id.homebase.core.config.locationLabeledDrive +import id.homebase.core.contactbook.clearICanLocate +import id.homebase.core.contactbook.setICanLocate import id.homebase.core.ui.navigation.Route import id.homebase.core.ui.screens.contactbook.model.ContactBookEntry import id.homebase.core.ui.screens.contactbook.ContactSaveResult @@ -55,10 +59,14 @@ class ContactDetailViewModel( private val connectionNetworkProvider: ConnectionNetworkProvider, private val ownerSessionRepository: OwnerSessionRepository, private val credentialsManager: CredentialsManager, + private val temporalDriveReadProvider: TemporalDriveReadProvider, ) : ViewModel() { private val route = savedStateHandle.toRoute() + /** The drive whose temporal grant gates "can I locate this contact in an emergency?". */ + private val locationDrive = locationLabeledDrive.drive.alias + private val _uiState = MutableStateFlow(ContactDetailUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -205,8 +213,51 @@ class ContactDetailViewModel( */ private fun handleSync() { val domain = odinId ?: return + val peer = OdinId(domain) _events.tryEmit(ContactDetailEvent.SyncStarted) - viewModelScope.launch { contactRepository.sync(OdinId(domain)) } + viewModelScope.launch { + contactRepository.sync(peer) + verifyLocateAccess(peer) + } + } + + /** + * Preflight whether we currently hold temporal read access to [peer]'s location drive (reads no + * data, fires no notification on the peer) and reconcile the cached `iCanLocate` "emergency + * contact" flag against that authoritative answer: + * - access → set the flag (add them to the emergency list) and surface the newest-data timestamp. + * - definitively no access → clear a stale flag. + * - network/parse failure → inconclusive; leave the flag and the timestamp untouched. + * + * The flag write needs the contact's [ContactBookEntry.versionTag]; a synthetic entry (deep-link + * with no stored contact) has none, so we can still show freshness but can't persist the flag. + */ + private suspend fun verifyLocateAccess(peer: OdinId) { + val status = runCatching { temporalDriveReadProvider.verifyTemporalAccess(peer, locationDrive) } + .onFailure { Logger.w(it, TAG) { "verifyTemporalAccess failed for ${peer.domainName}" } } + .getOrNull() ?: return + + val entry = _uiState.value.entry + val versionTag = entry?.versionTag + + if (status.hasAccess) { + // newestFileModified is only a real timestamp when we have access; 0 (ZeroTime) means the + // drive has no files yet — render "no data", not the epoch. + val newest = status.newestFileModified.takeIf { it.milliseconds > 0 } + _uiState.update { it.copy(locateNewestDataAt = newest) } + if (entry != null && versionTag != null && !entry.iCanLocate) { + runCatching { contactRepository.setICanLocate(entry.uniqueId, versionTag) } + .onFailure { Logger.w(it, TAG) { "setICanLocate failed for ${peer.domainName}" } } + } + } else { + _uiState.update { it.copy(locateNewestDataAt = null) } + // A successful verify with hasAccess=false is authoritative: clear a stale flag, mirroring + // EmergencyContactReconciler. Skip the write when the flag isn't set (nothing to clear). + if (entry != null && versionTag != null && entry.iCanLocate) { + runCatching { contactRepository.clearICanLocate(entry.uniqueId, versionTag) } + .onFailure { Logger.w(it, TAG) { "clearICanLocate failed for ${peer.domainName}" } } + } + } } private fun handleSave(action: ContactDetailAction.SaveContact) { From 448b3ce1188f54a85b401144edaf9680a3d78d58 Mon Sep 17 00:00:00 2001 From: toddmitchell Date: Thu, 25 Jun 2026 21:45:49 -0500 Subject: [PATCH 21/23] Location: fix background-permission Grant loop, route to Settings + clearer copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Android 11+ the "Allow all the time" (background) permission can't be granted by re-firing the runtime dialog — after the first non-grant the OS silently auto-denies (a brief GrantPermissionsActivity flash), so the Setup row kept showing "Grant" (permanentlyDenied stays false in the "ask every time" state) and looped, never routing to Settings. Fix: latch alwaysRequestAttempted when the user taps Grant; once set, the row shows "Open settings" instead of re-offering Grant. The latch clears when the grant lands so a later revoke starts fresh. Also clarify the copy: rename the row to "Allow all the time" (matches Android's own label), reword the default hint, and add a settings-specific hint that tells the user to choose "Allow all the time" in system Settings. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../composeResources/values/strings.xml | 5 +++-- .../core/ui/screens/location/LocationContent.kt | 17 +++++++++++++++-- .../core/ui/screens/location/LocationScreen.kt | 3 +++ .../core/ui/screens/location/LocationUiState.kt | 8 ++++++++ .../ui/screens/location/LocationViewModel.kt | 17 ++++++++++++++++- 5 files changed, 45 insertions(+), 5 deletions(-) diff --git a/homebase-common/src/commonMain/composeResources/values/strings.xml b/homebase-common/src/commonMain/composeResources/values/strings.xml index 65cff5e4b..586b77c38 100644 --- a/homebase-common/src/commonMain/composeResources/values/strings.xml +++ b/homebase-common/src/commonMain/composeResources/values/strings.xml @@ -1277,11 +1277,12 @@ Tracking isn't available on this device. Enable it on your phone to record location history. Permissions Location while using the app - Location all the time (background) + Allow all the time Granted Grant Open settings - Without this, locations are only recorded while the app is open. + Needed to record your location when the app is in the background or closed. + Android won't ask again — tap Open settings, then choose "Allow all the time" for Location. Status Last fix Points today diff --git a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/location/LocationContent.kt b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/location/LocationContent.kt index 70bf060a4..afcbda954 100644 --- a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/location/LocationContent.kt +++ b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/location/LocationContent.kt @@ -34,6 +34,7 @@ import id.homebase.resources.location_map_osm import id.homebase.resources.location_map_section import id.homebase.resources.location_perm_always import id.homebase.resources.location_perm_always_hint +import id.homebase.resources.location_perm_always_settings_hint import id.homebase.resources.location_perm_grant import id.homebase.resources.location_perm_granted import id.homebase.resources.location_perm_open_settings @@ -122,12 +123,23 @@ fun LocationContent( // Android requires foreground location before background // may even be requested; iOS escalation likewise. requestEnabled = uiState.whileInUseGranted, + // After the first ungranted background attempt the runtime dialog won't + // reappear (Android 11+), so route to Settings rather than re-offer Grant. + forceSettings = uiState.alwaysRequestAttempted, onRequest = { onAction(LocationUiAction.RequestAlwaysClicked) }, onOpenSettings = { onAction(LocationUiAction.OpenSystemSettingsClicked) }, ) if (!uiState.alwaysGranted) { Text( - text = stringResource(MR.string.location_perm_always_hint), + // Once the runtime dialog won't reappear, guide the user to the + // "Allow all the time" toggle in system Settings instead. + text = stringResource( + if (uiState.alwaysRequestAttempted) { + MR.string.location_perm_always_settings_hint + } else { + MR.string.location_perm_always_hint + } + ), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 12.dp), @@ -237,6 +249,7 @@ private fun PermissionRow( requestEnabled: Boolean, onRequest: () -> Unit, onOpenSettings: () -> Unit, + forceSettings: Boolean = false, ) { Row( modifier = Modifier @@ -257,7 +270,7 @@ private fun PermissionRow( color = MaterialTheme.colorScheme.primary, ) - permanentlyDenied -> Button(onClick = onOpenSettings) { + permanentlyDenied || forceSettings -> Button(onClick = onOpenSettings) { Text(stringResource(MR.string.location_perm_open_settings)) } diff --git a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/location/LocationScreen.kt b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/location/LocationScreen.kt index 29bb07a02..e214652ae 100644 --- a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/location/LocationScreen.kt +++ b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/location/LocationScreen.kt @@ -137,6 +137,9 @@ fun LocationScreen( LocationUiAction.RequestAlwaysClicked -> { viewModel.armTrackingAutoEnableOnGrant() + // Latch the attempt so that, if this request is silently denied (Android 11+ never + // re-shows the background dialog), the row routes to Settings instead of looping. + viewModel.markAlwaysRequested() permissionsManager.askPermission(PermissionType.LOCATION_ALWAYS) } diff --git a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/location/LocationUiState.kt b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/location/LocationUiState.kt index 9399a3ef7..c2f0d7ce2 100644 --- a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/location/LocationUiState.kt +++ b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/location/LocationUiState.kt @@ -20,6 +20,14 @@ data class LocationUiState( val whileInUsePermanentlyDenied: Boolean = false, val alwaysGranted: Boolean = false, val alwaysPermanentlyDenied: Boolean = false, + /** + * True once the user has tapped Grant on the background ("always") permission at least once this + * session without it being granted. On Android 11+ background location can't be granted by + * re-firing the runtime dialog — the OS silently denies repeat requests (the "flash") — so after + * the first attempt the Setup row routes to system Settings ("Allow all the time") instead of + * re-offering Grant. Cleared when the grant lands so a later revoke starts a fresh attempt. + */ + val alwaysRequestAttempted: Boolean = false, val lastFixEpochMs: Long? = null, val lastFixLat: Double? = null, val lastFixLon: Double? = null, diff --git a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/location/LocationViewModel.kt b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/location/LocationViewModel.kt index f6b6506c7..5e55126e6 100644 --- a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/location/LocationViewModel.kt +++ b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/location/LocationViewModel.kt @@ -375,6 +375,16 @@ class LocationViewModel( pendingTrackingAutoEnable = true } + /** + * Latch that the user has tried the background ("always") grant. Set when the Grant button is + * tapped — not when the result arrives — so a passive launch-time recheck (which also reports + * "not granted") never pre-empts the first real attempt. Once latched, the Setup row routes to + * Settings until the grant lands (see [LocationUiState.alwaysRequestAttempted]). + */ + fun markAlwaysRequested() { + _uiState.update { it.copy(alwaysRequestAttempted = true) } + } + fun updateWhileInUseStatus(granted: Boolean, permanentlyDenied: Boolean) { _uiState.update { it.copy(whileInUseGranted = granted, whileInUsePermanentlyDenied = permanentlyDenied) @@ -384,7 +394,12 @@ class LocationViewModel( fun updateAlwaysStatus(granted: Boolean, permanentlyDenied: Boolean) { _uiState.update { - it.copy(alwaysGranted = granted, alwaysPermanentlyDenied = permanentlyDenied) + it.copy( + alwaysGranted = granted, + alwaysPermanentlyDenied = permanentlyDenied, + // A successful grant clears the attempt latch so a future revoke starts fresh. + alwaysRequestAttempted = if (granted) false else it.alwaysRequestAttempted, + ) } maybeAutoEnableTracking(granted) } From eb4e707240ec1d925afc89a5572ed02cc346ef8e Mon Sep 17 00:00:00 2001 From: toddmitchell Date: Fri, 26 Jun 2026 10:57:47 -0500 Subject: [PATCH 22/23] Fix iOS framework link crash; render nothing for consumed status messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Disable kotlin.incremental.native: on Kotlin 2.3.21 the per-module incremental-native bitcode cache crashes the K/N backend with "Lowering ReturnsInsertion: phases [Enums] are required, but not satisfied" during :homebase-core:linkDebugFrameworkIosArm64 when an unchanged downstream module (homebase-auth) is rebuilt against changed dependencies (homebase-api/common). It's a compiler cache-codegen phase bug, not our code; the cache is build-speed only and framework output is identical with it off. MessageMapper: a soft-deleted status message (e.g. an emergency-contact designation consumed by the receiver) should render nothing, not a "Deleted File" tombstone — the user never authored or saw it. Co-Authored-By: Claude Opus 4.8 (1M context) --- gradle.properties | 11 ++++++++++- .../kotlin/id/homebase/chat/services/MessageMapper.kt | 9 +++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 6f2ad95fc..984a8e1ca 100644 --- a/gradle.properties +++ b/gradle.properties @@ -8,7 +8,16 @@ kotlin.mpp.enableCInteropCommonization=true kotlin.mpp.enableIntransitiveMetadataConfiguration=true org.jetbrains.compose.experimental.uikit.enabled=true # --- Kotlin/Native Optimizations --- -kotlin.incremental.native=true +# Disabled on Kotlin 2.3.21: building the per-module incremental-native bitcode +# cache crashes the K/N backend with +# "Lowering ReturnsInsertion: phases [Enums] are required, but not satisfied" +# during :homebase-core:linkDebugFrameworkIosArm64 (the ReturnsInsertion lowering +# runs before the Enums lowering it depends on while caching the homebase-auth +# klib). It's a compiler codegen-phase bug in the cache path, not our code — the +# compiler itself recommends this flag. The cache is a build-speed optimization +# only; framework output is identical with it off. Re-enable once K/N fixes the +# phase ordering. https://kotl.in/issue +kotlin.incremental.native=false kotlin.native.disableCompilerDaemon=false kotlin.native.linkerOptions=-dead_strip kotlin.native.cocoapods.generate.wrapper=true diff --git a/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/MessageMapper.kt b/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/MessageMapper.kt index 4dac2b432..35ae23113 100644 --- a/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/MessageMapper.kt +++ b/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/MessageMapper.kt @@ -131,6 +131,15 @@ suspend fun mapToMessageData( val isDeleted = header.isSoftDeleted() if (isDeleted) { + // A consumed status (system) message leaves no trace: unlike a deleted user + // message — where the "This message was deleted" tombstone is the point — a status + // message such as an emergency-contact designation is soft-deleted by the receiver + // purely to neutralise re-delivery (EmergencyContactReceiveService.consume). The user + // never authored or saw it, so render nothing rather than a "Deleted File" tombstone. + // appData survives the local soft-delete (the branch below reads groupId/uniqueId/ + // userDate from it), so isStatusMessage (appData.dataType) is reliable here. + if (isStatusMessage) return null + val deletedUserDate = if (appData.userDate == null) metadata.created else From 9e11346df72a37808b4d5d4d597d29a24d87a0b3 Mon Sep 17 00:00:00 2001 From: toddmitchell Date: Fri, 26 Jun 2026 11:34:22 -0500 Subject: [PATCH 23/23] Disconnect connection when deleting a connected contact Deleting a contact only removed the address-book record, leaving a connected identity's connection (and the drive access it granted) live. Delete now tears down the connection first when the contact is connected, and the confirm prompt warns about it. - handleConfirm() disconnects before delete for connected contacts, aborting (and surfacing the connection error) if disconnect fails so we never drop the record while the connection lingers. - New contactbook_detail_delete_message_connected string; ConfirmDialog shows it when isConnected. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../commonMain/composeResources/values/strings.xml | 1 + .../contactbook/detail/ContactDetailScreen.kt | 7 ++++++- .../contactbook/detail/ContactDetailViewModel.kt | 14 ++++++++++++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/homebase-common/src/commonMain/composeResources/values/strings.xml b/homebase-common/src/commonMain/composeResources/values/strings.xml index c3e938f68..f9f45224c 100644 --- a/homebase-common/src/commonMain/composeResources/values/strings.xml +++ b/homebase-common/src/commonMain/composeResources/values/strings.xml @@ -1443,6 +1443,7 @@ This permanently removes your connection and the access it granted. They won't be notified. To reconnect, you'll need to send a new connection request. Delete contact? This removes the contact from your address book. + This removes the contact from your address book and disconnects your connection, removing the access it granted. They won't be notified. To reconnect, you'll need to send a new connection request. Danger zone New contact diff --git a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailScreen.kt b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailScreen.kt index c10479071..101a9b475 100644 --- a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailScreen.kt +++ b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailScreen.kt @@ -75,6 +75,7 @@ import id.homebase.resources.contactbook_detail_blocked import id.homebase.resources.contactbook_detail_connect import id.homebase.resources.contactbook_detail_delete import id.homebase.resources.contactbook_detail_delete_message +import id.homebase.resources.contactbook_detail_delete_message_connected import id.homebase.resources.contactbook_detail_delete_title import id.homebase.resources.contactbook_detail_disconnect import id.homebase.resources.contactbook_detail_disconnect_message @@ -285,6 +286,7 @@ fun ContactDetailScreen( uiState.confirm?.let { confirm -> ConfirmDialog( confirm = confirm, + isConnected = uiState.isConnected, onConfirm = { viewModel.onAction(ContactDetailAction.ConfirmYes) }, onDismiss = { viewModel.onAction(ContactDetailAction.ConfirmDismiss) }, ) @@ -416,6 +418,7 @@ private fun DetailHeader( @Composable private fun ConfirmDialog( confirm: ContactDetailConfirm, + isConnected: Boolean, onConfirm: () -> Unit, onDismiss: () -> Unit, ) { @@ -432,7 +435,9 @@ private fun ConfirmDialog( ) ContactDetailConfirm.DELETE -> Triple( MR.string.contactbook_detail_delete_title, - MR.string.contactbook_detail_delete_message, + // Deleting a connected contact also tears down the connection — warn about that. + if (isConnected) MR.string.contactbook_detail_delete_message_connected + else MR.string.contactbook_detail_delete_message, MR.string.contactbook_detail_delete, ) } diff --git a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailViewModel.kt b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailViewModel.kt index 6499ccd98..22be6f4a4 100644 --- a/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailViewModel.kt +++ b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailViewModel.kt @@ -316,6 +316,7 @@ class ContactDetailViewModel( val confirm = _uiState.value.confirm ?: return val entry = _uiState.value.entry val domain = odinId + val wasConnected = _uiState.value.isConnected _uiState.update { it.copy(confirm = null, actionInProgress = true) } viewModelScope.launch { try { @@ -325,6 +326,19 @@ class ContactDetailViewModel( _events.tryEmit(ContactDetailEvent.Back) return@launch } + // A connected contact must be disconnected before its record is removed — + // otherwise deleting the address-book entry leaves the connection (and the + // access it granted) live. Tear that down first; abort the delete if it + // fails so we don't silently drop the contact while the connection lingers. + if (wasConnected && domain != null) { + val disconnected = runCatching { + connectionNetworkProvider.disconnect(OdinId(domain)) + } + .onSuccess { connectionService.refresh() } + .onFailure { emitConnectionError(it) } + .isSuccess + if (!disconnected) return@launch + } // repo.delete does the optimistic remove and restores on failure. val event = try { if (contactRepository.delete(entry.uniqueId)) ContactDetailEvent.Back