mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-10-31 08:15:02 +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>
|
||||
@@ -0,0 +1,68 @@
|
||||
import extension.buildConfigFieldStr
|
||||
import extension.readLocalProperty
|
||||
import extension.setupDependencyInjection
|
||||
import extension.testCommonDependencies
|
||||
|
||||
/*
|
||||
* 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")
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.features.healthchat.impl"
|
||||
|
||||
testOptions {
|
||||
unitTests {
|
||||
isIncludeAndroidResources = true
|
||||
}
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
buildConfigFieldStr(
|
||||
name = "HEALTHCHAT_API_BASE_URL",
|
||||
value = System.getenv("HEALTHCHAT_API_BASE_URL")
|
||||
?: readLocalProperty("healthchat.api.base.url")
|
||||
?: "https://api.healthchat.ch",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
setupDependencyInjection()
|
||||
|
||||
dependencies {
|
||||
api(projects.features.healthchat.api)
|
||||
implementation(libs.coroutines.core)
|
||||
implementation(platform(libs.network.okhttp.bom))
|
||||
implementation(libs.network.okhttp.okhttp)
|
||||
implementation(libs.network.okhttp.logging)
|
||||
implementation(platform(libs.network.retrofit.bom))
|
||||
implementation(libs.network.retrofit)
|
||||
implementation(libs.network.retrofit.converter.serialization)
|
||||
implementation(libs.serialization.json)
|
||||
implementation(projects.libraries.androidutils)
|
||||
implementation(projects.libraries.architecture)
|
||||
implementation(projects.libraries.core)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
implementation(projects.libraries.network)
|
||||
implementation(projects.libraries.sessionStorage.api)
|
||||
|
||||
testCommonDependencies(libs)
|
||||
testImplementation(libs.network.mockwebserver)
|
||||
testImplementation(projects.features.healthchat.test)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.libraries.preferences.api)
|
||||
testImplementation(projects.libraries.preferences.test)
|
||||
testImplementation(projects.libraries.sessionStorage.test)
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* 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.impl
|
||||
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.features.healthchat.api.HealthChatApiException
|
||||
import io.element.android.features.healthchat.api.HealthChatDirectoryService
|
||||
import io.element.android.features.healthchat.api.HealthChatMember
|
||||
import io.element.android.features.healthchat.api.HealthChatMemberDirectory
|
||||
import io.element.android.features.healthchat.impl.mapper.runHealthChatApiCatching
|
||||
import io.element.android.features.healthchat.impl.mapper.toHealthChatMemberDirectory
|
||||
import io.element.android.features.healthchat.impl.mapper.toHealthChatMemberOrNull
|
||||
import io.element.android.libraries.androidutils.json.JsonProvider
|
||||
import io.element.android.libraries.core.extensions.flatMap
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
|
||||
@ContributesBinding(SessionScope::class)
|
||||
class DefaultHealthChatDirectoryService(
|
||||
private val sessionApi: HealthChatSessionApi,
|
||||
private val jsonProvider: JsonProvider,
|
||||
) : HealthChatDirectoryService {
|
||||
override suspend fun searchMembers(query: String): Result<HealthChatMemberDirectory> {
|
||||
return runHealthChatApiCatching(jsonProvider()) {
|
||||
sessionApi.api.getUsers(search = query.takeIf { it.isNotBlank() })
|
||||
}
|
||||
.map { it.toHealthChatMemberDirectory() }
|
||||
}
|
||||
|
||||
override suspend fun getMember(userId: UserId): Result<HealthChatMember> {
|
||||
return runHealthChatApiCatching(jsonProvider()) {
|
||||
sessionApi.api.getUser(userId.value)
|
||||
}
|
||||
.flatMap { dto ->
|
||||
dto.toHealthChatMemberOrNull()
|
||||
?.let { Result.success(it) }
|
||||
?: Result.failure(
|
||||
HealthChatApiException.Unexpected(
|
||||
IllegalStateException("Member payload has no valid userId")
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* 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.impl
|
||||
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.features.healthchat.api.HealthChatInviteService
|
||||
import io.element.android.features.healthchat.impl.mapper.runHealthChatApiCatching
|
||||
import io.element.android.features.healthchat.impl.network.InvitationRequestDto
|
||||
import io.element.android.libraries.androidutils.json.JsonProvider
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
|
||||
@ContributesBinding(SessionScope::class)
|
||||
class DefaultHealthChatInviteService(
|
||||
private val sessionApi: HealthChatSessionApi,
|
||||
private val jsonProvider: JsonProvider,
|
||||
) : HealthChatInviteService {
|
||||
override suspend fun invite(email: String, personalNote: String): Result<String> {
|
||||
return runHealthChatApiCatching(jsonProvider()) {
|
||||
sessionApi.api.invite(InvitationRequestDto(email = email, personalNote = personalNote))
|
||||
}
|
||||
.map { it.message.orEmpty() }
|
||||
}
|
||||
}
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* 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.impl
|
||||
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
import io.element.android.features.healthchat.api.HealthChatApiException
|
||||
import io.element.android.features.healthchat.api.HealthChatCompleteness
|
||||
import io.element.android.features.healthchat.api.HealthChatMember
|
||||
import io.element.android.features.healthchat.api.HealthChatProfileRepository
|
||||
import io.element.android.features.healthchat.api.HealthChatProfileUpdate
|
||||
import io.element.android.features.healthchat.impl.mapper.runHealthChatApiCatching
|
||||
import io.element.android.features.healthchat.impl.mapper.toHealthChatMemberOrNull
|
||||
import io.element.android.features.healthchat.impl.network.toDto
|
||||
import io.element.android.libraries.androidutils.json.JsonProvider
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.core.extensions.flatMap
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
|
||||
@ContributesBinding(SessionScope::class)
|
||||
@SingleIn(SessionScope::class)
|
||||
class DefaultHealthChatProfileRepository(
|
||||
private val sessionApi: HealthChatSessionApi,
|
||||
private val jsonProvider: JsonProvider,
|
||||
) : HealthChatProfileRepository {
|
||||
private val ownProfileFlow = MutableStateFlow<AsyncData<HealthChatMember>>(AsyncData.Uninitialized)
|
||||
override val ownProfile: StateFlow<AsyncData<HealthChatMember>> = ownProfileFlow.asStateFlow()
|
||||
|
||||
private val completenessFlow = MutableStateFlow<AsyncData<HealthChatCompleteness>>(AsyncData.Uninitialized)
|
||||
override val completeness: StateFlow<AsyncData<HealthChatCompleteness>> = completenessFlow.asStateFlow()
|
||||
|
||||
override suspend fun refreshOwnProfile(): Result<HealthChatMember> {
|
||||
ownProfileFlow.value = AsyncData.Loading(ownProfileFlow.value.dataOrNull())
|
||||
return runHealthChatApiCatching(jsonProvider()) {
|
||||
sessionApi.api.getProfile()
|
||||
}
|
||||
.flatMap { dto ->
|
||||
dto.toHealthChatMemberOrNull()
|
||||
?.let { Result.success(it) }
|
||||
?: Result.failure(
|
||||
HealthChatApiException.Unexpected(
|
||||
IllegalStateException("Own profile payload has no valid userId")
|
||||
)
|
||||
)
|
||||
}
|
||||
.onSuccess { ownProfileFlow.value = AsyncData.Success(it) }
|
||||
.onFailure { ownProfileFlow.value = AsyncData.Failure(it) }
|
||||
}
|
||||
|
||||
override suspend fun refreshCompleteness(): Result<HealthChatCompleteness> {
|
||||
completenessFlow.value = AsyncData.Loading(completenessFlow.value.dataOrNull())
|
||||
return runHealthChatApiCatching(jsonProvider()) {
|
||||
sessionApi.api.getProfileCompleteness()
|
||||
}
|
||||
.map { dto ->
|
||||
HealthChatCompleteness(
|
||||
isComplete = dto.isComplete,
|
||||
missingFields = dto.missingFields.toImmutableList(),
|
||||
message = dto.message?.takeIf { it.isNotBlank() },
|
||||
hasProfilePhoto = dto.hasProfilePhoto,
|
||||
)
|
||||
}
|
||||
.onSuccess { completenessFlow.value = AsyncData.Success(it) }
|
||||
.onFailure { completenessFlow.value = AsyncData.Failure(it) }
|
||||
}
|
||||
|
||||
override suspend fun updateOwnProfile(update: HealthChatProfileUpdate): Result<String> {
|
||||
return runHealthChatApiCatching(jsonProvider()) {
|
||||
sessionApi.api.updateProfile(update.toDto())
|
||||
}
|
||||
.map { it.message.orEmpty() }
|
||||
// Re-fetch so ownProfile reflects the server-derived state (e.g. display name).
|
||||
.onSuccess { refreshOwnProfile() }
|
||||
}
|
||||
|
||||
override suspend fun uploadAvatar(fileName: String, mimeType: String, data: ByteArray): Result<String> {
|
||||
return runHealthChatApiCatching(jsonProvider()) {
|
||||
val part = MultipartBody.Part.createFormData(
|
||||
name = "file",
|
||||
filename = fileName,
|
||||
body = data.toRequestBody(mimeType.toMediaType()),
|
||||
)
|
||||
sessionApi.api.uploadAvatar(part)
|
||||
}
|
||||
.flatMap { dto ->
|
||||
dto.mxcUrl
|
||||
?.let { Result.success(it) }
|
||||
?: Result.failure(
|
||||
HealthChatApiException.Unexpected(
|
||||
IllegalStateException("Avatar upload response has no mxcUrl")
|
||||
)
|
||||
)
|
||||
}
|
||||
.onSuccess { refreshOwnProfile() }
|
||||
}
|
||||
|
||||
override suspend fun deleteAvatar(): Result<String> {
|
||||
return runHealthChatApiCatching(jsonProvider()) {
|
||||
sessionApi.api.deleteAvatar()
|
||||
}
|
||||
.map { it.message.orEmpty() }
|
||||
.onSuccess { refreshOwnProfile() }
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* 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.impl
|
||||
|
||||
/**
|
||||
* HealthChat API configuration.
|
||||
*
|
||||
* The base URL defaults to production and can be overridden at build time via the
|
||||
* `HEALTHCHAT_API_BASE_URL` environment variable or the `healthchat.api.base.url` entry
|
||||
* in the (gitignored) `local.properties`, so a dev override never ships.
|
||||
*/
|
||||
internal object HealthChatApiConfig {
|
||||
val baseUrl: String = BuildConfig.HEALTHCHAT_API_BASE_URL
|
||||
}
|
||||
+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.impl
|
||||
|
||||
import dev.zacsweers.metro.Inject
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
import io.element.android.features.healthchat.impl.network.HealthChatApi
|
||||
import io.element.android.features.healthchat.impl.network.HealthChatApiFactory
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
|
||||
/**
|
||||
* Holds the single per-session [HealthChatApi] instance (one derived OkHttp client and
|
||||
* Retrofit instance per session, shared by all HealthChat services).
|
||||
*/
|
||||
@Inject
|
||||
@SingleIn(SessionScope::class)
|
||||
class HealthChatSessionApi(
|
||||
apiFactory: HealthChatApiFactory,
|
||||
sessionId: SessionId,
|
||||
) {
|
||||
val api: HealthChatApi by lazy {
|
||||
apiFactory.create(HealthChatApiConfig.baseUrl, sessionId)
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* 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.impl.mapper
|
||||
|
||||
import io.element.android.features.healthchat.api.HealthChatApiException
|
||||
import io.element.android.features.healthchat.impl.network.MessageResponseDto
|
||||
import io.element.android.features.healthchat.impl.network.ValidationProblemDto
|
||||
import io.element.android.libraries.core.extensions.mapFailure
|
||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableMap
|
||||
import kotlinx.serialization.json.Json
|
||||
import retrofit2.HttpException
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* Runs an API call and maps every failure to a [HealthChatApiException].
|
||||
* CancellationExceptions are rethrown by [runCatchingExceptions].
|
||||
*/
|
||||
suspend fun <T> runHealthChatApiCatching(json: Json, block: suspend () -> T): Result<T> {
|
||||
return runCatchingExceptions { block() }
|
||||
.mapFailure { it.toHealthChatApiException(json) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps any throwable to the typed [HealthChatApiException] hierarchy, understanding both
|
||||
* HealthChat error body shapes: the regular `{message}` and ASP.NET ValidationProblemDetails
|
||||
* (`{title, status, errors}`, whose PascalCase field keys are lower-cased here).
|
||||
*/
|
||||
fun Throwable.toHealthChatApiException(json: Json): HealthChatApiException {
|
||||
return when (this) {
|
||||
is HealthChatApiException -> this
|
||||
is HttpException -> toHealthChatHttpException(json)
|
||||
is IOException -> HealthChatApiException.Network(this)
|
||||
else -> HealthChatApiException.Unexpected(this)
|
||||
}
|
||||
}
|
||||
|
||||
private fun HttpException.toHealthChatHttpException(json: Json): HealthChatApiException {
|
||||
val errorBody = try {
|
||||
response()?.errorBody()?.string()?.takeIf { it.isNotBlank() }
|
||||
} catch (ignored: IOException) {
|
||||
null
|
||||
}
|
||||
val validation = errorBody
|
||||
?.let { json.decodeOrNull<ValidationProblemDto>(it) }
|
||||
?.takeIf { it.errors.isNotEmpty() }
|
||||
if (validation != null) {
|
||||
return HealthChatApiException.Validation(
|
||||
status = code(),
|
||||
title = validation.title,
|
||||
fieldErrors = validation.errors.entries
|
||||
.associate { (field, messages) -> field.lowercase() to messages.toImmutableList() }
|
||||
.toImmutableMap(),
|
||||
)
|
||||
}
|
||||
val serverMessage = errorBody
|
||||
?.let { json.decodeOrNull<MessageResponseDto>(it) }
|
||||
?.message
|
||||
?.takeIf { it.isNotBlank() }
|
||||
return when (code()) {
|
||||
401 -> HealthChatApiException.Unauthorized(serverMessage)
|
||||
403 -> HealthChatApiException.NotApproved(serverMessage)
|
||||
404 -> HealthChatApiException.NotFound(serverMessage)
|
||||
else -> HealthChatApiException.Http(code(), serverMessage)
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun <reified T> Json.decodeOrNull(body: String): T? {
|
||||
return try {
|
||||
decodeFromString<T>(body)
|
||||
} catch (ignored: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* 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.impl.mapper
|
||||
|
||||
import io.element.android.features.healthchat.api.HealthChatMember
|
||||
import io.element.android.features.healthchat.api.HealthChatMemberDirectory
|
||||
import io.element.android.features.healthchat.impl.network.MatrixUserDto
|
||||
import io.element.android.features.healthchat.impl.network.MatrixUserSearchResultDto
|
||||
import io.element.android.libraries.matrix.api.core.MatrixPatterns
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Maps a member DTO to the domain model, or null when the record has no valid Matrix user id.
|
||||
*
|
||||
* The MXID is validated BEFORE constructing [UserId]: its init block crashes debug builds on
|
||||
* malformed ids, so one bad server row must never take the app down. Matrix ids are safe to log.
|
||||
*/
|
||||
fun MatrixUserDto.toHealthChatMemberOrNull(): HealthChatMember? {
|
||||
val validUserId = userId?.takeIf { MatrixPatterns.isUserId(it) }
|
||||
if (validUserId == null) {
|
||||
Timber.w("Dropping HealthChat member with invalid userId: $userId")
|
||||
return null
|
||||
}
|
||||
return HealthChatMember(
|
||||
userId = UserId(validUserId),
|
||||
displayName = displayName?.takeIf { it.isNotBlank() },
|
||||
avatarUrl = avatarMxcUrl?.takeIf { it.isNotBlank() },
|
||||
title = title?.takeIf { it.isNotBlank() },
|
||||
firstName = firstName?.takeIf { it.isNotBlank() },
|
||||
lastName = lastName?.takeIf { it.isNotBlank() },
|
||||
specialty = specialty?.takeIf { it.isNotBlank() },
|
||||
practiceName = practiceName?.takeIf { it.isNotBlank() },
|
||||
businessCity = businessCity?.takeIf { it.isNotBlank() },
|
||||
furtherInfo = furtherInfo?.takeIf { it.isNotBlank() },
|
||||
businessEmail = businessEmail?.takeIf { it.isNotBlank() },
|
||||
businessTel = businessTel?.takeIf { it.isNotBlank() },
|
||||
street = street?.takeIf { it.isNotBlank() },
|
||||
houseNumber = houseNumber?.takeIf { it.isNotBlank() },
|
||||
postalCode = postalCode?.takeIf { it.isNotBlank() },
|
||||
website = website?.takeIf { it.isNotBlank() },
|
||||
isApproved = isApproved,
|
||||
)
|
||||
}
|
||||
|
||||
fun MatrixUserSearchResultDto.toHealthChatMemberDirectory(): HealthChatMemberDirectory {
|
||||
return HealthChatMemberDirectory(
|
||||
members = users.mapNotNull { it.toHealthChatMemberOrNull() }.toImmutableList(),
|
||||
totalCount = totalCount,
|
||||
searchQuery = searchQuery.orEmpty(),
|
||||
isApproved = isApproved,
|
||||
pendingMessage = pendingMessage?.takeIf { it.isNotBlank() },
|
||||
)
|
||||
}
|
||||
+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.impl.network
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class AvatarUploadResponseDto(
|
||||
val mxcUrl: String? = null,
|
||||
)
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* 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.impl.network
|
||||
|
||||
import okhttp3.MultipartBody
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.DELETE
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Multipart
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.PUT
|
||||
import retrofit2.http.Part
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
|
||||
/**
|
||||
* Retrofit interface for the HealthChat API (`https://api.healthchat.ch`).
|
||||
* Authentication is added by [HealthChatAuthInterceptor].
|
||||
*/
|
||||
interface HealthChatApi {
|
||||
@GET("api/usermatrix/users")
|
||||
suspend fun getUsers(
|
||||
@Query("search") search: String?,
|
||||
): MatrixUserSearchResultDto
|
||||
|
||||
@GET("api/usermatrix/users/{userId}")
|
||||
suspend fun getUser(
|
||||
@Path("userId") userId: String,
|
||||
): MatrixUserDto
|
||||
|
||||
@GET("api/usermatrix/profile")
|
||||
suspend fun getProfile(): MatrixUserDto
|
||||
|
||||
@PUT("api/usermatrix/profile")
|
||||
suspend fun updateProfile(
|
||||
@Body request: UpdateProfileRequestDto,
|
||||
): MessageResponseDto
|
||||
|
||||
@GET("api/usermatrix/profile/completeness")
|
||||
suspend fun getProfileCompleteness(): ProfileCompletenessDto
|
||||
|
||||
@Multipart
|
||||
@POST("api/usermatrix/profile/avatar")
|
||||
suspend fun uploadAvatar(
|
||||
@Part file: MultipartBody.Part,
|
||||
): AvatarUploadResponseDto
|
||||
|
||||
@DELETE("api/usermatrix/profile/avatar")
|
||||
suspend fun deleteAvatar(): MessageResponseDto
|
||||
|
||||
@POST("api/usermatrix/invite")
|
||||
suspend fun invite(
|
||||
@Body request: InvitationRequestDto,
|
||||
): MessageResponseDto
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* 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.impl.network
|
||||
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.libraries.androidutils.json.JsonProvider
|
||||
import io.element.android.libraries.core.uri.ensureTrailingSlash
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.network.interceptors.DynamicHttpLoggingInterceptor
|
||||
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||
|
||||
interface HealthChatApiFactory {
|
||||
fun create(baseUrl: String, sessionId: SessionId): HealthChatApi
|
||||
}
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultHealthChatApiFactory(
|
||||
private val okHttpClient: () -> OkHttpClient,
|
||||
private val sessionStore: () -> SessionStore,
|
||||
private val json: () -> JsonProvider,
|
||||
) : HealthChatApiFactory {
|
||||
override fun create(baseUrl: String, sessionId: SessionId): HealthChatApi {
|
||||
val client = okHttpClient()
|
||||
.newBuilder()
|
||||
.apply {
|
||||
// PII: member directory and profile bodies must never reach BODY-level HTTP
|
||||
// logs, so the shared client's logging interceptor is dropped here.
|
||||
interceptors().removeAll { it is DynamicHttpLoggingInterceptor }
|
||||
addInterceptor(HealthChatAuthInterceptor(sessionId, sessionStore()))
|
||||
}
|
||||
.build()
|
||||
return Retrofit.Builder()
|
||||
.baseUrl(baseUrl.ensureTrailingSlash())
|
||||
.addConverterFactory(json()().asConverterFactory("application/json".toMediaType()))
|
||||
.client(client)
|
||||
.build()
|
||||
.create(HealthChatApi::class.java)
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* 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.impl.network
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import java.io.IOException
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* Adds `Authorization: Bearer <matrix access token>` to every HealthChat API request.
|
||||
*
|
||||
* The token is read FRESH from [SessionStore] on every request: the Rust SDK rotates the
|
||||
* access token and writes it back through `RustClientSessionDelegate`, so caching it here
|
||||
* would eventually send a stale token.
|
||||
*
|
||||
* `runBlocking` inside an OkHttp interceptor follows the in-repo precedent of
|
||||
* [io.element.android.libraries.network.interceptors.DynamicHttpLoggingInterceptor]:
|
||||
* interceptors run on OkHttp's own worker threads, never on the main thread.
|
||||
*/
|
||||
class HealthChatAuthInterceptor(
|
||||
private val sessionId: SessionId,
|
||||
private val sessionStore: SessionStore,
|
||||
) : Interceptor {
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val accessToken = runBlocking {
|
||||
withTimeoutOrNull(TOKEN_READ_TIMEOUT) {
|
||||
sessionStore.getSession(sessionId.value)?.accessToken
|
||||
}
|
||||
} ?: throw HealthChatTokenUnavailableException()
|
||||
val request = chain.request()
|
||||
.newBuilder()
|
||||
.header("Authorization", "Bearer $accessToken")
|
||||
.build()
|
||||
return chain.proceed(request)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TOKEN_READ_TIMEOUT = 5.seconds
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown (as an [IOException] so OkHttp surfaces it as a network failure) when no access
|
||||
* token is available for the session, e.g. right after a logout.
|
||||
*/
|
||||
class HealthChatTokenUnavailableException : IOException("No access token available for the HealthChat API")
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* 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.impl.network
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class InvitationRequestDto(
|
||||
val email: String,
|
||||
val personalNote: 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.impl.network
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* A member record as returned by the HealthChat API (camelCase).
|
||||
*
|
||||
* Every field is defaulted so the client stays tolerant to additive server-side changes
|
||||
* (the API contract guarantees only additive evolution).
|
||||
*/
|
||||
@Serializable
|
||||
data class MatrixUserDto(
|
||||
val userId: String? = null,
|
||||
val displayName: String? = null,
|
||||
val firstName: String? = null,
|
||||
val lastName: String? = null,
|
||||
val title: String? = null,
|
||||
val specialty: String? = null,
|
||||
val practiceName: String? = null,
|
||||
val businessCity: String? = null,
|
||||
val businessEmail: String? = null,
|
||||
val businessTel: String? = null,
|
||||
val furtherInfo: String? = null,
|
||||
val avatarMxcUrl: String? = null,
|
||||
val street: String? = null,
|
||||
val houseNumber: String? = null,
|
||||
val postalCode: String? = null,
|
||||
val website: String? = null,
|
||||
// Server-side default is true for legacy accounts.
|
||||
val isApproved: Boolean = true,
|
||||
val isActive: Boolean = true,
|
||||
)
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* 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.impl.network
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class MatrixUserSearchResultDto(
|
||||
val users: List<MatrixUserDto> = emptyList(),
|
||||
val totalCount: Int = 0,
|
||||
val searchQuery: String? = null,
|
||||
val isApproved: Boolean = true,
|
||||
val pendingMessage: String? = null,
|
||||
)
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* 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.impl.network
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/** The regular HealthChat API response/error body: a single German message. */
|
||||
@Serializable
|
||||
data class MessageResponseDto(
|
||||
val message: String? = null,
|
||||
)
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* 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.impl.network
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ProfileCompletenessDto(
|
||||
// Fail quiet: a missing flag must not nag the user with an "incomplete profile" banner.
|
||||
val isComplete: Boolean = true,
|
||||
val missingFields: List<String> = emptyList(),
|
||||
val message: String? = null,
|
||||
val hasProfilePhoto: Boolean = true,
|
||||
)
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* 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.impl.network
|
||||
|
||||
import io.element.android.features.healthchat.api.HealthChatProfileUpdate
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Diff-only profile update body.
|
||||
*
|
||||
* Serialized with `encodeDefaults = false` (the app's default Json), so `null` fields are
|
||||
* OMITTED from the JSON (server: untouched) while an explicit `""` is serialized
|
||||
* (server: clear the field). Guarded by a serializer test.
|
||||
*/
|
||||
@Serializable
|
||||
data class UpdateProfileRequestDto(
|
||||
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 HealthChatProfileUpdate.toDto() = UpdateProfileRequestDto(
|
||||
title = title,
|
||||
firstName = firstName,
|
||||
lastName = lastName,
|
||||
specialty = specialty,
|
||||
practiceName = practiceName,
|
||||
businessCity = businessCity,
|
||||
furtherInfo = furtherInfo,
|
||||
businessEmail = businessEmail,
|
||||
businessTel = businessTel,
|
||||
street = street,
|
||||
houseNumber = houseNumber,
|
||||
postalCode = postalCode,
|
||||
website = website,
|
||||
)
|
||||
+22
@@ -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.
|
||||
*/
|
||||
|
||||
package io.element.android.features.healthchat.impl.network
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* ASP.NET model validation error body (`ValidationProblemDetails`) — a DIFFERENT shape from
|
||||
* the regular `{message}` errors. Field keys arrive PascalCase (e.g. "Email", "PersonalNote").
|
||||
*/
|
||||
@Serializable
|
||||
data class ValidationProblemDto(
|
||||
val title: String? = null,
|
||||
val status: Int? = null,
|
||||
val errors: Map<String, List<String>> = emptyMap(),
|
||||
)
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
/*
|
||||
* 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.impl
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.healthchat.api.HealthChatApiException
|
||||
import io.element.android.libraries.androidutils.json.DefaultJsonProvider
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.Test
|
||||
|
||||
class DefaultHealthChatDirectoryServiceTest {
|
||||
@Test
|
||||
fun `searchMembers with a blank query requests the full directory and maps members`() = runTest {
|
||||
val server = MockWebServer()
|
||||
server.enqueue(
|
||||
aJsonResponse(
|
||||
"""
|
||||
{
|
||||
"users": [
|
||||
{
|
||||
"userId": "@erika.muster:healthchat.ch",
|
||||
"displayName": "Dr. med. Erika Muster",
|
||||
"specialty": "Allgemeinmedizin",
|
||||
"practiceName": "Praxis Muster",
|
||||
"businessCity": "Zürich",
|
||||
"businessEmail": "",
|
||||
"avatarMxcUrl": "mxc://healthchat.ch/abc",
|
||||
"isApproved": true
|
||||
}
|
||||
],
|
||||
"totalCount": 1,
|
||||
"searchQuery": "",
|
||||
"isApproved": true,
|
||||
"pendingMessage": null
|
||||
}
|
||||
""".trimIndent()
|
||||
)
|
||||
)
|
||||
server.start()
|
||||
val sut = createDirectoryService(server)
|
||||
val result = sut.searchMembers("")
|
||||
val request = server.takeRequest()
|
||||
assertThat(request.method).isEqualTo("GET")
|
||||
assertThat(request.requestUrl!!.encodedPath).isEqualTo("/api/usermatrix/users")
|
||||
assertThat(request.requestUrl!!.queryParameter("search")).isNull()
|
||||
val directory = result.getOrThrow()
|
||||
assertThat(directory.totalCount).isEqualTo(1)
|
||||
assertThat(directory.isApproved).isTrue()
|
||||
assertThat(directory.pendingMessage).isNull()
|
||||
val member = directory.members.single()
|
||||
assertThat(member.userId).isEqualTo(UserId("@erika.muster:healthchat.ch"))
|
||||
assertThat(member.displayName).isEqualTo("Dr. med. Erika Muster")
|
||||
assertThat(member.specialty).isEqualTo("Allgemeinmedizin")
|
||||
// Blank fields are normalized to null.
|
||||
assertThat(member.businessEmail).isNull()
|
||||
assertThat(member.avatarUrl).isEqualTo("mxc://healthchat.ch/abc")
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `searchMembers sends the query as an url encoded search parameter`() = runTest {
|
||||
val server = MockWebServer()
|
||||
server.enqueue(aJsonResponse("""{"users":[],"totalCount":0,"searchQuery":"Dr. Müller","isApproved":true}"""))
|
||||
server.start()
|
||||
val sut = createDirectoryService(server)
|
||||
sut.searchMembers("Dr. Müller")
|
||||
val request = server.takeRequest()
|
||||
assertThat(request.requestUrl!!.queryParameter("search")).isEqualTo("Dr. Müller")
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `searchMembers maps an unapproved caller with the verbatim pending message`() = runTest {
|
||||
val server = MockWebServer()
|
||||
server.enqueue(
|
||||
aJsonResponse(
|
||||
"""
|
||||
{
|
||||
"users": [],
|
||||
"totalCount": 0,
|
||||
"isApproved": false,
|
||||
"pendingMessage": "Ihr Konto wird derzeit geprüft."
|
||||
}
|
||||
""".trimIndent()
|
||||
)
|
||||
)
|
||||
server.start()
|
||||
val sut = createDirectoryService(server)
|
||||
val directory = sut.searchMembers("").getOrThrow()
|
||||
assertThat(directory.isApproved).isFalse()
|
||||
assertThat(directory.pendingMessage).isEqualTo("Ihr Konto wird derzeit geprüft.")
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a member row with an invalid userId is dropped instead of failing the whole directory`() = runTest {
|
||||
val server = MockWebServer()
|
||||
server.enqueue(
|
||||
aJsonResponse(
|
||||
"""
|
||||
{
|
||||
"users": [
|
||||
{"userId": "not-a-matrix-id", "displayName": "Broken"},
|
||||
{"userId": "@valid:healthchat.ch", "displayName": "Valid"}
|
||||
],
|
||||
"totalCount": 2,
|
||||
"isApproved": true
|
||||
}
|
||||
""".trimIndent()
|
||||
)
|
||||
)
|
||||
server.start()
|
||||
val sut = createDirectoryService(server)
|
||||
val directory = sut.searchMembers("").getOrThrow()
|
||||
assertThat(directory.members.map { it.userId }).containsExactly(UserId("@valid:healthchat.ch"))
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unknown json field does not fail the mapping`() = runTest {
|
||||
val server = MockWebServer()
|
||||
server.enqueue(
|
||||
aJsonResponse(
|
||||
"""{"users":[],"totalCount":0,"isApproved":true,"aBrandNewField":{"nested":1}}"""
|
||||
)
|
||||
)
|
||||
server.start()
|
||||
val sut = createDirectoryService(server)
|
||||
assertThat(sut.searchMembers("").isSuccess).isTrue()
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getMember requests the user by id and maps 403 to NotApproved`() = runTest {
|
||||
val server = MockWebServer()
|
||||
server.enqueue(MockResponse().setResponseCode(403))
|
||||
server.start()
|
||||
val sut = createDirectoryService(server)
|
||||
val result = sut.getMember(UserId("@bob:healthchat.ch"))
|
||||
val request = server.takeRequest()
|
||||
assertThat(request.requestUrl!!.encodedPath).isEqualTo("/api/usermatrix/users/@bob:healthchat.ch")
|
||||
assertThat(result.exceptionOrNull()).isEqualTo(HealthChatApiException.NotApproved(null))
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getMember maps 404 to NotFound with the verbatim server message`() = runTest {
|
||||
val server = MockWebServer()
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setResponseCode(404)
|
||||
.setBody("""{"message":"Benutzer nicht gefunden"}""")
|
||||
)
|
||||
server.start()
|
||||
val sut = createDirectoryService(server)
|
||||
val result = sut.getMember(UserId("@ghost:healthchat.ch"))
|
||||
assertThat(result.exceptionOrNull()).isEqualTo(HealthChatApiException.NotFound("Benutzer nicht gefunden"))
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getMember returns the mapped member on success`() = runTest {
|
||||
val server = MockWebServer()
|
||||
server.enqueue(
|
||||
aJsonResponse(
|
||||
"""{"userId":"@bob:healthchat.ch","displayName":"Bob","businessTel":"+41 44 111 22 33"}"""
|
||||
)
|
||||
)
|
||||
server.start()
|
||||
val sut = createDirectoryService(server)
|
||||
val member = sut.getMember(UserId("@bob:healthchat.ch")).getOrThrow()
|
||||
assertThat(member.displayName).isEqualTo("Bob")
|
||||
assertThat(member.businessTel).isEqualTo("+41 44 111 22 33")
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
private fun createDirectoryService(server: MockWebServer): DefaultHealthChatDirectoryService {
|
||||
return DefaultHealthChatDirectoryService(
|
||||
sessionApi = aHealthChatSessionApi(server),
|
||||
jsonProvider = DefaultJsonProvider(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun aJsonResponse(body: String): MockResponse {
|
||||
return MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setBody(body)
|
||||
}
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* 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.impl
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.healthchat.api.HealthChatApiException
|
||||
import io.element.android.libraries.androidutils.json.DefaultJsonProvider
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.Test
|
||||
|
||||
class DefaultHealthChatInviteServiceTest {
|
||||
@Test
|
||||
fun `invite posts email and personal note and returns the server message`() = runTest {
|
||||
val server = MockWebServer()
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setBody("""{"message":"Einladung wurde erfolgreich versendet"}""")
|
||||
)
|
||||
server.start()
|
||||
val sut = createInviteService(server)
|
||||
val result = sut.invite(
|
||||
email = "neu@beispiel.ch",
|
||||
personalNote = "Willkommen bei HealthChat!",
|
||||
)
|
||||
val request = server.takeRequest()
|
||||
assertThat(request.method).isEqualTo("POST")
|
||||
assertThat(request.requestUrl!!.encodedPath).isEqualTo("/api/usermatrix/invite")
|
||||
assertThat(request.body.readUtf8())
|
||||
.isEqualTo("""{"email":"neu@beispiel.ch","personalNote":"Willkommen bei HealthChat!"}""")
|
||||
assertThat(result.getOrThrow()).isEqualTo("Einladung wurde erfolgreich versendet")
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a server side validation error surfaces as Validation with lower cased keys`() = runTest {
|
||||
val server = MockWebServer()
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setResponseCode(400)
|
||||
.setBody(
|
||||
"""
|
||||
{
|
||||
"title": "One or more validation errors occurred.",
|
||||
"status": 400,
|
||||
"errors": {
|
||||
"PersonalNote": ["Die persönliche Nachricht muss mindestens 10 Zeichen lang sein"]
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
)
|
||||
)
|
||||
server.start()
|
||||
val sut = createInviteService(server)
|
||||
val result = sut.invite(email = "neu@beispiel.ch", personalNote = "kurz")
|
||||
val error = result.exceptionOrNull() as HealthChatApiException.Validation
|
||||
assertThat(error.fieldErrors.keys).containsExactly("personalnote")
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
private fun createInviteService(server: MockWebServer): DefaultHealthChatInviteService {
|
||||
return DefaultHealthChatInviteService(
|
||||
sessionApi = aHealthChatSessionApi(server),
|
||||
jsonProvider = DefaultJsonProvider(),
|
||||
)
|
||||
}
|
||||
}
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
/*
|
||||
* 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.impl
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.healthchat.api.HealthChatApiException
|
||||
import io.element.android.features.healthchat.api.HealthChatProfileUpdate
|
||||
import io.element.android.libraries.androidutils.json.DefaultJsonProvider
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.Test
|
||||
|
||||
class DefaultHealthChatProfileRepositoryTest {
|
||||
@Test
|
||||
fun `refreshOwnProfile fetches the profile and emits it into ownProfile`() = runTest {
|
||||
val server = MockWebServer()
|
||||
server.enqueue(aOwnProfileResponse(displayName = "Dr. med. Alice"))
|
||||
server.start()
|
||||
val sut = createRepository(server)
|
||||
assertThat(sut.ownProfile.value).isEqualTo(AsyncData.Uninitialized)
|
||||
val result = sut.refreshOwnProfile()
|
||||
val request = server.takeRequest()
|
||||
assertThat(request.method).isEqualTo("GET")
|
||||
assertThat(request.requestUrl!!.encodedPath).isEqualTo("/api/usermatrix/profile")
|
||||
val member = result.getOrThrow()
|
||||
assertThat(member.userId).isEqualTo(A_SESSION_ID)
|
||||
assertThat(member.displayName).isEqualTo("Dr. med. Alice")
|
||||
assertThat(sut.ownProfile.value).isEqualTo(AsyncData.Success(member))
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failed refreshOwnProfile emits Failure with the typed exception`() = runTest {
|
||||
val server = MockWebServer()
|
||||
server.enqueue(MockResponse().setResponseCode(401).setBody("""{"message":"Ungültiges Token"}"""))
|
||||
server.start()
|
||||
val sut = createRepository(server)
|
||||
val result = sut.refreshOwnProfile()
|
||||
val expected = HealthChatApiException.Unauthorized("Ungültiges Token")
|
||||
assertThat(result.exceptionOrNull()).isEqualTo(expected)
|
||||
assertThat(sut.ownProfile.value).isEqualTo(AsyncData.Failure<Nothing>(expected))
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `updateOwnProfile sends only the changed fields and re-fetches the profile`() = runTest {
|
||||
val server = MockWebServer()
|
||||
server.enqueue(aJsonResponse("""{"message":"Profil erfolgreich aktualisiert"}"""))
|
||||
server.enqueue(aOwnProfileResponse(displayName = "Dr. med. Alice Neu"))
|
||||
server.start()
|
||||
val sut = createRepository(server)
|
||||
val result = sut.updateOwnProfile(HealthChatProfileUpdate(specialty = "Kardiologie"))
|
||||
val putRequest = server.takeRequest()
|
||||
assertThat(putRequest.method).isEqualTo("PUT")
|
||||
assertThat(putRequest.requestUrl!!.encodedPath).isEqualTo("/api/usermatrix/profile")
|
||||
// The diff contract: exactly the set fields, nothing else.
|
||||
assertThat(putRequest.body.readUtf8()).isEqualTo("""{"specialty":"Kardiologie"}""")
|
||||
val refreshRequest = server.takeRequest()
|
||||
assertThat(refreshRequest.method).isEqualTo("GET")
|
||||
assertThat(result.getOrThrow()).isEqualTo("Profil erfolgreich aktualisiert")
|
||||
assertThat(sut.ownProfile.value.dataOrNull()?.displayName).isEqualTo("Dr. med. Alice Neu")
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `updateOwnProfile serializes an empty string to clear a field`() = runTest {
|
||||
val server = MockWebServer()
|
||||
server.enqueue(aJsonResponse("""{"message":"Profil erfolgreich aktualisiert"}"""))
|
||||
server.enqueue(aOwnProfileResponse())
|
||||
server.start()
|
||||
val sut = createRepository(server)
|
||||
sut.updateOwnProfile(HealthChatProfileUpdate(website = ""))
|
||||
assertThat(server.takeRequest().body.readUtf8()).isEqualTo("""{"website":""}""")
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failed updateOwnProfile does not touch ownProfile`() = runTest {
|
||||
val server = MockWebServer()
|
||||
server.enqueue(MockResponse().setResponseCode(500).setBody("""{"message":"Interner Fehler"}"""))
|
||||
server.start()
|
||||
val sut = createRepository(server)
|
||||
val result = sut.updateOwnProfile(HealthChatProfileUpdate(specialty = "Kardiologie"))
|
||||
assertThat(result.exceptionOrNull()).isEqualTo(HealthChatApiException.Http(500, "Interner Fehler"))
|
||||
assertThat(sut.ownProfile.value).isEqualTo(AsyncData.Uninitialized)
|
||||
assertThat(server.requestCount).isEqualTo(1)
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `uploadAvatar posts a multipart file part and returns the mxc url`() = runTest {
|
||||
val server = MockWebServer()
|
||||
server.enqueue(aJsonResponse("""{"mxcUrl":"mxc://healthchat.ch/newAvatar"}"""))
|
||||
server.enqueue(aOwnProfileResponse())
|
||||
server.start()
|
||||
val sut = createRepository(server)
|
||||
val result = sut.uploadAvatar(
|
||||
fileName = "avatar.jpg",
|
||||
mimeType = "image/jpeg",
|
||||
data = byteArrayOf(1, 2, 3),
|
||||
)
|
||||
val request = server.takeRequest()
|
||||
assertThat(request.method).isEqualTo("POST")
|
||||
assertThat(request.requestUrl!!.encodedPath).isEqualTo("/api/usermatrix/profile/avatar")
|
||||
assertThat(request.getHeader("Content-Type")).startsWith("multipart/form-data")
|
||||
val body = request.body.readUtf8()
|
||||
assertThat(body).contains("""name="file"""")
|
||||
assertThat(body).contains("""filename="avatar.jpg"""")
|
||||
assertThat(result.getOrThrow()).isEqualTo("mxc://healthchat.ch/newAvatar")
|
||||
// The own profile is refreshed after the upload.
|
||||
assertThat(server.takeRequest().method).isEqualTo("GET")
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deleteAvatar issues a DELETE and refreshes the profile`() = runTest {
|
||||
val server = MockWebServer()
|
||||
server.enqueue(aJsonResponse("""{"message":"Profilbild entfernt"}"""))
|
||||
server.enqueue(aOwnProfileResponse())
|
||||
server.start()
|
||||
val sut = createRepository(server)
|
||||
val result = sut.deleteAvatar()
|
||||
val request = server.takeRequest()
|
||||
assertThat(request.method).isEqualTo("DELETE")
|
||||
assertThat(request.requestUrl!!.encodedPath).isEqualTo("/api/usermatrix/profile/avatar")
|
||||
assertThat(result.getOrThrow()).isEqualTo("Profilbild entfernt")
|
||||
assertThat(server.takeRequest().method).isEqualTo("GET")
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `refreshCompleteness maps the payload and emits it into completeness`() = runTest {
|
||||
val server = MockWebServer()
|
||||
server.enqueue(
|
||||
aJsonResponse(
|
||||
"""
|
||||
{
|
||||
"isComplete": false,
|
||||
"missingFields": ["Fachgebiet", "Praxisname / Firma"],
|
||||
"message": "Bitte vervollständigen Sie Ihr Profil",
|
||||
"hasProfilePhoto": false
|
||||
}
|
||||
""".trimIndent()
|
||||
)
|
||||
)
|
||||
server.start()
|
||||
val sut = createRepository(server)
|
||||
val result = sut.refreshCompleteness()
|
||||
assertThat(server.takeRequest().requestUrl!!.encodedPath)
|
||||
.isEqualTo("/api/usermatrix/profile/completeness")
|
||||
val completeness = result.getOrThrow()
|
||||
assertThat(completeness.isComplete).isFalse()
|
||||
assertThat(completeness.missingFields).containsExactly("Fachgebiet", "Praxisname / Firma").inOrder()
|
||||
assertThat(completeness.message).isEqualTo("Bitte vervollständigen Sie Ihr Profil")
|
||||
assertThat(completeness.hasProfilePhoto).isFalse()
|
||||
assertThat(sut.completeness.value).isEqualTo(AsyncData.Success(completeness))
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failed refreshCompleteness emits Failure`() = runTest {
|
||||
val server = MockWebServer()
|
||||
server.enqueue(MockResponse().setResponseCode(500))
|
||||
server.start()
|
||||
val sut = createRepository(server)
|
||||
val result = sut.refreshCompleteness()
|
||||
assertThat(result.exceptionOrNull()).isEqualTo(HealthChatApiException.Http(500, null))
|
||||
assertThat(sut.completeness.value.isFailure()).isTrue()
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
private fun createRepository(server: MockWebServer): DefaultHealthChatProfileRepository {
|
||||
return DefaultHealthChatProfileRepository(
|
||||
sessionApi = aHealthChatSessionApi(server),
|
||||
jsonProvider = DefaultJsonProvider(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun aJsonResponse(body: String): MockResponse {
|
||||
return MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setBody(body)
|
||||
}
|
||||
|
||||
private fun aOwnProfileResponse(displayName: String = "Alice"): MockResponse {
|
||||
return aJsonResponse("""{"userId":"${A_SESSION_ID.value}","displayName":"$displayName"}""")
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* 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.impl
|
||||
|
||||
import io.element.android.features.healthchat.impl.network.DefaultHealthChatApiFactory
|
||||
import io.element.android.features.healthchat.impl.network.HealthChatApi
|
||||
import io.element.android.features.healthchat.impl.network.HealthChatApiFactory
|
||||
import io.element.android.libraries.androidutils.json.DefaultJsonProvider
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
|
||||
import io.element.android.libraries.sessionstorage.test.aSessionData
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
|
||||
const val AN_ACCESS_TOKEN = "aHealthChatAccessToken"
|
||||
|
||||
fun aHealthChatSessionStore(
|
||||
accessToken: String = AN_ACCESS_TOKEN,
|
||||
): InMemorySessionStore {
|
||||
return InMemorySessionStore(
|
||||
initialList = listOf(
|
||||
aSessionData(
|
||||
sessionId = A_SESSION_ID.value,
|
||||
accessToken = accessToken,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun aHealthChatApiFactory(
|
||||
baseOkHttpClient: OkHttpClient = OkHttpClient.Builder().build(),
|
||||
sessionStore: SessionStore = aHealthChatSessionStore(),
|
||||
): HealthChatApiFactory {
|
||||
return DefaultHealthChatApiFactory(
|
||||
okHttpClient = { baseOkHttpClient },
|
||||
sessionStore = { sessionStore },
|
||||
json = { DefaultJsonProvider() },
|
||||
)
|
||||
}
|
||||
|
||||
fun aHealthChatApi(
|
||||
server: MockWebServer,
|
||||
baseOkHttpClient: OkHttpClient = OkHttpClient.Builder().build(),
|
||||
sessionStore: SessionStore = aHealthChatSessionStore(),
|
||||
): HealthChatApi {
|
||||
return aHealthChatApiFactory(
|
||||
baseOkHttpClient = baseOkHttpClient,
|
||||
sessionStore = sessionStore,
|
||||
).create(server.url("/").toString(), A_SESSION_ID)
|
||||
}
|
||||
|
||||
/**
|
||||
* A [HealthChatSessionApi] whose requests are redirected to the given [server] instead of the
|
||||
* production base URL.
|
||||
*/
|
||||
fun aHealthChatSessionApi(
|
||||
server: MockWebServer,
|
||||
sessionStore: SessionStore = aHealthChatSessionStore(),
|
||||
): HealthChatSessionApi {
|
||||
return HealthChatSessionApi(
|
||||
apiFactory = object : HealthChatApiFactory {
|
||||
override fun create(baseUrl: String, sessionId: SessionId): HealthChatApi {
|
||||
return aHealthChatApi(server = server, sessionStore = sessionStore)
|
||||
}
|
||||
},
|
||||
sessionId = A_SESSION_ID,
|
||||
)
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
* 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.impl.mapper
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.healthchat.api.HealthChatApiException
|
||||
import io.element.android.libraries.androidutils.json.DefaultJsonProvider
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import org.junit.Test
|
||||
import retrofit2.HttpException
|
||||
import retrofit2.Response
|
||||
import java.io.IOException
|
||||
|
||||
class HealthChatErrorMapperTest {
|
||||
private val json = DefaultJsonProvider()()
|
||||
|
||||
@Test
|
||||
fun `a 401 with a message body maps to Unauthorized with the verbatim message`() {
|
||||
val result = anHttpException(401, """{"message":"Ungültiges Token"}""").toHealthChatApiException(json)
|
||||
assertThat(result).isEqualTo(HealthChatApiException.Unauthorized("Ungültiges Token"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a 403 without body maps to NotApproved with a null message`() {
|
||||
val result = anHttpException(403, "").toHealthChatApiException(json)
|
||||
assertThat(result).isEqualTo(HealthChatApiException.NotApproved(null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a 404 with a message body maps to NotFound with the verbatim message`() {
|
||||
val result = anHttpException(404, """{"message":"Benutzer nicht gefunden"}""").toHealthChatApiException(json)
|
||||
assertThat(result).isEqualTo(HealthChatApiException.NotFound("Benutzer nicht gefunden"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a 500 with a message body maps to Http with status and verbatim message`() {
|
||||
val result = anHttpException(500, """{"message":"Interner Fehler"}""").toHealthChatApiException(json)
|
||||
assertThat(result).isEqualTo(HealthChatApiException.Http(500, "Interner Fehler"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a 500 with a non json body maps to Http with a null message`() {
|
||||
val result = anHttpException(500, "<html>Bad Gateway</html>").toHealthChatApiException(json)
|
||||
assertThat(result).isEqualTo(HealthChatApiException.Http(500, null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a 400 with ProblemDetails maps to Validation with lower cased field keys`() {
|
||||
val body = """
|
||||
{
|
||||
"title": "One or more validation errors occurred.",
|
||||
"status": 400,
|
||||
"errors": {
|
||||
"Email": ["The Email field is not a valid e-mail address."],
|
||||
"PersonalNote": ["Die persönliche Nachricht muss mindestens 10 Zeichen lang sein"]
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
val result = anHttpException(400, body).toHealthChatApiException(json)
|
||||
assertThat(result).isInstanceOf(HealthChatApiException.Validation::class.java)
|
||||
result as HealthChatApiException.Validation
|
||||
assertThat(result.status).isEqualTo(400)
|
||||
assertThat(result.title).isEqualTo("One or more validation errors occurred.")
|
||||
assertThat(result.fieldErrors.keys).containsExactly("email", "personalnote")
|
||||
assertThat(result.fieldErrors["personalnote"])
|
||||
.isEqualTo(persistentListOf("Die persönliche Nachricht muss mindestens 10 Zeichen lang sein"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a 400 with a plain message body maps to Http not Validation`() {
|
||||
val result = anHttpException(400, """{"message":"Keine Datei hochgeladen"}""").toHealthChatApiException(json)
|
||||
assertThat(result).isEqualTo(HealthChatApiException.Http(400, "Keine Datei hochgeladen"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an IOException maps to Network`() {
|
||||
val cause = IOException("timeout")
|
||||
val result = cause.toHealthChatApiException(json)
|
||||
assertThat(result).isEqualTo(HealthChatApiException.Network(cause))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `any other exception maps to Unexpected`() {
|
||||
val cause = IllegalStateException("boom")
|
||||
val result = cause.toHealthChatApiException(json)
|
||||
assertThat(result).isEqualTo(HealthChatApiException.Unexpected(cause))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an existing HealthChatApiException passes through unchanged`() {
|
||||
val existing = HealthChatApiException.NotFound("Benutzer nicht gefunden")
|
||||
assertThat(existing.toHealthChatApiException(json)).isSameInstanceAs(existing)
|
||||
}
|
||||
|
||||
private fun anHttpException(code: Int, body: String): HttpException {
|
||||
return HttpException(
|
||||
Response.error<Any>(code, body.toResponseBody("application/json".toMediaType()))
|
||||
)
|
||||
}
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
* 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.impl.network
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.healthchat.impl.AN_ACCESS_TOKEN
|
||||
import io.element.android.features.healthchat.impl.aHealthChatApi
|
||||
import io.element.android.features.healthchat.impl.aHealthChatSessionStore
|
||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||
import io.element.android.libraries.matrix.api.tracing.LogLevel
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||
import io.element.android.libraries.network.interceptors.DynamicHttpLoggingInterceptor
|
||||
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
|
||||
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
|
||||
import io.element.android.libraries.sessionstorage.test.aSessionData
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.Test
|
||||
|
||||
class HealthChatAuthInterceptorTest {
|
||||
@Test
|
||||
fun `requests carry the current access token as Bearer authorization`() = runTest {
|
||||
val server = MockWebServer()
|
||||
server.enqueue(aProfileResponse())
|
||||
server.start()
|
||||
val api = aHealthChatApi(server)
|
||||
api.getProfile()
|
||||
val request = server.takeRequest()
|
||||
assertThat(request.getHeader("Authorization")).isEqualTo("Bearer $AN_ACCESS_TOKEN")
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a rotated access token is used on the next request without recreating the client`() = runTest {
|
||||
val server = MockWebServer()
|
||||
server.enqueue(aProfileResponse())
|
||||
server.enqueue(aProfileResponse())
|
||||
server.start()
|
||||
val sessionStore = aHealthChatSessionStore()
|
||||
val api = aHealthChatApi(server, sessionStore = sessionStore)
|
||||
api.getProfile()
|
||||
assertThat(server.takeRequest().getHeader("Authorization")).isEqualTo("Bearer $AN_ACCESS_TOKEN")
|
||||
// The SDK rotates the token and persists it through the session store.
|
||||
sessionStore.updateData(
|
||||
aSessionData(
|
||||
sessionId = A_SESSION_ID.value,
|
||||
accessToken = "aRotatedAccessToken",
|
||||
),
|
||||
)
|
||||
api.getProfile()
|
||||
assertThat(server.takeRequest().getHeader("Authorization")).isEqualTo("Bearer aRotatedAccessToken")
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a missing session fails the request before it reaches the server`() = runTest {
|
||||
val server = MockWebServer()
|
||||
server.start()
|
||||
val api = aHealthChatApi(server, sessionStore = InMemorySessionStore())
|
||||
val result = runCatchingExceptions { api.getProfile() }
|
||||
assertThat(result.exceptionOrNull()).isInstanceOf(HealthChatTokenUnavailableException::class.java)
|
||||
assertThat(server.requestCount).isEqualTo(0)
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `interceptors of the base client are preserved by the derived client`() = runTest {
|
||||
val server = MockWebServer()
|
||||
server.enqueue(aProfileResponse())
|
||||
server.start()
|
||||
val baseClient = OkHttpClient.Builder()
|
||||
.addInterceptor { chain ->
|
||||
chain.proceed(
|
||||
chain.request().newBuilder().header("X-Test-Marker", "aMarker").build()
|
||||
)
|
||||
}
|
||||
.build()
|
||||
val api = aHealthChatApi(server, baseOkHttpClient = baseClient)
|
||||
api.getProfile()
|
||||
assertThat(server.takeRequest().getHeader("X-Test-Marker")).isEqualTo("aMarker")
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the http body logging interceptor of the base client is removed from the derived client`() = runTest {
|
||||
val server = MockWebServer()
|
||||
server.enqueue(aProfileResponse())
|
||||
server.enqueue(aProfileResponse())
|
||||
server.start()
|
||||
val loggedLines = mutableListOf<String>()
|
||||
val loggingInterceptor = DynamicHttpLoggingInterceptor(
|
||||
appPreferencesStore = InMemoryAppPreferencesStore(logLevel = LogLevel.DEBUG),
|
||||
loggingInterceptor = HttpLoggingInterceptor { message -> loggedLines.add(message) },
|
||||
)
|
||||
val baseClient = OkHttpClient.Builder()
|
||||
.addInterceptor(loggingInterceptor)
|
||||
.build()
|
||||
// Positive control: the base client itself does log.
|
||||
baseClient.newCall(Request.Builder().url(server.url("/control")).build()).execute().close()
|
||||
assertThat(loggedLines).isNotEmpty()
|
||||
loggedLines.clear()
|
||||
// The derived HealthChat client must not log anything (PII in bodies).
|
||||
val api = aHealthChatApi(server, baseOkHttpClient = baseClient)
|
||||
api.getProfile()
|
||||
assertThat(loggedLines).isEmpty()
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
private fun aProfileResponse(): MockResponse {
|
||||
return MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setBody("""{"userId":"${A_SESSION_ID.value}"}""")
|
||||
}
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* 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.impl.network
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.healthchat.api.HealthChatProfileUpdate
|
||||
import io.element.android.libraries.androidutils.json.DefaultJsonProvider
|
||||
import kotlinx.serialization.encodeToString
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Guards the diff-only PUT contract: `null` fields must be OMITTED from the JSON
|
||||
* (server: untouched) while an explicit `""` must be serialized (server: clear).
|
||||
*/
|
||||
class UpdateProfileRequestDtoTest {
|
||||
private val json = DefaultJsonProvider()()
|
||||
|
||||
@Test
|
||||
fun `null fields are omitted from the serialized body`() {
|
||||
val body = json.encodeToString(UpdateProfileRequestDto(specialty = "Kardiologie"))
|
||||
assertThat(body).isEqualTo("""{"specialty":"Kardiologie"}""")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty strings are serialized to clear a field`() {
|
||||
val body = json.encodeToString(UpdateProfileRequestDto(website = ""))
|
||||
assertThat(body).isEqualTo("""{"website":""}""")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty update serializes to an empty object`() {
|
||||
val body = json.encodeToString(UpdateProfileRequestDto())
|
||||
assertThat(body).isEqualTo("{}")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the domain update maps field by field`() {
|
||||
val dto = HealthChatProfileUpdate(
|
||||
title = "Dr. med.",
|
||||
firstName = "Erika",
|
||||
lastName = "Muster",
|
||||
specialty = "Allgemeinmedizin",
|
||||
practiceName = "Praxis Muster",
|
||||
businessCity = "Zürich",
|
||||
furtherInfo = "FMH",
|
||||
businessEmail = "praxis@muster.ch",
|
||||
businessTel = "+41 44 123 45 67",
|
||||
street = "Musterstrasse",
|
||||
houseNumber = "12",
|
||||
postalCode = "8001",
|
||||
website = "https://praxis-muster.ch",
|
||||
).toDto()
|
||||
assertThat(dto).isEqualTo(
|
||||
UpdateProfileRequestDto(
|
||||
title = "Dr. med.",
|
||||
firstName = "Erika",
|
||||
lastName = "Muster",
|
||||
specialty = "Allgemeinmedizin",
|
||||
practiceName = "Praxis Muster",
|
||||
businessCity = "Zürich",
|
||||
furtherInfo = "FMH",
|
||||
businessEmail = "praxis@muster.ch",
|
||||
businessTel = "+41 44 123 45 67",
|
||||
street = "Musterstrasse",
|
||||
houseNumber = "12",
|
||||
postalCode = "8001",
|
||||
website = "https://praxis-muster.ch",
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
plugins {
|
||||
id("io.element.android-compose-library")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.features.healthchat.test"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(projects.features.healthchat.api)
|
||||
implementation(libs.coroutines.core)
|
||||
implementation(projects.libraries.architecture)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
implementation(projects.libraries.matrix.test)
|
||||
implementation(projects.tests.testutils)
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* 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.test
|
||||
|
||||
import io.element.android.features.healthchat.api.HealthChatDirectoryService
|
||||
import io.element.android.features.healthchat.api.HealthChatMember
|
||||
import io.element.android.features.healthchat.api.HealthChatMemberDirectory
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
|
||||
class FakeHealthChatDirectoryService(
|
||||
private val searchMembersLambda: suspend (String) -> Result<HealthChatMemberDirectory> = { lambdaError() },
|
||||
private val getMemberLambda: suspend (UserId) -> Result<HealthChatMember> = { lambdaError() },
|
||||
) : HealthChatDirectoryService {
|
||||
override suspend fun searchMembers(query: String): Result<HealthChatMemberDirectory> {
|
||||
return searchMembersLambda(query)
|
||||
}
|
||||
|
||||
override suspend fun getMember(userId: UserId): Result<HealthChatMember> {
|
||||
return getMemberLambda(userId)
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* 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.test
|
||||
|
||||
import io.element.android.features.healthchat.api.HealthChatInviteService
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
|
||||
class FakeHealthChatInviteService(
|
||||
private val inviteLambda: suspend (String, String) -> Result<String> = { _, _ -> lambdaError() },
|
||||
) : HealthChatInviteService {
|
||||
override suspend fun invite(email: String, personalNote: String): Result<String> {
|
||||
return inviteLambda(email, personalNote)
|
||||
}
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* 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.test
|
||||
|
||||
import io.element.android.features.healthchat.api.HealthChatCompleteness
|
||||
import io.element.android.features.healthchat.api.HealthChatMember
|
||||
import io.element.android.features.healthchat.api.HealthChatProfileRepository
|
||||
import io.element.android.features.healthchat.api.HealthChatProfileUpdate
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
class FakeHealthChatProfileRepository(
|
||||
private val refreshOwnProfileLambda: suspend () -> Result<HealthChatMember> = { lambdaError() },
|
||||
private val refreshCompletenessLambda: suspend () -> Result<HealthChatCompleteness> = { lambdaError() },
|
||||
private val updateOwnProfileLambda: suspend (HealthChatProfileUpdate) -> Result<String> = { lambdaError() },
|
||||
private val uploadAvatarLambda: suspend (String, String, ByteArray) -> Result<String> = { _, _, _ -> lambdaError() },
|
||||
private val deleteAvatarLambda: suspend () -> Result<String> = { lambdaError() },
|
||||
) : HealthChatProfileRepository {
|
||||
private val ownProfileFlow = MutableStateFlow<AsyncData<HealthChatMember>>(AsyncData.Uninitialized)
|
||||
override val ownProfile: StateFlow<AsyncData<HealthChatMember>> = ownProfileFlow
|
||||
|
||||
private val completenessFlow = MutableStateFlow<AsyncData<HealthChatCompleteness>>(AsyncData.Uninitialized)
|
||||
override val completeness: StateFlow<AsyncData<HealthChatCompleteness>> = completenessFlow
|
||||
|
||||
fun givenOwnProfile(value: AsyncData<HealthChatMember>) {
|
||||
ownProfileFlow.value = value
|
||||
}
|
||||
|
||||
fun givenCompleteness(value: AsyncData<HealthChatCompleteness>) {
|
||||
completenessFlow.value = value
|
||||
}
|
||||
|
||||
override suspend fun refreshOwnProfile(): Result<HealthChatMember> {
|
||||
return refreshOwnProfileLambda()
|
||||
.onSuccess { ownProfileFlow.value = AsyncData.Success(it) }
|
||||
.onFailure { ownProfileFlow.value = AsyncData.Failure(it) }
|
||||
}
|
||||
|
||||
override suspend fun refreshCompleteness(): Result<HealthChatCompleteness> {
|
||||
return refreshCompletenessLambda()
|
||||
.onSuccess { completenessFlow.value = AsyncData.Success(it) }
|
||||
.onFailure { completenessFlow.value = AsyncData.Failure(it) }
|
||||
}
|
||||
|
||||
override suspend fun updateOwnProfile(update: HealthChatProfileUpdate): Result<String> {
|
||||
return updateOwnProfileLambda(update)
|
||||
}
|
||||
|
||||
override suspend fun uploadAvatar(fileName: String, mimeType: String, data: ByteArray): Result<String> {
|
||||
return uploadAvatarLambda(fileName, mimeType, data)
|
||||
}
|
||||
|
||||
override suspend fun deleteAvatar(): Result<String> {
|
||||
return deleteAvatarLambda()
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* 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.test
|
||||
|
||||
import io.element.android.features.healthchat.api.HealthChatCompleteness
|
||||
import io.element.android.features.healthchat.api.HealthChatMember
|
||||
import io.element.android.features.healthchat.api.HealthChatMemberDirectory
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
fun aHealthChatMember(
|
||||
userId: UserId = UserId("@erika.muster:healthchat.ch"),
|
||||
displayName: String? = "Dr. med. Erika Muster",
|
||||
avatarUrl: String? = null,
|
||||
title: String? = "Dr. med.",
|
||||
firstName: String? = "Erika",
|
||||
lastName: String? = "Muster",
|
||||
specialty: String? = "Allgemeinmedizin",
|
||||
practiceName: String? = "Praxis Muster",
|
||||
businessCity: String? = "Zürich",
|
||||
furtherInfo: String? = null,
|
||||
businessEmail: String? = "praxis@muster.ch",
|
||||
businessTel: String? = "+41 44 123 45 67",
|
||||
street: String? = "Musterstrasse",
|
||||
houseNumber: String? = "12",
|
||||
postalCode: String? = "8001",
|
||||
website: String? = "https://praxis-muster.ch",
|
||||
isApproved: Boolean = true,
|
||||
) = HealthChatMember(
|
||||
userId = userId,
|
||||
displayName = displayName,
|
||||
avatarUrl = avatarUrl,
|
||||
title = title,
|
||||
firstName = firstName,
|
||||
lastName = lastName,
|
||||
specialty = specialty,
|
||||
practiceName = practiceName,
|
||||
businessCity = businessCity,
|
||||
furtherInfo = furtherInfo,
|
||||
businessEmail = businessEmail,
|
||||
businessTel = businessTel,
|
||||
street = street,
|
||||
houseNumber = houseNumber,
|
||||
postalCode = postalCode,
|
||||
website = website,
|
||||
isApproved = isApproved,
|
||||
)
|
||||
|
||||
fun aHealthChatMemberDirectory(
|
||||
members: List<HealthChatMember> = listOf(aHealthChatMember()),
|
||||
totalCount: Int = members.size,
|
||||
searchQuery: String = "",
|
||||
isApproved: Boolean = true,
|
||||
pendingMessage: String? = null,
|
||||
) = HealthChatMemberDirectory(
|
||||
members = members.toImmutableList(),
|
||||
totalCount = totalCount,
|
||||
searchQuery = searchQuery,
|
||||
isApproved = isApproved,
|
||||
pendingMessage = pendingMessage,
|
||||
)
|
||||
|
||||
fun aHealthChatCompleteness(
|
||||
isComplete: Boolean = true,
|
||||
missingFields: ImmutableList<String> = persistentListOf(),
|
||||
message: String? = null,
|
||||
hasProfilePhoto: Boolean = true,
|
||||
) = HealthChatCompleteness(
|
||||
isComplete = isComplete,
|
||||
missingFields = missingFields,
|
||||
message = message,
|
||||
hasProfilePhoto = hasProfilePhoto,
|
||||
)
|
||||
Reference in New Issue
Block a user