mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-05 22:36:34 +00:00
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:
@@ -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)
|
||||
}
|
||||
+69
@@ -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)
|
||||
}
|
||||
+26
@@ -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,
|
||||
)
|
||||
+31
@@ -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>
|
||||
}
|
||||
+24
@@ -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>
|
||||
}
|
||||
+40
@@ -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,
|
||||
)
|
||||
+27
@@ -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?,
|
||||
)
|
||||
+23
@@ -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
|
||||
}
|
||||
+54
@@ -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>
|
||||
}
|
||||
+38
@@ -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()
|
||||
}
|
||||
+16
@@ -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>
|
||||
Reference in New Issue
Block a user