Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
f37011e
Contact migration step 1: shared name helpers + Contact->ContactBookE…
Jun 19, 2026
239a0e9
Contact migration step 2: contact book consumes ContactRepository
Jun 19, 2026
c653dd0
Contact migration step 3: re-apply §7 + #6; drop dead HomebaseFile pa…
Jun 19, 2026
efcd493
Chat migration: ContactService + connection-accept on ContactReposito…
Jun 19, 2026
523381f
Merge branch 'main' into contactbook-on-repository
toddmitchell Jun 19, 2026
cd05370
Contact detail: divider above danger zone + labeled details with firs…
Jun 19, 2026
8064a05
Use ContactDetail for 1:1 contact info; retire the ContactInfo overvi…
Jun 19, 2026
1c430ce
Merge branch 'main' into chat-on-repository
toddmitchell Jun 19, 2026
42c38be
1:1 conversation "info" opens the contact detail screen
Jun 20, 2026
4792b83
Contact detail: surface full shared-content overview (parity with 1:1…
Jun 20, 2026
23bfea9
Merge branch 'contactbook-on-repository' into chat-on-repository
Jun 20, 2026
98045c3
Merge remote-tracking branch 'origin/chat-on-repository' into chat-on…
Jun 20, 2026
0c44fbe
Merge branch 'main' into chat-on-repository
toddmitchell Jun 22, 2026
4a10e6c
Merge branch 'main' into chat-on-repository
toddmitchell Jun 22, 2026
8a5a318
Merge branch 'main' into chat-on-repository
toddmitchell Jun 22, 2026
99215fb
Contacts: fix ContactRepository/ContactsProvider review findings
Jun 22, 2026
3b92fdd
Contacts: full content-blob fields + on-demand ext_data bios
Jun 22, 2026
3826b5d
Contacts: emergencyContacts flow + non-null isEmergencyContact
Jun 22, 2026
ed2015b
Contacts: detail status/bio/social sections + per-app data tiers
Jun 22, 2026
8fec737
Contacts: tests for per-app app-data tiers
Jun 22, 2026
d689e17
Contacts: emergency-contact designation over chat + setEmergencyContact
Jun 22, 2026
0bdd4e6
Contacts: send-side EmergencyContactDesignated status
Jun 23, 2026
ed0e509
Contacts: emergency-contact toggle in detail + dedicated clear write
Jun 23, 2026
6b1b156
Merge branch 'main' into chat-on-repository
toddmitchell Jun 23, 2026
008331c
Chat: open peer (not owner) contact detail from conversation header
Jun 23, 2026
450baf4
Contacts: store emergency flag as app-data; location "who can locate …
Jun 23, 2026
c0bc69c
Merge branch 'main' into chat-on-repository
toddmitchell Jun 24, 2026
bf6a16a
Location: emergency-contact directionality via circle membership + iC…
Jun 24, 2026
6dce1c4
Merge branch 'main' into chat-on-repository
toddmitchell Jun 25, 2026
f6802c7
Merge branch 'main' into chat-on-repository
toddmitchell Jun 25, 2026
353f17b
Merge branch 'main' into chat-on-repository
toddmitchell Jun 25, 2026
4cbdb25
Contacts: verify temporal locate access on Sync, set emergency flag +…
Jun 25, 2026
0083814
Merge branch 'main' into chat-on-repository
toddmitchell Jun 25, 2026
448b3ce
Location: fix background-permission Grant loop, route to Settings + c…
Jun 26, 2026
fbfe935
Merge branch 'main' into chat-on-repository
toddmitchell Jun 26, 2026
eb4e707
Fix iOS framework link crash; render nothing for consumed status mess…
Jun 26, 2026
424578c
Merge branch 'main' into chat-on-repository
toddmitchell Jun 26, 2026
9e11346
Disconnect connection when deleting a connected contact
Jun 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = emptySet(),
)

/** Everything needed to render a contact's stored avatar (`prfl_pic`) without a second drive read. */
Expand Down Expand Up @@ -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,
)
}
Original file line number Diff line number Diff line change
@@ -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<String, String> = 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)
Original file line number Diff line number Diff line change
@@ -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<Pair<ContactSocialNetwork, String>> {
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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String>? = 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<String, String>? = null,
)

@Serializable
Expand All @@ -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,
)

Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, JsonElement> = 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 <reified T> decode(typeId: String): T? {
val element = attributes[typeId] ?: return null
return runCatching { OdinSystemSerializer.json.decodeFromJsonElement<T>(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)
}
Loading
Loading