Add HealthChat API service layer (#1)

* feat(healthchat): add HealthChat API service layer

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

* test(healthchat): use runCatchingExceptions

Detekt's RunCatchingNotAllowed rule forbids plain runCatching.
This commit is contained in:
2026-08-05 14:13:00 +02:00
committed by GitHub
parent 621df1dedf
commit 2db4c9847f
46 changed files with 2328 additions and 0 deletions
+24
View File
@@ -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)
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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()
}
}
@@ -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,
)