Add HealthChat API service layer (#1)

* feat(healthchat): add HealthChat API service layer

New features/healthchat modules: typed Retrofit client with
per-request Matrix token auth, member directory, profile and
invite services, plus test fakes and fixtures.

* test(healthchat): use runCatchingExceptions

Detekt's RunCatchingNotAllowed rule forbids plain runCatching.
This commit is contained in:
2026-08-05 14:13:00 +02:00
committed by GitHub
parent 621df1dedf
commit 2db4c9847f
46 changed files with 2328 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-compose-library")
}
android {
namespace = "io.element.android.features.healthchat.api"
}
dependencies {
api(libs.kotlinx.collections.immutable)
implementation(libs.coroutines.core)
implementation(projects.libraries.architecture)
implementation(projects.libraries.designsystem)
api(projects.libraries.matrix.api)
}
@@ -0,0 +1,69 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.healthchat.api
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
/**
* Typed error for all HealthChat API failures.
*
* Invariant: every failed [Result] returned by a HealthChat service carries a
* [HealthChatApiException] (never a raw Retrofit/OkHttp exception).
*
* [serverMessage] values are German texts generated by the server and are rendered verbatim;
* they can be null (e.g. a bare 403 Forbid has no body), in which case the UI falls back
* to a localized string.
*/
sealed class HealthChatApiException(
override val message: String,
override val cause: Throwable?,
) : Exception(message, cause) {
/** HTTP 401: the Matrix access token was rejected. */
data class Unauthorized(
val serverMessage: String?,
) : HealthChatApiException(serverMessage ?: "unauthorized", null)
/** HTTP 403: the calling account is not approved (yet). */
data class NotApproved(
val serverMessage: String?,
) : HealthChatApiException(serverMessage ?: "not approved", null)
/** HTTP 404: the requested resource does not exist. */
data class NotFound(
val serverMessage: String?,
) : HealthChatApiException(serverMessage ?: "not found", null)
/** Any other HTTP error with the regular `{message}` error body shape. */
data class Http(
val status: Int,
val serverMessage: String?,
) : HealthChatApiException(serverMessage ?: "HTTP $status", null)
/**
* ASP.NET model validation error (`{title, status, errors: {Field: [msg]}}`).
*
* [fieldErrors] keys are lower-cased field names (e.g. "email", "personalnote").
*/
data class Validation(
val status: Int,
val title: String?,
val fieldErrors: ImmutableMap<String, ImmutableList<String>>,
) : HealthChatApiException(title ?: "validation failed", null)
/** The request never reached the server (I/O, timeout, no token). */
data class Network(
override val cause: Throwable,
) : HealthChatApiException("network error", cause)
/** Anything else (unparseable response, mapping failure, programming error). */
data class Unexpected(
override val cause: Throwable?,
) : HealthChatApiException("unexpected error", cause)
}
@@ -0,0 +1,26 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.healthchat.api
import androidx.compose.runtime.Immutable
import kotlinx.collections.immutable.ImmutableList
/**
* Profile completeness as reported by the HealthChat API.
*
* [missingFields] contains German labels generated by the server and is rendered verbatim
* (with a best-effort localisation mapping on top).
*/
@Immutable
data class HealthChatCompleteness(
val isComplete: Boolean,
val missingFields: ImmutableList<String>,
val message: String?,
val hasProfilePhoto: Boolean,
)
@@ -0,0 +1,31 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.healthchat.api
import io.element.android.libraries.matrix.api.core.UserId
/**
* Stateless access to the HealthChat member directory.
*
* All failures are [HealthChatApiException]s. There is no caching: every call hits the API.
*/
interface HealthChatDirectoryService {
/**
* Search the member directory. A blank [query] returns the full directory.
* Search is server-side (name, practice, city, specialty, further info).
*/
suspend fun searchMembers(query: String): Result<HealthChatMemberDirectory>
/**
* Fetch a single member by Matrix user id.
* Fails with [HealthChatApiException.NotApproved] when the calling account is not approved,
* and [HealthChatApiException.NotFound] when the user is not a directory member.
*/
suspend fun getMember(userId: UserId): Result<HealthChatMember>
}
@@ -0,0 +1,24 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.healthchat.api
/**
* Invite a new member to HealthChat by email.
*
* All failures are [HealthChatApiException]s; server-side field validation surfaces as
* [HealthChatApiException.Validation] with lower-cased field keys ("email", "personalnote").
*/
interface HealthChatInviteService {
/**
* @param email the invitee's email address.
* @param personalNote a personal message, at least 10 characters (server-enforced).
* @return the German confirmation message from the server.
*/
suspend fun invite(email: String, personalNote: String): Result<String>
}
@@ -0,0 +1,40 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.healthchat.api
import androidx.compose.runtime.Immutable
import io.element.android.libraries.matrix.api.core.UserId
/**
* A member of the HealthChat directory, as returned by the HealthChat API.
*
* All profile fields are nullable: the server sanitizer blanks fields in list responses,
* and members may simply not have filled them in. Blank values are normalized to null.
*/
@Immutable
data class HealthChatMember(
val userId: UserId,
val displayName: String?,
/** Avatar as a mxc:// URI, resolved by the app's regular media loading. */
val avatarUrl: String?,
val title: String?,
val firstName: String?,
val lastName: String?,
val specialty: String?,
val practiceName: String?,
val businessCity: String?,
val furtherInfo: String?,
val businessEmail: String?,
val businessTel: String?,
val street: String?,
val houseNumber: String?,
val postalCode: String?,
val website: String?,
val isApproved: Boolean,
)
@@ -0,0 +1,27 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.healthchat.api
import androidx.compose.runtime.Immutable
import kotlinx.collections.immutable.ImmutableList
/**
* The result of a HealthChat member directory query.
*
* When the calling account is not approved yet, [isApproved] is false, [members] only contains
* the server's default members and [pendingMessage] carries a German explanation from the server.
*/
@Immutable
data class HealthChatMemberDirectory(
val members: ImmutableList<HealthChatMember>,
val totalCount: Int,
val searchQuery: String,
val isApproved: Boolean,
val pendingMessage: String?,
)
@@ -0,0 +1,23 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.healthchat.api
/**
* Display name fallback chain, mirroring the web client:
* displayName → "title firstName lastName" → businessEmail → [fallback].
*/
fun HealthChatMember.bestDisplayName(fallback: String): String {
displayName?.takeIf { it.isNotBlank() }?.let { return it }
val composedName = listOfNotNull(title, firstName, lastName)
.filter { it.isNotBlank() }
.joinToString(" ")
if (composedName.isNotBlank()) return composedName
businessEmail?.takeIf { it.isNotBlank() }?.let { return it }
return fallback
}
@@ -0,0 +1,54 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.healthchat.api
import io.element.android.libraries.architecture.AsyncData
import kotlinx.coroutines.flow.StateFlow
/**
* Access to the own HealthChat profile.
*
* The [StateFlow]s exist so that a successful write on one screen propagates to every other
* screen observing the profile (same role as `MatrixClient.userProfile`). They are in-memory
* only, never persisted, and never used as a fallback for failed requests.
*
* All failures are [HealthChatApiException]s.
*/
interface HealthChatProfileRepository {
/** The own profile. Updated by [refreshOwnProfile] and [updateOwnProfile]. */
val ownProfile: StateFlow<AsyncData<HealthChatMember>>
/** The own profile completeness. Updated by [refreshCompleteness]. */
val completeness: StateFlow<AsyncData<HealthChatCompleteness>>
suspend fun refreshOwnProfile(): Result<HealthChatMember>
suspend fun refreshCompleteness(): Result<HealthChatCompleteness>
/**
* Apply a partial profile update ([HealthChatProfileUpdate] semantics), then re-fetch the
* profile so [ownProfile] reflects the server-derived state (e.g. the display name).
*
* @return the German confirmation message from the server.
*/
suspend fun updateOwnProfile(update: HealthChatProfileUpdate): Result<String>
/**
* Upload a new avatar (max 2 MiB, image mime types only) and return its `mxc://` URL.
* On success [ownProfile] is refreshed.
*/
suspend fun uploadAvatar(fileName: String, mimeType: String, data: ByteArray): Result<String>
/**
* Remove the current avatar. On success [ownProfile] is refreshed.
*
* @return the German confirmation message from the server.
*/
suspend fun deleteAvatar(): Result<String>
}
@@ -0,0 +1,38 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.healthchat.api
/**
* A partial update of the own HealthChat profile.
*
* Semantics per field (mirrors the HealthChat API contract):
* - `null`: the field is left untouched on the server.
* - `""`: the field is cleared on the server.
* - any other value: the field is set to that value.
*
* Writing [firstName]/[lastName] makes the server re-derive the Matrix display name and
* sync it to Zitadel; the client must never call `setDisplayName` itself.
*/
data class HealthChatProfileUpdate(
val title: String? = null,
val firstName: String? = null,
val lastName: String? = null,
val specialty: String? = null,
val practiceName: String? = null,
val businessCity: String? = null,
val furtherInfo: String? = null,
val businessEmail: String? = null,
val businessTel: String? = null,
val street: String? = null,
val houseNumber: String? = null,
val postalCode: String? = null,
val website: String? = null,
) {
fun isEmpty(): Boolean = this == HealthChatProfileUpdate()
}
@@ -0,0 +1,16 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.healthchat.api
/**
* Fork-owned HealthChat strings (de/en/fr/it), maintained in the `strings_healthchat.xml`
* files under `features/healthchat/api/src/main/res`.
* They are intentionally NOT part of the Localazy workflow.
*/
typealias HealthChatStrings = R.string
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (c) 2026 Novaloop AG
~ Copyright (c) 2026 Element Creations Ltd.
~
~ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
~ Please see LICENSE files in the repository root for full details.
~
~ Fork-owned HealthChat strings (German). NOT managed by Localazy.
-->
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="UnusedResources">
<string name="healthchat_error_network">Ein Netzwerkfehler ist aufgetreten. Bitte prüfen Sie Ihre Verbindung und versuchen Sie es erneut.</string>
<string name="healthchat_error_unknown">Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut.</string>
</resources>
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (c) 2026 Novaloop AG
~ Copyright (c) 2026 Element Creations Ltd.
~
~ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
~ Please see LICENSE files in the repository root for full details.
~
~ Fork-owned HealthChat strings (French). NOT managed by Localazy.
-->
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="UnusedResources">
<string name="healthchat_error_network">Une erreur réseau est survenue. Veuillez vérifier votre connexion et réessayer.</string>
<string name="healthchat_error_unknown">Une erreur est survenue. Veuillez réessayer.</string>
</resources>
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (c) 2026 Novaloop AG
~ Copyright (c) 2026 Element Creations Ltd.
~
~ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
~ Please see LICENSE files in the repository root for full details.
~
~ Fork-owned HealthChat strings (Italian). NOT managed by Localazy.
-->
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="UnusedResources">
<string name="healthchat_error_network">Si è verificato un errore di rete. Controlla la connessione e riprova.</string>
<string name="healthchat_error_unknown">Qualcosa è andato storto. Riprova.</string>
</resources>
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (c) 2026 Novaloop AG
~ Copyright (c) 2026 Element Creations Ltd.
~
~ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
~ Please see LICENSE files in the repository root for full details.
~
~ Fork-owned HealthChat strings. NOT managed by Localazy — edit directly and keep
~ values-de/, values-fr/ and values-it/ in sync.
-->
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="UnusedResources">
<string name="healthchat_error_network">A network error occurred. Please check your connection and try again.</string>
<string name="healthchat_error_unknown">Something went wrong. Please try again.</string>
</resources>