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-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..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 @@ -27,6 +27,19 @@ 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, + /** + * 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. */ @@ -66,10 +79,15 @@ fun HomebaseFile.toContact(): Contact? { ) } + val payloadKeys = fileMetadata.payloads?.mapTo(HashSet()) { it.key } ?: emptySet() + return Contact( uniqueId = uniqueId, versionTag = fileMetadata.versionTag, content = content, image = image, + fileId = fileId, + keyHeader = keyHeader, + 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..525011dfb --- /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(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 { + 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 1d44c2c9f..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 @@ -28,6 +28,30 @@ 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 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 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, + /** + * 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 @@ -38,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/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/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-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 fbcf2de55..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 @@ -3,15 +3,18 @@ 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.HomebaseFile import id.homebase.api.client.drives.QueryBatchSortField import id.homebase.api.client.drives.QueryBatchSortOrder 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.serialization.OdinSystemSerializer import id.homebase.api.sync.database.DatabaseManager import id.homebase.api.sync.database.QueryBatch import kotlinx.coroutines.CoroutineScope @@ -41,6 +44,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, @@ -56,8 +60,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 +86,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 +121,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 +160,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) } } @@ -163,6 +189,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 (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 (ContactsProvider.CONTACT_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_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 // ------------------------------------------------------------ @@ -189,7 +250,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 +261,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 +269,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 +282,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) { @@ -254,4 +320,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 57d1eeece..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 @@ -54,6 +54,12 @@ 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" + + /** 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, @@ -128,6 +134,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 @@ -233,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 // ------------------------------------------------------------ @@ -292,10 +392,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/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/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/ContactContentSerializationTest.kt b/homebase-api/src/jvmTest/kotlin/id/homebase/api/client/contacts/ContactContentSerializationTest.kt index 016ac3079..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( 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/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 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..6e1a1aa8b --- /dev/null +++ b/homebase-api/src/jvmTest/kotlin/id/homebase/api/client/contacts/ContactRepositoryTest.kt @@ -0,0 +1,398 @@ +@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 }) + // No ext_data tests here, so the payload reader is never invoked. + val payloadReader = ContactPayloadReader { _, _, _, _ -> null } + val scope = backgroundScope + UnconfinedTestDispatcher(testScheduler) + return ContactRepository(provider, payloadReader, 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()) + } +} 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-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-chat/src/commonMain/kotlin/id/homebase/chat/conversationlist/ConversationLifecycleHandler.kt b/homebase-chat/src/commonMain/kotlin/id/homebase/chat/conversationlist/ConversationLifecycleHandler.kt index f93c55cfc..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 @@ -161,7 +161,16 @@ 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. + // `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( uiEvent = NavigateToGroupSettings( @@ -169,6 +178,8 @@ internal class ConversationLifecycleHandler( ) ) } + } else if (!conversation.isWithSelf && peerOdinId != null) { + uiState.update { it.copy(uiEvent = NavigateToContactInfo(peerOdinId)) } } else { uiState.update { it.copy( 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/MessageMapper.kt b/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/MessageMapper.kt index ad8d0cb13..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 @@ -44,6 +44,12 @@ 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.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 @@ -125,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 @@ -524,5 +539,25 @@ 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) + } + + 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 f1f0b8f98..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 @@ -36,4 +36,15 @@ 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 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 88f374718..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 @@ -381,6 +381,75 @@ 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 + } + } + + /** + * 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 ba427be25..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 @@ -128,6 +128,22 @@ 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 + + /** + * 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 @@ -401,6 +417,46 @@ 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 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") @@ -408,6 +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, + // 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-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-common/src/commonMain/composeResources/values/strings.xml b/homebase-common/src/commonMain/composeResources/values/strings.xml index 2088d2b9e..f9f45224c 100644 --- a/homebase-common/src/commonMain/composeResources/values/strings.xml +++ b/homebase-common/src/commonMain/composeResources/values/strings.xml @@ -486,6 +486,12 @@ 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 + %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 @@ -1284,11 +1290,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 @@ -1302,7 +1309,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. @@ -1417,6 +1424,11 @@ Recent media No recent media yet Contact details + Emergency contact + Location data as of %1$s + Bio + Social + Location Circles Not in any of your circles yet. Connect with this person to add them to your circles. @@ -1431,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 @@ -1463,6 +1476,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-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-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 a9236a503..8ecbc23b0 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/contactbook/EmergencyContact.kt b/homebase-core/src/commonMain/kotlin/id/homebase/core/contactbook/EmergencyContact.kt new file mode 100644 index 000000000..7e163df66 --- /dev/null +++ b/homebase-core/src/commonMain/kotlin/id/homebase/core/contactbook/EmergencyContact.kt @@ -0,0 +1,79 @@ +@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( + /** + * 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 we can locate this contact (the cached [ChatContactAppData.iCanLocate] flag). */ +fun Contact.iCanLocate(): Boolean = chatAppData()?.iCanLocate == true + +/** + * 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.locatableContacts: Flow> + get() = contacts.map { list -> list.filter { it.iCanLocate() } } + +private fun Contact.chatAppData(): ChatContactAppData? = + appDataFor(AppConfig.APP_ID)?.let { + runCatching { OdinSystemSerializer.deserialize(it) }.getOrNull() + } + +/** + * 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.setICanLocate(uniqueId: Uuid, versionTag: Uuid): ContactWriteResponse? = + writeICanLocateFlag(uniqueId, versionTag, canLocate = true) + +/** 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.writeICanLocateFlag( + uniqueId: Uuid, + versionTag: Uuid, + canLocate: Boolean, +): ContactWriteResponse? { + val current = contacts.value.firstOrNull { it.uniqueId == uniqueId }?.chatAppData() + ?: ChatContactAppData() + 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) + } else { + setAppData(uniqueId, AppConfig.APP_ID, OdinSystemSerializer.serialize(updated), 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 48c336e20..bc7389d57 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 @@ -66,7 +65,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 @@ -74,8 +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.core.ui.screens.contactbook.ContactBookService -import id.homebase.core.ui.screens.contactbook.ContactBookStream +import id.homebase.api.client.contacts.ContactRepository +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 @@ -149,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 @@ -220,8 +220,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()) } @@ -462,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 -> @@ -483,6 +485,19 @@ val appModule = module { } // endregion + // 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 + // region Auto-unarchive: incoming message for archived conversation conversationStream.onUnarchiveConversation = { conversationId -> conversationService.unarchiveConversation(conversationId) @@ -494,7 +509,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() } @@ -537,7 +552,9 @@ val appModule = module { singleOf(::ConnectionCacheRepository) singleOf(::ConnectionService) - singleOf(::DriveContactService) + singleOf(::EmergencyCircleNotifier) + singleOf(::EmergencyContactReceiveService) + singleOf(::EmergencyContactReconciler) singleOf(::ContactService) singleOf(::ConversationStream) bind ConversationLoader::class single { get() } @@ -731,7 +748,6 @@ val appModule = module { viewModelOf(::CreateConversationGroupViewModel) viewModelOf(::SelectMembersViewModel) viewModelOf(::MessageInfoViewModel) - viewModelOf(::ContactInfoViewModel) viewModelOf(::ConversationSettingsViewModel) viewModelOf(::ConversationMediaViewModel) viewModelOf(::GroupSettingsViewModel) @@ -767,8 +783,10 @@ val appModule = module { pointStore = get(), uploaderService = get(), deviceDirectory = get(), - connectionNetworkProvider = 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/navigation/AppNavHost.kt b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/navigation/AppNavHost.kt index dfbd7396d..84adeb083 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 @@ -891,7 +891,14 @@ fun AppNavHost( // permission) — covers both "not set up" gate-fail cases. onNavigateToLocationSetup = openLocation, 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)) @@ -1002,15 +1009,6 @@ fun AppNavHost( } } - composable { - if (isAuthenticated) { - ContactInfoScreen( - viewModel = koinViewModel(), - onNavigateBack = { navController.popBackStack() }, - ) - } - } - composable { if (isAuthenticated) { MessageInfoScreen( @@ -1086,7 +1084,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)) 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/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/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 b9cde4330..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 @@ -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,12 +304,15 @@ 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)) } + if (result.clearedFieldsIgnored) { + _events.tryEmit(ContactBookUiEvent.Error(ContactBookError.ClearUnsupported)) + } } ContactSaveResult.Forbidden -> _events.tryEmit(ContactBookUiEvent.Error(ContactBookError.SaveForbidden)) @@ -334,7 +344,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 +382,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..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 @@ -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,16 @@ 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. [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 @@ -32,18 +41,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 @@ -51,26 +58,64 @@ 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 }) + // 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 = 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 { - service.save( + repo.save( content = content, knownUniqueId = editing?.uniqueId, knownVersionTag = editing?.versionTag, @@ -80,37 +125,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, clearedFieldsIgnored) } private suspend fun uploadContactPhoto( - service: ContactBookService, + repo: ContactRepository, uniqueId: Uuid, versionTag: Uuid, photo: PlatformFile, - contactDriveId: Uuid, ): Boolean { val bytes = try { photo.readBytes() @@ -121,9 +145,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/ContactDetailScreen.kt b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailScreen.kt index f3be77c86..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 @@ -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 @@ -57,11 +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 @@ -71,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 @@ -83,10 +88,12 @@ 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 import org.jetbrains.compose.resources.stringResource +import kotlin.time.Instant import kotlin.uuid.Uuid @Composable @@ -102,6 +109,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 +133,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) @@ -193,6 +203,9 @@ fun ContactDetailScreen( onToggleMore = { detailsExpanded = !detailsExpanded }, ) + BioSection(entry.shortBio) + SocialSection(entry.socialHandles) + Spacer(modifier = Modifier.height(28.dp)) RecentMediaSection( overview = uiState.overview, @@ -273,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) }, ) @@ -312,6 +326,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 { @@ -333,6 +358,36 @@ private fun DetailHeader( ) } + // Emergency-contact indicator — visible whenever this contact is one of our emergency + // contacts (independent of connection state). + if (entry.iCanLocate) { + 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, + ) + } + // 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 { connected -> { Spacer(modifier = Modifier.height(12.dp)) @@ -363,6 +418,7 @@ private fun DetailHeader( @Composable private fun ConfirmDialog( confirm: ContactDetailConfirm, + isConnected: Boolean, onConfirm: () -> Unit, onDismiss: () -> Unit, ) { @@ -379,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/ContactDetailSections.kt b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/contactbook/detail/ContactDetailSections.kt index f9f4ceb39..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 @@ -18,14 +18,17 @@ 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 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 @@ -46,13 +49,23 @@ 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 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 @@ -70,7 +83,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?, @@ -78,6 +96,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), @@ -88,21 +111,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), @@ -111,6 +127,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), + ) } } @@ -222,11 +246,29 @@ 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 { - 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 { + // 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()) { Text( @@ -239,7 +281,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( @@ -256,6 +298,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( @@ -270,8 +353,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, @@ -325,10 +410,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) }, ) } 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..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 @@ -33,6 +34,15 @@ 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, + /** + * `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 @@ -73,6 +83,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 665fe02b5..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 @@ -8,8 +8,10 @@ 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.client.peer.temporal.TemporalDriveReadProvider import id.homebase.api.common.OdinId import id.homebase.chat.conversationsettings.GroupInCommonItem import id.homebase.chat.conversationsettings.collectConversationOverview @@ -17,17 +19,20 @@ 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.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.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,18 +51,22 @@ 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, private val connectionService: ConnectionService, 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() @@ -69,10 +78,13 @@ 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 { + val selfDomain = runCatching { credentialsManager.getActiveDomain()?.domainName }.getOrNull() combine( - contactBookStream.contacts, + contactRepository.contacts.map { list -> list.mapNotNull { it.toContactBookEntry() } }, connectionService.connections, connectionService.circles, ) { contacts, conn, circ -> @@ -81,6 +93,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 @@ -105,6 +118,7 @@ class ContactDetailViewModel( connectionStatus = status, circles = circleNames, isLoading = false, + isSelf = isSelf, ) } } @@ -199,8 +213,51 @@ class ContactDetailViewModel( */ private fun handleSync() { val domain = odinId ?: return + val peer = OdinId(domain) _events.tryEmit(ContactDetailEvent.SyncStarted) - viewModelScope.launch { contactBookService.syncFromIdentity(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) { @@ -208,15 +265,17 @@ 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) + if (result.clearedFieldsIgnored) { + _events.tryEmit(ContactDetailEvent.ClearUnsupported) + } } ContactSaveResult.Forbidden -> _events.tryEmit(ContactDetailEvent.Forbidden) ContactSaveResult.Failed -> _events.tryEmit(ContactDetailEvent.Error) @@ -257,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 { @@ -266,18 +326,24 @@ class ContactDetailViewModel( _events.tryEmit(ContactDetailEvent.Back) return@launch } - contactBookStream.removeOptimistic(entry.uniqueId) - 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 + // 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 + else ContactDetailEvent.DeleteError } catch (e: ForbiddenException) { - // 403 — app lacks manage-contacts permission. Restore and explain. - contactBookStream.insertOrUpdateOptimistic(entry) ContactDetailEvent.DeleteForbidden } _events.tryEmit(event) 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..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 @@ -4,17 +4,19 @@ 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 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.drives.HomebaseFile +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.iCanLocate 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 @@ -30,10 +32,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( @@ -47,9 +49,22 @@ 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, + /** 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(), + /** 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, @@ -82,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 @@ -130,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) }, @@ -139,35 +173,23 @@ 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. + * 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 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 - } - +fun Contact.toContactBookEntry(): ContactBookEntry? { 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 } + val display = name.resolveDisplayName( + odinId = content.odinId, + phone = content.phone?.number, + email = content.email?.email, + ) ?: return null return ContactBookEntry( uniqueId = uniqueId, - fileId = fileId, - versionTag = fileMetadata.versionTag, + fileId = image?.fileId ?: uniqueId, + versionTag = versionTag, odinId = content.odinId, displayName = display, givenName = name?.givenName, @@ -175,14 +197,22 @@ fun HomebaseFile.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(), + iCanLocate = iCanLocate(), source = content.source, - driveId = driveId, - keyHeader = keyHeader, - isEncrypted = fileMetadata.isEncrypted, - previewThumbnail = fileMetadata.appData.previewThumbnail, - imagePayload = imagePayload, + driveId = image?.driveId, + keyHeader = image?.keyHeader, + isEncrypted = image?.isEncrypted ?: false, + previewThumbnail = image?.previewThumbnail, + imagePayload = image?.payload, ) } 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/LocationDashboardContent.kt b/homebase-core/src/commonMain/kotlin/id/homebase/core/ui/screens/location/LocationDashboardContent.kt index d75fcd41b..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 @@ -78,11 +78,10 @@ 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 -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 @@ -301,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 (members of the Emergency Location Access circle) ── + // ── Who can locate you (members of our emergency-location-access circle) ── DashboardSection( title = stringResource(MR.string.location_emergency_access_section), onManage = onManageEmergencyAccess, ) { - EmergencyAccessBody( - circleFound = uiState.emergencyCircleFound, - members = uiState.emergencyContacts, + PeopleListBody( + loaded = uiState.whoCanLocateMeLoaded, + members = uiState.whoCanLocateMe, + emptyText = stringResource(MR.string.location_emergency_access_none), ) } @@ -431,32 +430,26 @@ 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( - circleFound: Boolean?, +private fun PeopleListBody( + loaded: Boolean, members: List, + emptyText: String, ) { 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), + 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/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 cdb6a497b..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, @@ -29,11 +37,17 @@ 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). */ - 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. */ + /** 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, 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 d42b653d0..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 @@ -3,13 +3,19 @@ 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.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.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 @@ -32,18 +38,12 @@ 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 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, @@ -52,8 +52,10 @@ class LocationViewModel( private val pointStore: LocationPointStore, private val uploaderService: LocationTrackUploaderService, private val deviceDirectory: LocationDeviceDirectory, - private val connectionNetworkProvider: ConnectionNetworkProvider, + 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, @@ -96,6 +98,50 @@ class LocationViewModel( private var activationKicked = false init { + // "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: 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.domainName.lowercase() == self } + .sortedBy { it.name.lowercase() } + } + .collect { members -> + _uiState.update { + 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 } @@ -190,7 +236,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 +252,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 +276,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) @@ -319,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) @@ -328,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) } @@ -361,29 +432,17 @@ 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" } + val manageUrl = domain?.let { "https://$it/owner/circles/$EMERGENCY_LOCATION_CIRCLE_ID" } _uiState.update { it.copy( todayTraces = traces, devices = devices, - emergencyContacts = members, - emergencyCircleFound = circleFound, emergencyManageUrl = manageUrl, ) } 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 new file mode 100644 index 000000000..b40fffb20 --- /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 can-locate flag: [Contact.iCanLocate] 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_isNotLocatable() { + assertFalse(contact(null).iCanLocate()) + } + + @Test + fun ourSlotTrue_isLocatable() { + assertTrue(contact(mapOf(ourSlot to """{"iCanLocate":true}""")).iCanLocate()) + } + + @Test + 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 """{"iCanLocate":true}""")).iCanLocate()) + } + + @Test + 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()) + } +}