From 95af70df51ecb6708538973e9546eba30a5fece8 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Wed, 21 Jan 2026 17:09:17 +0100 Subject: [PATCH 1/7] Fix crash on uploading files in new claim chat --- .../feature-claim-chat/build.gradle.kts | 1 + .../claim/chat/data/UploadFileUseCase.kt | 55 ++++++++++++------- 2 files changed, 36 insertions(+), 20 deletions(-) diff --git a/app/feature/feature-claim-chat/build.gradle.kts b/app/feature/feature-claim-chat/build.gradle.kts index 1d86ff541c..5d4a650d93 100644 --- a/app/feature/feature-claim-chat/build.gradle.kts +++ b/app/feature/feature-claim-chat/build.gradle.kts @@ -42,6 +42,7 @@ kotlin { implementation(projects.designSystemHedvig) implementation(projects.languageCore) implementation(projects.moleculePublic) + implementation(projects.networkClients) implementation(projects.uiClaimFlow) implementation(projects.uiForceUpgrade) } diff --git a/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/data/UploadFileUseCase.kt b/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/data/UploadFileUseCase.kt index 69e2025ea8..cb0b48cd9d 100644 --- a/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/data/UploadFileUseCase.kt +++ b/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/data/UploadFileUseCase.kt @@ -5,13 +5,14 @@ import arrow.core.raise.context.raise import com.hedvig.android.core.common.ErrorMessage import com.hedvig.android.logger.LogPriority import com.hedvig.android.logger.logcat +import com.hedvig.android.network.clients.NetworkError +import com.hedvig.android.network.clients.safePost import com.hedvig.feature.claim.chat.data.file.CommonFile import io.ktor.client.HttpClient import io.ktor.client.plugins.onUpload import io.ktor.client.request.forms.InputProvider import io.ktor.client.request.forms.MultiPartFormDataContent import io.ktor.client.request.forms.formData -import io.ktor.client.request.post import io.ktor.client.request.setBody import io.ktor.client.statement.bodyAsText import io.ktor.http.Headers @@ -26,26 +27,40 @@ internal class UploadFileUseCase(private val client: HttpClient) { context(_: Raise) suspend fun invoke(commonFile: CommonFile, uploadUrl: String): FileUploadResponse { // todo URL for prod/staging - val response = client.post("https://gateway.test.hedvig.com$uploadUrl") { - setBody( - MultiPartFormDataContent( - formData { - append("description", commonFile.description) - append( - "files", - InputProvider { commonFile.source() }, - Headers.build { - append(HttpHeaders.ContentType, commonFile.mimeType) - append(HttpHeaders.ContentDisposition, """filename="${commonFile.fileName}"""") - }, - ) - }, - ), - ) - onUpload { bytesSentTotal, contentLength -> - logcat(LogPriority.VERBOSE) { "UploadFileUseCase bytesSentTotal:$bytesSentTotal contentLength:$contentLength" } + val response = client + .safePost("https://gateway.test.hedvig.com$uploadUrl") { + setBody( + MultiPartFormDataContent( + formData { + append("description", commonFile.description) + append( + "files", + InputProvider { commonFile.source() }, + Headers.build { + append(HttpHeaders.ContentType, commonFile.mimeType) + append(HttpHeaders.ContentDisposition, """filename="${commonFile.fileName}"""") + }, + ) + }, + ), + ) + onUpload { bytesSentTotal, contentLength -> + logcat(LogPriority.VERBOSE) { + "UploadFileUseCase bytesSentTotal:$bytesSentTotal contentLength:$contentLength" + } + } } - } + .fold( + ifLeft = { error -> + raise( + when (error) { + is NetworkError.IOError -> ErrorMessage("Network error: ${error.message}", error.throwable) + is NetworkError.UnknownError -> ErrorMessage(error.message, error.throwable) + }, + ) + }, + ifRight = { it }, + ) return if (response.status.isSuccess()) { val jsonResponse = Json.parseToJsonElement(response.bodyAsText()) From ec410b62203d19091ad06abb5ad4259f71b048d8 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Thu, 22 Jan 2026 14:54:34 +0100 Subject: [PATCH 2/7] Turn core-file-upload into a KMP module This change introduces a new Kotlin Multiplatform (KMP) module, `core-file-upload`, to abstract and centralize file handling logic, including uploads and downloads. Key changes include: - Introducing `CommonFile` and `FileService` interfaces for platform-agnostic file operations. - Creating a generic `FileUploadService` to handle multipart file uploads. - Implementing platform-specific versions (`Android`, `Native`, `JVM`) for file services and PDF downloading. - Refactoring existing features (Chat, Claims, Certificates) to use the new module, replacing direct `android.net.Uri` usage with the multiplatform `com.eygraber.uri.Uri`. - Updating the `DownloadPdfUseCase` to return a platform-agnostic `DownloadedFile` object instead of a `java.io.File`. --- app/core/core-file-upload/build.gradle.kts | 38 +++-- .../AndroidDownloadPdfUseCaseImpl.kt | 69 ++++++++ .../android/core/fileupload/AndroidFile.kt | 43 +++++ .../core/fileupload/AndroidFileService.kt | 96 ++++++++++++ .../fileupload/FileUploadModule.android.kt | 24 +++ .../ClaimsServiceUploadFileUseCase.kt | 85 ++++++++++ .../android/core/fileupload/CommonFile.kt | 23 +++ .../core/fileupload/DownloadPdfUseCase.kt | 20 +++ .../android/core/fileupload/FileService.kt | 28 ++++ .../core/fileupload/FileUploadExceptions.kt | 10 ++ .../core/fileupload/FileUploadModule.kt | 27 ++++ .../core/fileupload/FileUploadService.kt | 147 ++++++++++++++++++ .../core/fileupload/FileUploadModule.jvm.kt | 14 ++ .../fileupload/JvmDownloadPdfUseCaseImpl.kt | 10 ++ .../hedvig/android/core/fileupload/JvmFile.kt | 16 ++ .../android/core/fileupload/JvmFileService.kt | 24 +++ .../fileupload/FileUploadModule.native.kt | 14 ++ .../NativeDownloadPdfUseCaseImpl.kt | 10 ++ .../android/core/fileupload/NativeFile.kt | 16 ++ .../core/fileupload/NativeFileService.kt | 24 +++ .../feature/chat/data/BotServiceService.kt | 7 +- .../feature/chat/data/CbmChatRepository.kt | 5 +- .../android/feature/chat/di/ChatModule.kt | 1 + .../claim/chat/data/file/AndroidFile.kt | 2 +- .../claim/details/ui/AddFilesDestination.kt | 8 +- .../claim/details/ui/AddFilesViewModel.kt | 4 +- .../details/ui/ClaimDetailsDestination.kt | 2 +- .../claim/details/ui/ClaimDetailsViewModel.kt | 11 +- .../InsuranceEvidenceOverviewDestination.kt | 4 +- .../InsuranceEvidenceOverviewViewModel.kt | 10 +- .../step/fileupload/FileUploadDestination.kt | 7 +- .../step/fileupload/FileUploadViewModel.kt | 4 +- .../ui/history/CertificateHistoryViewModel.kt | 12 +- .../ui/history/TravelCertificateHistory.kt | 2 +- .../ui/overview/TravelCertificateOverview.kt | 4 +- .../TravelCertificateOverviewViewModel.kt | 10 +- 36 files changed, 773 insertions(+), 58 deletions(-) create mode 100644 app/core/core-file-upload/src/androidMain/kotlin/com/hedvig/android/core/fileupload/AndroidDownloadPdfUseCaseImpl.kt create mode 100644 app/core/core-file-upload/src/androidMain/kotlin/com/hedvig/android/core/fileupload/AndroidFile.kt create mode 100644 app/core/core-file-upload/src/androidMain/kotlin/com/hedvig/android/core/fileupload/AndroidFileService.kt create mode 100644 app/core/core-file-upload/src/androidMain/kotlin/com/hedvig/android/core/fileupload/FileUploadModule.android.kt create mode 100644 app/core/core-file-upload/src/commonMain/kotlin/com/hedvig/android/core/fileupload/ClaimsServiceUploadFileUseCase.kt create mode 100644 app/core/core-file-upload/src/commonMain/kotlin/com/hedvig/android/core/fileupload/CommonFile.kt create mode 100644 app/core/core-file-upload/src/commonMain/kotlin/com/hedvig/android/core/fileupload/DownloadPdfUseCase.kt create mode 100644 app/core/core-file-upload/src/commonMain/kotlin/com/hedvig/android/core/fileupload/FileService.kt create mode 100644 app/core/core-file-upload/src/commonMain/kotlin/com/hedvig/android/core/fileupload/FileUploadExceptions.kt create mode 100644 app/core/core-file-upload/src/commonMain/kotlin/com/hedvig/android/core/fileupload/FileUploadModule.kt create mode 100644 app/core/core-file-upload/src/commonMain/kotlin/com/hedvig/android/core/fileupload/FileUploadService.kt create mode 100644 app/core/core-file-upload/src/jvmMain/kotlin/com/hedvig/android/core/fileupload/FileUploadModule.jvm.kt create mode 100644 app/core/core-file-upload/src/jvmMain/kotlin/com/hedvig/android/core/fileupload/JvmDownloadPdfUseCaseImpl.kt create mode 100644 app/core/core-file-upload/src/jvmMain/kotlin/com/hedvig/android/core/fileupload/JvmFile.kt create mode 100644 app/core/core-file-upload/src/jvmMain/kotlin/com/hedvig/android/core/fileupload/JvmFileService.kt create mode 100644 app/core/core-file-upload/src/nativeMain/kotlin/com/hedvig/android/core/fileupload/FileUploadModule.native.kt create mode 100644 app/core/core-file-upload/src/nativeMain/kotlin/com/hedvig/android/core/fileupload/NativeDownloadPdfUseCaseImpl.kt create mode 100644 app/core/core-file-upload/src/nativeMain/kotlin/com/hedvig/android/core/fileupload/NativeFile.kt create mode 100644 app/core/core-file-upload/src/nativeMain/kotlin/com/hedvig/android/core/fileupload/NativeFileService.kt diff --git a/app/core/core-file-upload/build.gradle.kts b/app/core/core-file-upload/build.gradle.kts index 6719b1d91a..574a37456f 100644 --- a/app/core/core-file-upload/build.gradle.kts +++ b/app/core/core-file-upload/build.gradle.kts @@ -1,24 +1,32 @@ plugins { - id("hedvig.android.library") + id("hedvig.multiplatform.library") + id("hedvig.multiplatform.library.android") id("hedvig.gradle.plugin") } hedvig { serialization() - compose() } -dependencies { - api(libs.androidx.compose.foundation) - implementation(libs.arrow.core) - implementation(libs.koin.core) - implementation(libs.kotlinx.serialization.core) - implementation(libs.kotlinx.serialization.json) - implementation(libs.ktor.client.core) - implementation(projects.apolloOctopusPublic) - implementation(projects.coreBuildConstants) - implementation(projects.coreCommonPublic) - implementation(projects.coreResources) - implementation(projects.designSystemHedvig) - implementation(projects.networkClients) +kotlin { + sourceSets { + commonMain.dependencies { + implementation(libs.arrow.core) + implementation(libs.koin.core) + implementation(libs.kotlinx.serialization.core) + implementation(libs.kotlinx.serialization.json) + implementation(libs.ktor.client.core) + implementation(libs.uri.kmp) + api(projects.coreBuildConstants) + api(projects.coreCommonPublic) + implementation(projects.networkClients) + } + + androidMain.dependencies { + api(libs.androidx.compose.foundation) + implementation(projects.apolloOctopusPublic) + implementation(projects.coreResources) + implementation(projects.designSystemHedvig) + } + } } diff --git a/app/core/core-file-upload/src/androidMain/kotlin/com/hedvig/android/core/fileupload/AndroidDownloadPdfUseCaseImpl.kt b/app/core/core-file-upload/src/androidMain/kotlin/com/hedvig/android/core/fileupload/AndroidDownloadPdfUseCaseImpl.kt new file mode 100644 index 0000000000..7bea154716 --- /dev/null +++ b/app/core/core-file-upload/src/androidMain/kotlin/com/hedvig/android/core/fileupload/AndroidDownloadPdfUseCaseImpl.kt @@ -0,0 +1,69 @@ +package com.hedvig.android.core.fileupload + +import android.content.Context +import arrow.core.Either +import arrow.core.raise.either +import com.hedvig.android.core.common.ErrorMessage +import com.hedvig.android.logger.LogPriority +import com.hedvig.android.logger.logcat +import io.ktor.client.HttpClient +import io.ktor.client.request.prepareGet +import io.ktor.client.statement.bodyAsChannel +import io.ktor.utils.io.readAvailable +import java.io.File +import java.time.format.DateTimeFormatter +import kotlin.coroutines.cancellation.CancellationException +import kotlin.time.Clock +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toJavaLocalDateTime +import kotlinx.datetime.toLocalDateTime +import okio.buffer +import okio.sink + +private const val FILE_NAME = "hedvig_" +private const val FILE_EXT = ".pdf" + +internal class AndroidDownloadPdfUseCaseImpl( + private val context: Context, + private val clock: Clock, + private val httpClient: HttpClient, +) : DownloadPdfUseCase { + override suspend fun invoke(url: String): Either = withContext(Dispatchers.IO) { + either { + try { + val now = DateTimeFormatter.ISO_DATE_TIME.format( + clock.now() + .toLocalDateTime(TimeZone.UTC) + .toJavaLocalDateTime(), + ) + + val downloadedFile = File(context.filesDir, FILE_NAME + now + FILE_EXT) + + httpClient.prepareGet(url).execute { response -> + val channel = response.bodyAsChannel() + downloadedFile.sink().buffer().use { fileSink -> + val buffer = ByteArray(8192) + while (true) { + val bytesRead = channel.readAvailable(buffer) + if (bytesRead == -1) break + fileSink.write(buffer, 0, bytesRead) + } + } + } + + DownloadedFile( + path = downloadedFile.absolutePath, + name = downloadedFile.name, + ) + } catch (exception: Exception) { + if (exception is CancellationException) { + throw exception + } + logcat(LogPriority.ERROR, exception) { "Could not download pdf with: $exception" } + raise(ErrorMessage("Could not download pdf")) + } + } + } +} diff --git a/app/core/core-file-upload/src/androidMain/kotlin/com/hedvig/android/core/fileupload/AndroidFile.kt b/app/core/core-file-upload/src/androidMain/kotlin/com/hedvig/android/core/fileupload/AndroidFile.kt new file mode 100644 index 0000000000..40b40ae4f2 --- /dev/null +++ b/app/core/core-file-upload/src/androidMain/kotlin/com/hedvig/android/core/fileupload/AndroidFile.kt @@ -0,0 +1,43 @@ +package com.hedvig.android.core.fileupload + +import android.content.ContentResolver +import android.net.Uri as AndroidUri +import android.provider.OpenableColumns +import kotlinx.io.IOException +import kotlinx.io.Source +import kotlinx.io.asSource +import kotlinx.io.buffered +import kotlin.math.max + +class AndroidFile( + override val fileName: String, + override val mimeType: String, + private val contentResolver: ContentResolver, + private val androidUri: AndroidUri, +) : CommonFile { + + override fun source(): Source { + val inputStream = contentResolver.openInputStream(androidUri) + ?: throw IOException("Could not open input stream for uri:$androidUri") + return inputStream.asSource().buffered() + } + + override fun getSize(): Long { + val statSize = contentResolver.openFileDescriptor(androidUri, "r")?.use { + it.statSize + } ?: -1 + + val sizeFromCursor = contentResolver.query( + androidUri, + arrayOf(OpenableColumns.SIZE), + null, + null, + null, + )?.use { cursor -> + val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE) + if (cursor.moveToFirst()) cursor.getLong(sizeIndex) else null + } ?: -1 + + return max(statSize, sizeFromCursor) + } +} diff --git a/app/core/core-file-upload/src/androidMain/kotlin/com/hedvig/android/core/fileupload/AndroidFileService.kt b/app/core/core-file-upload/src/androidMain/kotlin/com/hedvig/android/core/fileupload/AndroidFileService.kt new file mode 100644 index 0000000000..d32380b0fe --- /dev/null +++ b/app/core/core-file-upload/src/androidMain/kotlin/com/hedvig/android/core/fileupload/AndroidFileService.kt @@ -0,0 +1,96 @@ +package com.hedvig.android.core.fileupload + +import android.content.ContentResolver +import android.net.Uri as AndroidUri +import android.provider.OpenableColumns +import android.webkit.MimeTypeMap +import com.eygraber.uri.Uri +import com.eygraber.uri.toAndroidUri +import com.hedvig.android.logger.logcat +import java.util.Locale +import kotlin.math.max + +class AndroidFileService( + private val contentResolver: ContentResolver, +) : FileService { + + override fun convertToCommonFile(uri: Uri): CommonFile { + val androidUri = uri.toAndroidUri() + + return AndroidFile( + fileName = getFileName(uri) ?: "media", + mimeType = getMimeType(uri), + contentResolver = contentResolver, + androidUri = androidUri, + ) + } + + override fun getFileName(uri: Uri): String? { + val androidUri = uri.toAndroidUri() + + if (androidUri.scheme == ContentResolver.SCHEME_CONTENT) { + val cursor = contentResolver.query(androidUri, null, null, null, null) + cursor.use { c -> + if (c?.moveToFirst() == true) { + val columnIndex = c.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (columnIndex >= 0) { + return c.getString(columnIndex) + } + } + } + } + + val path = androidUri.path + return path?.substringAfterLast('/') ?: path + } + + override fun getMimeType(uri: Uri): String { + val androidUri = uri.toAndroidUri() + + if (androidUri.scheme == ContentResolver.SCHEME_CONTENT) { + val resolvedMimeType = contentResolver.getType(androidUri) + if (resolvedMimeType != null) { + return resolvedMimeType + } + } + + return getMimeTypeFromPath(uri.toString()) + } + + override fun isFileSizeWithinBackendLimits(uri: Uri): Boolean { + val androidUri = uri.toAndroidUri() + val size = getFileSize(androidUri) + logcat { + "FileService: Size of the file: ${size / 1024 / 1024} Mb, " + + "Backend limit: ${BACKEND_CONTENT_SIZE_LIMIT / 1024 / 1024} Mb" + } + return size < BACKEND_CONTENT_SIZE_LIMIT + } + + private fun getFileSize(androidUri: AndroidUri): Long { + val statSize = contentResolver.openFileDescriptor(androidUri, "r")?.use { + it.statSize + } ?: -1 + + val sizeFromCursor = contentResolver.query( + androidUri, + arrayOf(OpenableColumns.SIZE), + null, + null, + null, + )?.use { cursor -> + val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE) + if (cursor.moveToFirst()) cursor.getLong(sizeIndex) else null + } ?: -1 + + logcat { "getFileSize for uri:$androidUri | statSize:$statSize | contentSize:$sizeFromCursor" } + return max(statSize, sizeFromCursor) + } + + private fun getMimeTypeFromPath(path: String): String { + val fileExtension = MimeTypeMap.getFileExtensionFromUrl(path) + return MimeTypeMap.getSingleton() + .getMimeTypeFromExtension(fileExtension.lowercase(Locale.getDefault())) + ?: "" + } +} diff --git a/app/core/core-file-upload/src/androidMain/kotlin/com/hedvig/android/core/fileupload/FileUploadModule.android.kt b/app/core/core-file-upload/src/androidMain/kotlin/com/hedvig/android/core/fileupload/FileUploadModule.android.kt new file mode 100644 index 0000000000..662f6b82bb --- /dev/null +++ b/app/core/core-file-upload/src/androidMain/kotlin/com/hedvig/android/core/fileupload/FileUploadModule.android.kt @@ -0,0 +1,24 @@ +package com.hedvig.android.core.fileupload + +import android.content.Context +import com.hedvig.android.core.common.di.baseHttpClientQualifier +import io.ktor.client.HttpClient +import kotlin.time.Clock +import org.koin.core.module.Module +import org.koin.dsl.module + +actual val fileUploadPlatformModule: Module = module { + single { + AndroidFileService( + contentResolver = get().contentResolver, + ) + } + + single { + AndroidDownloadPdfUseCaseImpl( + context = get(), + clock = get(), + httpClient = get(baseHttpClientQualifier), + ) + } +} diff --git a/app/core/core-file-upload/src/commonMain/kotlin/com/hedvig/android/core/fileupload/ClaimsServiceUploadFileUseCase.kt b/app/core/core-file-upload/src/commonMain/kotlin/com/hedvig/android/core/fileupload/ClaimsServiceUploadFileUseCase.kt new file mode 100644 index 0000000000..470edb66ea --- /dev/null +++ b/app/core/core-file-upload/src/commonMain/kotlin/com/hedvig/android/core/fileupload/ClaimsServiceUploadFileUseCase.kt @@ -0,0 +1,85 @@ +package com.hedvig.android.core.fileupload + +import arrow.core.Either +import arrow.core.raise.Raise +import arrow.core.raise.either +import arrow.core.raise.ensureNotNull +import com.eygraber.uri.Uri +import com.hedvig.android.core.buildconstants.HedvigBuildConstants +import com.hedvig.android.core.common.ErrorMessage +import com.hedvig.android.logger.LogPriority +import com.hedvig.android.logger.logcat +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +interface ClaimsServiceUploadFileUseCase { + suspend fun invoke(url: String, uri: Uri): Either + + suspend fun invoke(url: String, uris: List): Either +} + +internal data class ClaimsServiceUploadFileUseCaseImpl( + private val fileUploadService: FileUploadService, + private val buildConstants: HedvigBuildConstants, + private val fileService: FileService, +) : ClaimsServiceUploadFileUseCase { + override suspend fun invoke(url: String, uri: Uri): Either { + return invoke(url, listOf(uri)) + } + + override suspend fun invoke(url: String, uris: List): Either = either { + val claimId = url.substringAfter("claimId=", "").substringBefore("&") + if (claimId.isEmpty()) { + raise(ErrorMessage("No claim id found in url")) + } + + val result = either { + uploadFiles(claimId = claimId, uris = uris) + } + .onLeft { + logcat(LogPriority.ERROR) { "Failed to upload file. Error:$it" } + } + .bind() + + handleResult(result) + } + + context(_: Raise) + private suspend fun uploadFiles(claimId: String, uris: List): List { + // Convert URIs to CommonFiles + val files = uris.map { uri -> fileService.convertToCommonFile(uri) } + + return fileUploadService.uploadFiles( + url = "${buildConstants.urlClaimsService}/api/claim-files/upload?claimId=$claimId", + files = files, + validateFileSize = false, // Claims service doesn't require file size validation + deserializer = { responseBody -> + Json.decodeFromString>(responseBody) + }, + ) + } + + private fun Raise.handleResult(result: List): UploadSuccess { + val fileUploadResponse = result.firstOrNull() ?: raise(ErrorMessage("No file upload response")) + ensureNotNull(fileUploadResponse.file) { + ErrorMessage("Backend responded with an empty list as a response$result") + } + + return UploadSuccess(fileIds = result.mapNotNull { it.file.fileId }) + } +} + +@Serializable +internal data class FileUploadResponse(val file: FileResponse, val error: String?) + +@Serializable +internal data class FileResponse( + val fileId: String?, + val mimeType: String?, + val name: String?, + val url: String?, +) + +data class UploadSuccess( + val fileIds: List, +) diff --git a/app/core/core-file-upload/src/commonMain/kotlin/com/hedvig/android/core/fileupload/CommonFile.kt b/app/core/core-file-upload/src/commonMain/kotlin/com/hedvig/android/core/fileupload/CommonFile.kt new file mode 100644 index 0000000000..7efdc254c8 --- /dev/null +++ b/app/core/core-file-upload/src/commonMain/kotlin/com/hedvig/android/core/fileupload/CommonFile.kt @@ -0,0 +1,23 @@ +package com.hedvig.android.core.fileupload + +import kotlinx.io.Source + +/** + * Platform-agnostic representation of a file for upload. + * Implementations provide platform-specific ways to access file data. + */ +interface CommonFile { + val fileName: String + val mimeType: String + + /** + * Returns a Source for streaming the file contents. + * This Source should be buffered and ready for reading. + */ + fun source(): Source + + /** + * Returns the file size in bytes, or -1 if unknown. + */ + fun getSize(): Long +} diff --git a/app/core/core-file-upload/src/commonMain/kotlin/com/hedvig/android/core/fileupload/DownloadPdfUseCase.kt b/app/core/core-file-upload/src/commonMain/kotlin/com/hedvig/android/core/fileupload/DownloadPdfUseCase.kt new file mode 100644 index 0000000000..09d9522c54 --- /dev/null +++ b/app/core/core-file-upload/src/commonMain/kotlin/com/hedvig/android/core/fileupload/DownloadPdfUseCase.kt @@ -0,0 +1,20 @@ +package com.hedvig.android.core.fileupload + +import arrow.core.Either +import com.hedvig.android.core.common.ErrorMessage + +/** + * Platform-agnostic representation of a downloaded file. + */ +data class DownloadedFile( + val path: String, + val name: String, +) + +interface DownloadPdfUseCase { + /** + * Downloads a PDF from the given URL. + * Returns a platform-specific file path or identifier. + */ + suspend fun invoke(url: String): Either +} diff --git a/app/core/core-file-upload/src/commonMain/kotlin/com/hedvig/android/core/fileupload/FileService.kt b/app/core/core-file-upload/src/commonMain/kotlin/com/hedvig/android/core/fileupload/FileService.kt new file mode 100644 index 0000000000..44cce1666d --- /dev/null +++ b/app/core/core-file-upload/src/commonMain/kotlin/com/hedvig/android/core/fileupload/FileService.kt @@ -0,0 +1,28 @@ +package com.hedvig.android.core.fileupload + +import com.eygraber.uri.Uri + +/** + * Platform-agnostic file service for file metadata operations. + */ +interface FileService { + /** + * Converts a URI to a CommonFile that can be uploaded. + */ + fun convertToCommonFile(uri: Uri): CommonFile + + /** + * Returns the file name from a URI, or null if it cannot be determined. + */ + fun getFileName(uri: Uri): String? + + /** + * Returns the MIME type for a given URI. + */ + fun getMimeType(uri: Uri): String + + /** + * Checks if file size is within backend limits (512 MB). + */ + fun isFileSizeWithinBackendLimits(uri: Uri): Boolean +} diff --git a/app/core/core-file-upload/src/commonMain/kotlin/com/hedvig/android/core/fileupload/FileUploadExceptions.kt b/app/core/core-file-upload/src/commonMain/kotlin/com/hedvig/android/core/fileupload/FileUploadExceptions.kt new file mode 100644 index 0000000000..9759e22eee --- /dev/null +++ b/app/core/core-file-upload/src/commonMain/kotlin/com/hedvig/android/core/fileupload/FileUploadExceptions.kt @@ -0,0 +1,10 @@ +package com.hedvig.android.core.fileupload + +import kotlinx.io.IOException + +class BackendFileLimitException(message: String) : IOException(message) { + constructor(uri: String, limit: Long) : + this("Failed to upload with uri:$uri. Content size above backend limit:$limit") +} + +internal const val BACKEND_CONTENT_SIZE_LIMIT = 512 * 1024 * 1024L // 512 MB diff --git a/app/core/core-file-upload/src/commonMain/kotlin/com/hedvig/android/core/fileupload/FileUploadModule.kt b/app/core/core-file-upload/src/commonMain/kotlin/com/hedvig/android/core/fileupload/FileUploadModule.kt new file mode 100644 index 0000000000..ff2dfe834b --- /dev/null +++ b/app/core/core-file-upload/src/commonMain/kotlin/com/hedvig/android/core/fileupload/FileUploadModule.kt @@ -0,0 +1,27 @@ +package com.hedvig.android.core.fileupload + +import com.hedvig.android.core.buildconstants.HedvigBuildConstants +import io.ktor.client.HttpClient +import org.koin.core.module.Module +import org.koin.dsl.module + +val fileUploadModule = module { + includes(fileUploadPlatformModule) + + single { + FileUploadService( + client = get(), + fileService = get(), + ) + } + + single { + ClaimsServiceUploadFileUseCaseImpl( + fileUploadService = get(), + buildConstants = get(), + fileService = get(), + ) + } +} + +expect val fileUploadPlatformModule: Module diff --git a/app/core/core-file-upload/src/commonMain/kotlin/com/hedvig/android/core/fileupload/FileUploadService.kt b/app/core/core-file-upload/src/commonMain/kotlin/com/hedvig/android/core/fileupload/FileUploadService.kt new file mode 100644 index 0000000000..892d4786e7 --- /dev/null +++ b/app/core/core-file-upload/src/commonMain/kotlin/com/hedvig/android/core/fileupload/FileUploadService.kt @@ -0,0 +1,147 @@ +package com.hedvig.android.core.fileupload + +import arrow.core.raise.Raise +import arrow.core.raise.context.raise +import com.hedvig.android.core.common.ErrorMessage +import com.hedvig.android.logger.LogPriority +import com.hedvig.android.logger.logcat +import com.hedvig.android.network.clients.NetworkError +import com.hedvig.android.network.clients.safePost +import io.ktor.client.HttpClient +import io.ktor.client.request.forms.FormBuilder +import io.ktor.client.request.forms.InputProvider +import io.ktor.client.request.forms.MultiPartFormDataContent +import io.ktor.client.request.forms.formData +import io.ktor.client.request.setBody +import io.ktor.client.statement.HttpResponse +import io.ktor.client.statement.bodyAsText +import io.ktor.http.Headers +import io.ktor.http.HttpHeaders +import io.ktor.http.isSuccess + +class FileUploadService( + private val client: HttpClient, + private val fileService: FileService, +) { + /** + * Uploads files to a backend service with optional file size validation. + * + * @param url The full URL including any query parameters + * @param files List of CommonFiles to upload + * @param validateFileSize Whether to validate file sizes before upload (default: false) + * @param deserializer Function to deserialize the response body + * @return The deserialized response + */ + context(_: Raise) + suspend fun uploadFiles( + url: String, + files: List, + validateFileSize: Boolean = false, + deserializer: (String) -> T, + ): T { + logcat { "FileUploadService: Uploading ${files.size} file(s) to $url" } + + // Validate file sizes if requested + if (validateFileSize) { + files.forEach { file -> + val size = file.getSize() + if (size >= BACKEND_CONTENT_SIZE_LIMIT) { + raise( + ErrorMessage( + "File ${file.fileName} exceeds backend size limit", + BackendFileLimitException(file.fileName, BACKEND_CONTENT_SIZE_LIMIT), + ), + ) + } + } + } + + val response: HttpResponse = client + .safePost(url) { + setBody( + MultiPartFormDataContent( + formData { + files.forEach { file -> + append( + "files", + InputProvider { file.source() }, + Headers.build { + append(HttpHeaders.ContentType, file.mimeType) + append(HttpHeaders.ContentDisposition, """filename="${file.fileName}"""") + }, + ) + } + }, + ), + ) + } + .fold( + ifLeft = { error -> + raise( + when (error) { + is NetworkError.IOError -> ErrorMessage("Network error: ${error.message}", error.throwable) + is NetworkError.UnknownError -> ErrorMessage(error.message, error.throwable) + }, + ) + }, + ifRight = { it }, + ) + + return if (response.status.isSuccess()) { + val responseBody = response.bodyAsText() + logcat { "FileUploadService: Upload successful, response: $responseBody" } + deserializer(responseBody) + } else { + val errorBody = response.bodyAsText() + logcat(LogPriority.ERROR) { + "FileUploadService failed with status ${response.status}: $errorBody" + } + raise(ErrorMessage("File upload failed with status ${response.status}: $errorBody")) + } + } + + /** + * Uploads a file with custom form data builder. + * + * @param url The full URL including any query parameters + * @param formDataBuilder Lambda to build custom form data with files and additional fields + * @param deserializer Function to deserialize the response body + * @return The deserialized response + */ + context(_: Raise) + suspend fun uploadWithCustomFormData( + url: String, + formDataBuilder: FormBuilder.() -> Unit, + deserializer: (String) -> T, + ): T { + logcat { "FileUploadService: Uploading to $url with custom form data" } + + val response: HttpResponse = client + .safePost(url) { + setBody(MultiPartFormDataContent(formData(formDataBuilder))) + } + .fold( + ifLeft = { error -> + raise( + when (error) { + is NetworkError.IOError -> ErrorMessage("Network error: ${error.message}", error.throwable) + is NetworkError.UnknownError -> ErrorMessage(error.message, error.throwable) + }, + ) + }, + ifRight = { it }, + ) + + return if (response.status.isSuccess()) { + val responseBody = response.bodyAsText() + logcat { "FileUploadService: Upload successful, response: $responseBody" } + deserializer(responseBody) + } else { + val errorBody = response.bodyAsText() + logcat(LogPriority.ERROR) { + "FileUploadService failed with status ${response.status}: $errorBody" + } + raise(ErrorMessage("File upload failed with status ${response.status}: $errorBody")) + } + } +} diff --git a/app/core/core-file-upload/src/jvmMain/kotlin/com/hedvig/android/core/fileupload/FileUploadModule.jvm.kt b/app/core/core-file-upload/src/jvmMain/kotlin/com/hedvig/android/core/fileupload/FileUploadModule.jvm.kt new file mode 100644 index 0000000000..7a83b3b8f2 --- /dev/null +++ b/app/core/core-file-upload/src/jvmMain/kotlin/com/hedvig/android/core/fileupload/FileUploadModule.jvm.kt @@ -0,0 +1,14 @@ +package com.hedvig.android.core.fileupload + +import org.koin.core.module.Module +import org.koin.dsl.module + +actual val fileUploadPlatformModule: Module = module { + single { + JvmFileService() + } + + single { + JvmDownloadPdfUseCaseImpl() + } +} diff --git a/app/core/core-file-upload/src/jvmMain/kotlin/com/hedvig/android/core/fileupload/JvmDownloadPdfUseCaseImpl.kt b/app/core/core-file-upload/src/jvmMain/kotlin/com/hedvig/android/core/fileupload/JvmDownloadPdfUseCaseImpl.kt new file mode 100644 index 0000000000..46183eedf2 --- /dev/null +++ b/app/core/core-file-upload/src/jvmMain/kotlin/com/hedvig/android/core/fileupload/JvmDownloadPdfUseCaseImpl.kt @@ -0,0 +1,10 @@ +package com.hedvig.android.core.fileupload + +import arrow.core.Either +import com.hedvig.android.core.common.ErrorMessage + +internal class JvmDownloadPdfUseCaseImpl : DownloadPdfUseCase { + override suspend fun invoke(url: String): Either { + TODO("JVM PDF download not yet implemented") + } +} diff --git a/app/core/core-file-upload/src/jvmMain/kotlin/com/hedvig/android/core/fileupload/JvmFile.kt b/app/core/core-file-upload/src/jvmMain/kotlin/com/hedvig/android/core/fileupload/JvmFile.kt new file mode 100644 index 0000000000..60a9c0c7be --- /dev/null +++ b/app/core/core-file-upload/src/jvmMain/kotlin/com/hedvig/android/core/fileupload/JvmFile.kt @@ -0,0 +1,16 @@ +package com.hedvig.android.core.fileupload + +import kotlinx.io.Source + +class JvmFile( + override val fileName: String, + override val mimeType: String, +) : CommonFile { + override fun source(): Source { + TODO("JVM file upload not yet implemented") + } + + override fun getSize(): Long { + TODO("JVM file upload not yet implemented") + } +} diff --git a/app/core/core-file-upload/src/jvmMain/kotlin/com/hedvig/android/core/fileupload/JvmFileService.kt b/app/core/core-file-upload/src/jvmMain/kotlin/com/hedvig/android/core/fileupload/JvmFileService.kt new file mode 100644 index 0000000000..be108c3313 --- /dev/null +++ b/app/core/core-file-upload/src/jvmMain/kotlin/com/hedvig/android/core/fileupload/JvmFileService.kt @@ -0,0 +1,24 @@ +package com.hedvig.android.core.fileupload + +import com.eygraber.uri.Uri + +class JvmFileService : FileService { + override fun convertToCommonFile(uri: Uri): CommonFile { + return JvmFile( + fileName = "todo", + mimeType = "todo", + ) + } + + override fun getFileName(uri: Uri): String? { + TODO("JVM file operations not yet implemented") + } + + override fun getMimeType(uri: Uri): String { + TODO("JVM file operations not yet implemented") + } + + override fun isFileSizeWithinBackendLimits(uri: Uri): Boolean { + TODO("JVM file operations not yet implemented") + } +} diff --git a/app/core/core-file-upload/src/nativeMain/kotlin/com/hedvig/android/core/fileupload/FileUploadModule.native.kt b/app/core/core-file-upload/src/nativeMain/kotlin/com/hedvig/android/core/fileupload/FileUploadModule.native.kt new file mode 100644 index 0000000000..8dc99d0fa3 --- /dev/null +++ b/app/core/core-file-upload/src/nativeMain/kotlin/com/hedvig/android/core/fileupload/FileUploadModule.native.kt @@ -0,0 +1,14 @@ +package com.hedvig.android.core.fileupload + +import org.koin.core.module.Module +import org.koin.dsl.module + +actual val fileUploadPlatformModule: Module = module { + single { + NativeFileService() + } + + single { + NativeDownloadPdfUseCaseImpl() + } +} diff --git a/app/core/core-file-upload/src/nativeMain/kotlin/com/hedvig/android/core/fileupload/NativeDownloadPdfUseCaseImpl.kt b/app/core/core-file-upload/src/nativeMain/kotlin/com/hedvig/android/core/fileupload/NativeDownloadPdfUseCaseImpl.kt new file mode 100644 index 0000000000..fbe4e3b01f --- /dev/null +++ b/app/core/core-file-upload/src/nativeMain/kotlin/com/hedvig/android/core/fileupload/NativeDownloadPdfUseCaseImpl.kt @@ -0,0 +1,10 @@ +package com.hedvig.android.core.fileupload + +import arrow.core.Either +import com.hedvig.android.core.common.ErrorMessage + +internal class NativeDownloadPdfUseCaseImpl : DownloadPdfUseCase { + override suspend fun invoke(url: String): Either { + TODO("Native PDF download not yet implemented") + } +} diff --git a/app/core/core-file-upload/src/nativeMain/kotlin/com/hedvig/android/core/fileupload/NativeFile.kt b/app/core/core-file-upload/src/nativeMain/kotlin/com/hedvig/android/core/fileupload/NativeFile.kt new file mode 100644 index 0000000000..c0fcb9277d --- /dev/null +++ b/app/core/core-file-upload/src/nativeMain/kotlin/com/hedvig/android/core/fileupload/NativeFile.kt @@ -0,0 +1,16 @@ +package com.hedvig.android.core.fileupload + +import kotlinx.io.Source + +class NativeFile( + override val fileName: String, + override val mimeType: String, +) : CommonFile { + override fun source(): Source { + TODO("Native file upload not yet implemented") + } + + override fun getSize(): Long { + TODO("Native file upload not yet implemented") + } +} diff --git a/app/core/core-file-upload/src/nativeMain/kotlin/com/hedvig/android/core/fileupload/NativeFileService.kt b/app/core/core-file-upload/src/nativeMain/kotlin/com/hedvig/android/core/fileupload/NativeFileService.kt new file mode 100644 index 0000000000..f0052e2f51 --- /dev/null +++ b/app/core/core-file-upload/src/nativeMain/kotlin/com/hedvig/android/core/fileupload/NativeFileService.kt @@ -0,0 +1,24 @@ +package com.hedvig.android.core.fileupload + +import com.eygraber.uri.Uri + +class NativeFileService : FileService { + override fun convertToCommonFile(uri: Uri): CommonFile { + return NativeFile( + fileName = "todo", + mimeType = "todo", + ) + } + + override fun getFileName(uri: Uri): String? { + TODO("Native file operations not yet implemented") + } + + override fun getMimeType(uri: Uri): String { + TODO("Native file operations not yet implemented") + } + + override fun isFileSizeWithinBackendLimits(uri: Uri): Boolean { + TODO("Native file operations not yet implemented") + } +} diff --git a/app/feature/feature-chat/src/main/kotlin/com/hedvig/android/feature/chat/data/BotServiceService.kt b/app/feature/feature-chat/src/main/kotlin/com/hedvig/android/feature/chat/data/BotServiceService.kt index 7162ee5a8b..c92bc466cb 100644 --- a/app/feature/feature-chat/src/main/kotlin/com/hedvig/android/feature/chat/data/BotServiceService.kt +++ b/app/feature/feature-chat/src/main/kotlin/com/hedvig/android/feature/chat/data/BotServiceService.kt @@ -1,15 +1,17 @@ package com.hedvig.android.feature.chat.data -import android.net.Uri import arrow.core.raise.Raise +import com.eygraber.uri.Uri import com.hedvig.android.core.buildconstants.HedvigBuildConstants import com.hedvig.android.core.common.ErrorMessage +import com.hedvig.android.core.fileupload.FileService import com.hedvig.android.core.fileupload.FileUploadService import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json internal class BotServiceService( private val fileUploadService: FileUploadService, + private val fileService: FileService, private val buildConstants: HedvigBuildConstants, ) { context(_: Raise) @@ -19,9 +21,10 @@ internal class BotServiceService( context(_: Raise) suspend fun uploadFiles(uris: List): List { + val files = uris.map { uri -> fileService.convertToCommonFile(uri) } return fileUploadService.uploadFiles( url = "${buildConstants.urlBotService}/api/files/upload", - uris = uris, + files = files, validateFileSize = true, // Bot service requires file size validation deserializer = { responseBody -> Json.decodeFromString>(responseBody) diff --git a/app/feature/feature-chat/src/main/kotlin/com/hedvig/android/feature/chat/data/CbmChatRepository.kt b/app/feature/feature-chat/src/main/kotlin/com/hedvig/android/feature/chat/data/CbmChatRepository.kt index 68d16c3bff..334d697fed 100644 --- a/app/feature/feature-chat/src/main/kotlin/com/hedvig/android/feature/chat/data/CbmChatRepository.kt +++ b/app/feature/feature-chat/src/main/kotlin/com/hedvig/android/feature/chat/data/CbmChatRepository.kt @@ -20,6 +20,7 @@ import com.apollographql.apollo.cache.normalized.FetchPolicy import com.apollographql.apollo.cache.normalized.doNotStore import com.apollographql.apollo.cache.normalized.fetchPolicy import com.benasher44.uuid.Uuid +import com.eygraber.uri.toKmpUri import com.hedvig.android.apollo.ApolloOperationError import com.hedvig.android.apollo.ErrorMessage import com.hedvig.android.apollo.safeExecute @@ -371,7 +372,7 @@ internal class CbmChatRepositoryImpl( private suspend fun Raise.uploadPhotoToBotService(uri: Uri): String { val file = uri.toFile() val uploadToken = either { - botServiceService.uploadFile(uri) + botServiceService.uploadFile(uri.toKmpUri()) }.mapLeft { logcat(LogPriority.ERROR) { "Failed to upload file with path:${file.absolutePath}. Error:$it" } it.toMessageSendError() @@ -383,7 +384,7 @@ internal class CbmChatRepositoryImpl( private suspend fun Raise.uploadMediaToBotService(uri: Uri): String { val uploadToken = either { - botServiceService.uploadFile(uri) + botServiceService.uploadFile(uri.toKmpUri()) }.mapLeft { logcat(LogPriority.ERROR) { "Failed to upload media with uri:$uri. Error:$it" } it.toMessageSendError() diff --git a/app/feature/feature-chat/src/main/kotlin/com/hedvig/android/feature/chat/di/ChatModule.kt b/app/feature/feature-chat/src/main/kotlin/com/hedvig/android/feature/chat/di/ChatModule.kt index d365e0f5e3..d5414f4211 100644 --- a/app/feature/feature-chat/src/main/kotlin/com/hedvig/android/feature/chat/di/ChatModule.kt +++ b/app/feature/feature-chat/src/main/kotlin/com/hedvig/android/feature/chat/di/ChatModule.kt @@ -26,6 +26,7 @@ val chatModule = module { single { BotServiceService( fileUploadService = get(), + fileService = get(), buildConstants = get(), ) } diff --git a/app/feature/feature-claim-chat/src/androidMain/kotlin/com/hedvig/feature/claim/chat/data/file/AndroidFile.kt b/app/feature/feature-claim-chat/src/androidMain/kotlin/com/hedvig/feature/claim/chat/data/file/AndroidFile.kt index 04e492dabc..5fa69de053 100644 --- a/app/feature/feature-claim-chat/src/androidMain/kotlin/com/hedvig/feature/claim/chat/data/file/AndroidFile.kt +++ b/app/feature/feature-claim-chat/src/androidMain/kotlin/com/hedvig/feature/claim/chat/data/file/AndroidFile.kt @@ -46,7 +46,7 @@ internal class AndroidFileService( override fun convertToCommonFile(uri: Uri): CommonFile { val androidUri = uri.toAndroidUri() - val fileName = coreFileService.getFileName(androidUri) ?: "media" + val fileName = coreFileService.getFileName(uri) ?: "media" val mimeType = getMimeType(uri.toString()) return AndroidFile( diff --git a/app/feature/feature-claim-details/src/main/kotlin/com/hedvig/android/feature/claim/details/ui/AddFilesDestination.kt b/app/feature/feature-claim-details/src/main/kotlin/com/hedvig/android/feature/claim/details/ui/AddFilesDestination.kt index a998f571d1..70141caea0 100644 --- a/app/feature/feature-claim-details/src/main/kotlin/com/hedvig/android/feature/claim/details/ui/AddFilesDestination.kt +++ b/app/feature/feature-claim-details/src/main/kotlin/com/hedvig/android/feature/claim/details/ui/AddFilesDestination.kt @@ -25,7 +25,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.ImageLoader -import com.eygraber.uri.toAndroidUri +import com.eygraber.uri.toKmpUri import com.hedvig.android.compose.photo.capture.state.rememberPhotoCaptureState import com.hedvig.android.compose.ui.plus import com.hedvig.android.core.uidata.UiFile @@ -73,20 +73,20 @@ internal fun AddFilesDestination( val addLocalFile = viewModel::addLocalFile val photoCaptureState = rememberPhotoCaptureState(appPackageId = appPackageId) { uri -> logcat { "ChatFileState sending uri:$uri" } - addLocalFile(uri.toAndroidUri()) + addLocalFile(uri) } val photoPicker = rememberLauncherForActivityResult( contract = ActivityResultContracts.PickMultipleVisualMedia(), ) { resultingUriList: List -> for (resultingUri in resultingUriList) { - addLocalFile(resultingUri) + addLocalFile(resultingUri.toKmpUri()) } } val filePicker = rememberLauncherForActivityResult( contract = ActivityResultContracts.GetMultipleContents(), ) { resultingUriList: List -> for (resultingUri in resultingUriList) { - addLocalFile(resultingUri) + addLocalFile(resultingUri.toKmpUri()) } } diff --git a/app/feature/feature-claim-details/src/main/kotlin/com/hedvig/android/feature/claim/details/ui/AddFilesViewModel.kt b/app/feature/feature-claim-details/src/main/kotlin/com/hedvig/android/feature/claim/details/ui/AddFilesViewModel.kt index a98841b5c6..0eb0b8f859 100644 --- a/app/feature/feature-claim-details/src/main/kotlin/com/hedvig/android/feature/claim/details/ui/AddFilesViewModel.kt +++ b/app/feature/feature-claim-details/src/main/kotlin/com/hedvig/android/feature/claim/details/ui/AddFilesViewModel.kt @@ -1,6 +1,6 @@ package com.hedvig.android.feature.claim.details.ui -import android.net.Uri +import com.eygraber.uri.Uri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import arrow.core.raise.either @@ -38,7 +38,7 @@ internal class AddFilesViewModel( viewModelScope.launch { _uiState.update { it.copy(isLoading = true) } either { - val uris = uiState.value.localFiles.map { Uri.parse(it.localPath) } + val uris = uiState.value.localFiles.mapNotNull { it.localPath?.let { path -> Uri.parse(path) } } if (uris.isNotEmpty()) { val result = claimsServiceUploadFileUseCase.invoke(url = targetUploadUrl, uris = uris).bind() result.fileIds diff --git a/app/feature/feature-claim-details/src/main/kotlin/com/hedvig/android/feature/claim/details/ui/ClaimDetailsDestination.kt b/app/feature/feature-claim-details/src/main/kotlin/com/hedvig/android/feature/claim/details/ui/ClaimDetailsDestination.kt index 3e9297bf7d..40ea2c0329 100644 --- a/app/feature/feature-claim-details/src/main/kotlin/com/hedvig/android/feature/claim/details/ui/ClaimDetailsDestination.kt +++ b/app/feature/feature-claim-details/src/main/kotlin/com/hedvig/android/feature/claim/details/ui/ClaimDetailsDestination.kt @@ -257,7 +257,7 @@ private fun ClaimDetailContentScreen( ) { if (uiState.savedFileUri != null) { LaunchedEffect(uiState.savedFileUri) { - sharePdf(uiState.savedFileUri) + sharePdf(File(uiState.savedFileUri.path)) } } val fileTypeSelectBottomSheetState = rememberHedvigBottomSheetState() diff --git a/app/feature/feature-claim-details/src/main/kotlin/com/hedvig/android/feature/claim/details/ui/ClaimDetailsViewModel.kt b/app/feature/feature-claim-details/src/main/kotlin/com/hedvig/android/feature/claim/details/ui/ClaimDetailsViewModel.kt index f9067d8638..962656d1f0 100644 --- a/app/feature/feature-claim-details/src/main/kotlin/com/hedvig/android/feature/claim/details/ui/ClaimDetailsViewModel.kt +++ b/app/feature/feature-claim-details/src/main/kotlin/com/hedvig/android/feature/claim/details/ui/ClaimDetailsViewModel.kt @@ -1,6 +1,6 @@ package com.hedvig.android.feature.claim.details.ui -import android.net.Uri +import com.eygraber.uri.Uri import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -10,6 +10,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import arrow.fx.coroutines.parMap import com.hedvig.android.core.fileupload.DownloadPdfUseCase +import com.hedvig.android.core.fileupload.DownloadedFile import com.hedvig.android.core.fileupload.ClaimsServiceUploadFileUseCase import com.hedvig.android.core.uidata.UiFile import com.hedvig.android.data.display.items.DisplayItem @@ -86,11 +87,11 @@ private class ClaimDetailPresenter( content = content?.copy(downloadError = true, isLoadingPdf = null) downloadingUrl = null }, - ifRight = { uri -> + ifRight = { downloadedFile -> logcat( LogPriority.INFO, - ) { "Downloading terms and conditions succeeded. Result uri:${uri.absolutePath}" } - content = content?.copy(downloadError = null, savedFileUri = uri, isLoadingPdf = null) + ) { "Downloading terms and conditions succeeded. Result path:${downloadedFile.path}" } + content = content?.copy(downloadError = null, savedFileUri = downloadedFile, isLoadingPdf = null) downloadingUrl = null }, ) @@ -171,7 +172,7 @@ internal sealed interface ClaimDetailUiState { val submittedAt: LocalDateTime, val insuranceDisplayName: String?, val termsConditionsUrl: String?, - val savedFileUri: File?, + val savedFileUri: DownloadedFile?, val downloadError: Boolean?, val isLoadingPdf: String?, val appealInstructionsUrl: String?, diff --git a/app/feature/feature-insurance-certificate/src/main/kotlin/com/hedvig/android/feature/insurance/certificate/ui/overview/InsuranceEvidenceOverviewDestination.kt b/app/feature/feature-insurance-certificate/src/main/kotlin/com/hedvig/android/feature/insurance/certificate/ui/overview/InsuranceEvidenceOverviewDestination.kt index 281f05ccde..d43316ea39 100644 --- a/app/feature/feature-insurance-certificate/src/main/kotlin/com/hedvig/android/feature/insurance/certificate/ui/overview/InsuranceEvidenceOverviewDestination.kt +++ b/app/feature/feature-insurance-certificate/src/main/kotlin/com/hedvig/android/feature/insurance/certificate/ui/overview/InsuranceEvidenceOverviewDestination.kt @@ -81,7 +81,7 @@ internal fun InsuranceEvidenceOverview( is Success -> { LaunchedEffect(uiState.insuranceEvidenceUri) { uiState.insuranceEvidenceUri?.let { - onShareInsuranceEvidence(it) + onShareInsuranceEvidence(File(it.path)) } } HedvigScaffold( @@ -101,7 +101,7 @@ internal fun InsuranceEvidenceOverview( text = stringResource(Res.string.CERTIFICATES_DOWNLOAD), onClick = { if (uiState.insuranceEvidenceUri != null) { - onShareInsuranceEvidence(uiState.insuranceEvidenceUri) + onShareInsuranceEvidence(File(uiState.insuranceEvidenceUri.path)) } else { onDownloadCertificate(insuranceEvidenceUrl) } diff --git a/app/feature/feature-insurance-certificate/src/main/kotlin/com/hedvig/android/feature/insurance/certificate/ui/overview/InsuranceEvidenceOverviewViewModel.kt b/app/feature/feature-insurance-certificate/src/main/kotlin/com/hedvig/android/feature/insurance/certificate/ui/overview/InsuranceEvidenceOverviewViewModel.kt index 1dd8476328..c4959a5186 100644 --- a/app/feature/feature-insurance-certificate/src/main/kotlin/com/hedvig/android/feature/insurance/certificate/ui/overview/InsuranceEvidenceOverviewViewModel.kt +++ b/app/feature/feature-insurance-certificate/src/main/kotlin/com/hedvig/android/feature/insurance/certificate/ui/overview/InsuranceEvidenceOverviewViewModel.kt @@ -8,13 +8,13 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import com.hedvig.android.core.fileupload.DownloadPdfUseCase +import com.hedvig.android.core.fileupload.DownloadedFile import com.hedvig.android.feature.insurance.certificate.ui.overview.InsuranceEvidenceOverviewState.Loading import com.hedvig.android.logger.LogPriority import com.hedvig.android.logger.logcat import com.hedvig.android.molecule.public.MoleculePresenter import com.hedvig.android.molecule.public.MoleculePresenterScope import com.hedvig.android.molecule.public.MoleculeViewModel -import java.io.File internal class InsuranceEvidenceOverviewViewModel( downloadPdfUseCase: DownloadPdfUseCase, @@ -66,11 +66,11 @@ internal class InsuranceEvidenceOverviewPresenter( logcat(LogPriority.ERROR) { "Downloading insurance evidence failed:$errorMessage" } currentState = InsuranceEvidenceOverviewState.Failure }, - ifRight = { uri -> + ifRight = { downloadedFile -> logcat( LogPriority.INFO, - ) { "Downloading insurance evidence succeeded. Result uri:${uri.absolutePath}" } - currentState = InsuranceEvidenceOverviewState.Success(uri) + ) { "Downloading insurance evidence succeeded. Result path:${downloadedFile.path}" } + currentState = InsuranceEvidenceOverviewState.Success(downloadedFile) }, ) } else { @@ -87,7 +87,7 @@ internal sealed interface InsuranceEvidenceOverviewState { data object Failure : InsuranceEvidenceOverviewState data class Success( - val insuranceEvidenceUri: File?, + val insuranceEvidenceUri: DownloadedFile?, val isButtonLoading: Boolean = false, ) : InsuranceEvidenceOverviewState } diff --git a/app/feature/feature-odyssey/src/main/kotlin/com/hedvig/android/feature/odyssey/step/fileupload/FileUploadDestination.kt b/app/feature/feature-odyssey/src/main/kotlin/com/hedvig/android/feature/odyssey/step/fileupload/FileUploadDestination.kt index 48ed6f0b8a..4529b1a484 100644 --- a/app/feature/feature-odyssey/src/main/kotlin/com/hedvig/android/feature/odyssey/step/fileupload/FileUploadDestination.kt +++ b/app/feature/feature-odyssey/src/main/kotlin/com/hedvig/android/feature/odyssey/step/fileupload/FileUploadDestination.kt @@ -14,6 +14,7 @@ import androidx.compose.runtime.setValue import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.ImageLoader import com.eygraber.uri.toAndroidUri +import com.eygraber.uri.toKmpUri import com.hedvig.android.compose.photo.capture.state.rememberPhotoCaptureState import com.hedvig.android.data.claimflow.ClaimFlowStep import com.hedvig.android.design.system.hedvig.HedvigAlertDialog @@ -47,20 +48,20 @@ internal fun FileUploadDestination( } val photoCaptureState = rememberPhotoCaptureState(appPackageId = appPackageId) { uri -> - viewModel.addLocalFile(uri.toAndroidUri()) + viewModel.addLocalFile(uri) } val photoPicker = rememberLauncherForActivityResult( contract = ActivityResultContracts.PickMultipleVisualMedia(), ) { resultingUriList: List -> for (resultingUri in resultingUriList) { - viewModel.addLocalFile(resultingUri) + viewModel.addLocalFile(resultingUri.toKmpUri()) } } val filePicker = rememberLauncherForActivityResult( contract = ActivityResultContracts.GetMultipleContents(), ) { resultingUriList: List -> for (resultingUri in resultingUriList) { - viewModel.addLocalFile(resultingUri) + viewModel.addLocalFile(resultingUri.toKmpUri()) } } diff --git a/app/feature/feature-odyssey/src/main/kotlin/com/hedvig/android/feature/odyssey/step/fileupload/FileUploadViewModel.kt b/app/feature/feature-odyssey/src/main/kotlin/com/hedvig/android/feature/odyssey/step/fileupload/FileUploadViewModel.kt index 098c348e32..456861f3f6 100644 --- a/app/feature/feature-odyssey/src/main/kotlin/com/hedvig/android/feature/odyssey/step/fileupload/FileUploadViewModel.kt +++ b/app/feature/feature-odyssey/src/main/kotlin/com/hedvig/android/feature/odyssey/step/fileupload/FileUploadViewModel.kt @@ -1,7 +1,7 @@ package com.hedvig.android.feature.odyssey.step.fileupload -import android.net.Uri import androidx.lifecycle.ViewModel +import com.eygraber.uri.Uri import androidx.lifecycle.viewModelScope import arrow.core.raise.either import com.hedvig.android.core.fileupload.FileService @@ -37,7 +37,7 @@ internal class FileUploadViewModel( viewModelScope.launch { _uiState.update { it.copy(isLoading = true) } either { - val uris = uiState.value.localFiles.map { Uri.parse(it.localPath) } + val uris = uiState.value.localFiles.mapNotNull { it.localPath?.let { path -> Uri.parse(path) } } val uploadedFileIds = uiState.value.uploadedFiles.map { it.id } val allIds = if (uris.isNotEmpty()) { val result = claimsServiceUploadFileUseCase.invoke(url = targetUploadUrl, uris = uris).bind() diff --git a/app/feature/feature-travel-certificate/src/main/kotlin/com/hedvig/android/feature/travelcertificate/ui/history/CertificateHistoryViewModel.kt b/app/feature/feature-travel-certificate/src/main/kotlin/com/hedvig/android/feature/travelcertificate/ui/history/CertificateHistoryViewModel.kt index 76d027541f..d3d6d8034f 100644 --- a/app/feature/feature-travel-certificate/src/main/kotlin/com/hedvig/android/feature/travelcertificate/ui/history/CertificateHistoryViewModel.kt +++ b/app/feature/feature-travel-certificate/src/main/kotlin/com/hedvig/android/feature/travelcertificate/ui/history/CertificateHistoryViewModel.kt @@ -8,6 +8,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import com.hedvig.android.core.fileupload.DownloadPdfUseCase +import com.hedvig.android.core.fileupload.DownloadedFile import com.hedvig.android.data.addons.data.GetTravelAddonBannerInfoUseCase import com.hedvig.android.data.addons.data.TravelAddonBannerInfo import com.hedvig.android.data.addons.data.TravelAddonBannerSource @@ -20,7 +21,6 @@ import com.hedvig.android.logger.logcat import com.hedvig.android.molecule.public.MoleculePresenter import com.hedvig.android.molecule.public.MoleculePresenterScope import com.hedvig.android.molecule.public.MoleculeViewModel -import java.io.File import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flow @@ -63,7 +63,7 @@ internal class CertificateHistoryPresenter( } var savedFileUri by remember { - mutableStateOf(null) + mutableStateOf(null) } var idsToNavigateToAddons by remember { @@ -116,12 +116,12 @@ internal class CertificateHistoryPresenter( showErrorDialog = true downloadingUrl = null }, - ifRight = { uri -> + ifRight = { downloadedFile -> isLoadingCertificate = false logcat( LogPriority.INFO, - ) { "Downloading travel certificate succeeded. Result uri:${uri.absolutePath}" } - savedFileUri = uri + ) { "Downloading travel certificate succeeded. Result path:${downloadedFile.path}" } + savedFileUri = downloadedFile downloadingUrl = null }, ) @@ -206,7 +206,7 @@ internal sealed interface CertificateHistoryUiState { val certificateHistoryList: List, val showDownloadCertificateError: Boolean, val showGenerateButton: Boolean, - val travelCertificateUri: File?, + val travelCertificateUri: DownloadedFile?, val isLoadingCertificate: Boolean, val hasChooseOption: Boolean, val travelAddonBannerInfo: TravelAddonBannerInfo?, diff --git a/app/feature/feature-travel-certificate/src/main/kotlin/com/hedvig/android/feature/travelcertificate/ui/history/TravelCertificateHistory.kt b/app/feature/feature-travel-certificate/src/main/kotlin/com/hedvig/android/feature/travelcertificate/ui/history/TravelCertificateHistory.kt index 178cd5de43..cb6c7bab5a 100644 --- a/app/feature/feature-travel-certificate/src/main/kotlin/com/hedvig/android/feature/travelcertificate/ui/history/TravelCertificateHistory.kt +++ b/app/feature/feature-travel-certificate/src/main/kotlin/com/hedvig/android/feature/travelcertificate/ui/history/TravelCertificateHistory.kt @@ -153,7 +153,7 @@ private fun TravelCertificateHistoryScreen( is SuccessDownloadingHistory -> { if (uiState.travelCertificateUri != null) { LaunchedEffect(uiState.travelCertificateUri) { - onShareTravelCertificate(uiState.travelCertificateUri) + onShareTravelCertificate(File(uiState.travelCertificateUri.path)) } } if (uiState.idsToNavigateToAddonPurchase != null) { diff --git a/app/feature/feature-travel-certificate/src/main/kotlin/com/hedvig/android/feature/travelcertificate/ui/overview/TravelCertificateOverview.kt b/app/feature/feature-travel-certificate/src/main/kotlin/com/hedvig/android/feature/travelcertificate/ui/overview/TravelCertificateOverview.kt index 0837e5cb2a..afee237f5e 100644 --- a/app/feature/feature-travel-certificate/src/main/kotlin/com/hedvig/android/feature/travelcertificate/ui/overview/TravelCertificateOverview.kt +++ b/app/feature/feature-travel-certificate/src/main/kotlin/com/hedvig/android/feature/travelcertificate/ui/overview/TravelCertificateOverview.kt @@ -89,7 +89,7 @@ internal fun TravelCertificateOverview( is Success -> { LaunchedEffect(uiState.travelCertificateUri) { uiState.travelCertificateUri?.let { - onShareTravelCertificate(it) + onShareTravelCertificate(File(it.path)) } } HedvigScaffold( @@ -121,7 +121,7 @@ internal fun TravelCertificateOverview( }, onClick = { if (uiState.travelCertificateUri != null) { - onShareTravelCertificate(uiState.travelCertificateUri) + onShareTravelCertificate(File(uiState.travelCertificateUri.path)) } else { onDownloadCertificate(travelCertificateUrl) } diff --git a/app/feature/feature-travel-certificate/src/main/kotlin/com/hedvig/android/feature/travelcertificate/ui/overview/TravelCertificateOverviewViewModel.kt b/app/feature/feature-travel-certificate/src/main/kotlin/com/hedvig/android/feature/travelcertificate/ui/overview/TravelCertificateOverviewViewModel.kt index 117df2133c..aa5b8946ed 100644 --- a/app/feature/feature-travel-certificate/src/main/kotlin/com/hedvig/android/feature/travelcertificate/ui/overview/TravelCertificateOverviewViewModel.kt +++ b/app/feature/feature-travel-certificate/src/main/kotlin/com/hedvig/android/feature/travelcertificate/ui/overview/TravelCertificateOverviewViewModel.kt @@ -8,13 +8,13 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import com.hedvig.android.core.fileupload.DownloadPdfUseCase +import com.hedvig.android.core.fileupload.DownloadedFile import com.hedvig.android.feature.travelcertificate.data.TravelCertificateUrl import com.hedvig.android.logger.LogPriority import com.hedvig.android.logger.logcat import com.hedvig.android.molecule.public.MoleculePresenter import com.hedvig.android.molecule.public.MoleculePresenterScope import com.hedvig.android.molecule.public.MoleculeViewModel -import java.io.File internal class TravelCertificateOverviewViewModel( downloadTravelCertificateUseCase: DownloadPdfUseCase, @@ -62,11 +62,11 @@ internal class TravelCertificateOverviewPresenter( logcat(LogPriority.ERROR) { "Downloading travel certificate failed:$errorMessage" } currentState = TravelCertificateOverviewUiState.Failure }, - ifRight = { uri -> + ifRight = { downloadedFile -> logcat( LogPriority.INFO, - ) { "Downloading travel certificate succeeded. Result uri:${uri.absolutePath}" } - currentState = TravelCertificateOverviewUiState.Success(uri) + ) { "Downloading travel certificate succeeded. Result path:${downloadedFile.path}" } + currentState = TravelCertificateOverviewUiState.Success(downloadedFile) }, ) } else { @@ -89,6 +89,6 @@ internal sealed interface TravelCertificateOverviewUiState { data object Failure : TravelCertificateOverviewUiState data class Success( - val travelCertificateUri: File?, + val travelCertificateUri: DownloadedFile?, ) : TravelCertificateOverviewUiState } From 9c7c7df203606e6910f02f736ac589f440a00b97 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Thu, 22 Jan 2026 15:11:14 +0100 Subject: [PATCH 3/7] Refactor UploadFileUseCase to use centralized FileUploadService Eliminates duplicate HTTP upload logic by delegating to FileUploadService instead of directly using HttpClient. This reduces code by ~40 lines and ensures consistent error handling and request building across the app. Changes: - UploadFileUseCase now uses FileUploadService.uploadWithCustomFormData() - Move core-file-upload dependency to commonMain for broader access - Update DI to inject FileUploadService and HedvigBuildConstants - Remove manual HTTP client setup and error handling code --- .../feature-claim-chat/build.gradle.kts | 3 +- .../claim/chat/data/UploadFileUseCase.kt | 93 +++++++------------ .../feature/claim/chat/di/ClaimChatModule.kt | 45 ++++----- 3 files changed, 58 insertions(+), 83 deletions(-) diff --git a/app/feature/feature-claim-chat/build.gradle.kts b/app/feature/feature-claim-chat/build.gradle.kts index 5d4a650d93..4b7534ab75 100644 --- a/app/feature/feature-claim-chat/build.gradle.kts +++ b/app/feature/feature-claim-chat/build.gradle.kts @@ -36,7 +36,9 @@ kotlin { implementation(projects.composePhotoCaptureState) implementation(projects.composeResultLauncher) implementation(projects.composeUi) + implementation(projects.coreBuildConstants) implementation(projects.coreCommonPublic) + implementation(projects.coreFileUpload) implementation(projects.coreResources) implementation(projects.coreUiData) implementation(projects.designSystemHedvig) @@ -51,7 +53,6 @@ kotlin { implementation(libs.androidx.navigation.compose) implementation(libs.bundles.kmpPreviewBugWorkaround) implementation(projects.composeUi) - implementation(projects.coreFileUpload) implementation(projects.navigationCommon) implementation(projects.navigationCompose) implementation(projects.notificationPermission) diff --git a/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/data/UploadFileUseCase.kt b/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/data/UploadFileUseCase.kt index cb0b48cd9d..4da75ca930 100644 --- a/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/data/UploadFileUseCase.kt +++ b/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/data/UploadFileUseCase.kt @@ -2,80 +2,51 @@ package com.hedvig.feature.claim.chat.data import arrow.core.raise.Raise import arrow.core.raise.context.raise +import com.hedvig.android.core.buildconstants.HedvigBuildConstants import com.hedvig.android.core.common.ErrorMessage -import com.hedvig.android.logger.LogPriority -import com.hedvig.android.logger.logcat -import com.hedvig.android.network.clients.NetworkError -import com.hedvig.android.network.clients.safePost -import com.hedvig.feature.claim.chat.data.file.CommonFile -import io.ktor.client.HttpClient -import io.ktor.client.plugins.onUpload +import com.hedvig.android.core.fileupload.FileUploadService import io.ktor.client.request.forms.InputProvider -import io.ktor.client.request.forms.MultiPartFormDataContent -import io.ktor.client.request.forms.formData -import io.ktor.client.request.setBody -import io.ktor.client.statement.bodyAsText import io.ktor.http.Headers import io.ktor.http.HttpHeaders -import io.ktor.http.isSuccess +import com.hedvig.feature.claim.chat.data.file.CommonFile import kotlinx.serialization.json.Json import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive -internal class UploadFileUseCase(private val client: HttpClient) { +internal class UploadFileUseCase( + private val fileUploadService: FileUploadService, + private val buildConstants: HedvigBuildConstants, +) { context(_: Raise) suspend fun invoke(commonFile: CommonFile, uploadUrl: String): FileUploadResponse { - // todo URL for prod/staging - val response = client - .safePost("https://gateway.test.hedvig.com$uploadUrl") { - setBody( - MultiPartFormDataContent( - formData { - append("description", commonFile.description) - append( - "files", - InputProvider { commonFile.source() }, - Headers.build { - append(HttpHeaders.ContentType, commonFile.mimeType) - append(HttpHeaders.ContentDisposition, """filename="${commonFile.fileName}"""") - }, - ) - }, - ), + val fullUrl = "${buildConstants.urlBaseWeb}$uploadUrl" + val responseBody = fileUploadService.uploadWithCustomFormData( + url = fullUrl, + formDataBuilder = { + append("description", commonFile.description) + append( + "files", + InputProvider { commonFile.source() }, + Headers.build { + append(HttpHeaders.ContentType, commonFile.mimeType) + append(HttpHeaders.ContentDisposition, """filename="${commonFile.fileName}"""") + }, ) - onUpload { bytesSentTotal, contentLength -> - logcat(LogPriority.VERBOSE) { - "UploadFileUseCase bytesSentTotal:$bytesSentTotal contentLength:$contentLength" - } - } - } - .fold( - ifLeft = { error -> - raise( - when (error) { - is NetworkError.IOError -> ErrorMessage("Network error: ${error.message}", error.throwable) - is NetworkError.UnknownError -> ErrorMessage(error.message, error.throwable) - }, - ) - }, - ifRight = { it }, - ) + }, + deserializer = { it }, + ) - return if (response.status.isSuccess()) { - val jsonResponse = Json.parseToJsonElement(response.bodyAsText()) - val fileId = jsonResponse - .jsonObject["fileIds"] - ?.jsonArray - ?.map { jsonElement -> - jsonElement.jsonPrimitive.content - } - ?.firstOrNull() - ?: raise(ErrorMessage("UploadFileUseCase jsonResponse$jsonResponse did not contain file IDs")) - FileUploadResponse(CommonFileId(fileId)) - } else { - raise(ErrorMessage("UploadFileUseCase failed with error ${response.status} | ${response.bodyAsText()}")) - } + val jsonResponse = Json.parseToJsonElement(responseBody) + val fileId = jsonResponse + .jsonObject["fileIds"] + ?.jsonArray + ?.map { jsonElement -> + jsonElement.jsonPrimitive.content + } + ?.firstOrNull() + ?: raise(ErrorMessage("UploadFileUseCase jsonResponse$jsonResponse did not contain file IDs")) + return FileUploadResponse(CommonFileId(fileId)) } } diff --git a/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/di/ClaimChatModule.kt b/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/di/ClaimChatModule.kt index 36a987bed3..3b14cef46b 100644 --- a/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/di/ClaimChatModule.kt +++ b/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/di/ClaimChatModule.kt @@ -1,7 +1,11 @@ package com.hedvig.feature.claim.chat.di import com.apollographql.apollo.ApolloClient +import com.hedvig.android.core.buildconstants.HedvigBuildConstants +import com.hedvig.android.core.fileupload.FileUploadService +import com.hedvig.android.language.LanguageService import com.hedvig.feature.claim.chat.ClaimChatViewModel +import com.hedvig.feature.claim.chat.data.AudioRecordingManager import com.hedvig.feature.claim.chat.data.GetClaimIntentUseCase import com.hedvig.feature.claim.chat.data.RegretStepUseCase import com.hedvig.feature.claim.chat.data.RegretStepUseCaseImpl @@ -16,7 +20,6 @@ import com.hedvig.feature.claim.chat.data.SubmitSummaryUseCase import com.hedvig.feature.claim.chat.data.SubmitTaskUseCase import com.hedvig.feature.claim.chat.data.UploadFileUseCase import com.hedvig.feature.claim.chat.data.file.FileService -import io.ktor.client.HttpClient import org.koin.core.module.Module import org.koin.core.module.dsl.viewModel import org.koin.dsl.module @@ -26,15 +29,15 @@ val claimChatModule = module { viewModel { (developmentFlow: Boolean) -> ClaimChatViewModel( developmentFlow, - get(), - get(), - get(), - get(), - get(), - get(), - get(), - get(), - get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), get(), get(), get(), @@ -42,47 +45,47 @@ val claimChatModule = module { } single { - SkipStepUseCaseImpl(get(), get()) + SkipStepUseCaseImpl(get(), get()) } single { - StartClaimIntentUseCase(get(), get()) + StartClaimIntentUseCase(get(), get()) } single { - GetClaimIntentUseCase(get(), get()) + GetClaimIntentUseCase(get(), get()) } single { - SubmitTaskUseCase(get(), get()) + SubmitTaskUseCase(get(), get()) } single { - SubmitAudioRecordingUseCase(get(), get(), get()) + SubmitAudioRecordingUseCase(get(), get(), get()) } single { - SubmitFileUploadUseCase(get(), get(), get(), get()) + SubmitFileUploadUseCase(get(), get(), get(), get()) } single { - SubmitFormUseCase(get(), get()) + SubmitFormUseCase(get(), get()) } single { - SubmitSelectUseCase(get(), get()) + SubmitSelectUseCase(get(), get()) } single { - SubmitSummaryUseCase(get(), get()) + SubmitSummaryUseCase(get(), get()) } single { - UploadFileUseCase(get()) + UploadFileUseCase(get(), get()) } single { - RegretStepUseCaseImpl(get(), get()) + RegretStepUseCaseImpl(get(), get()) } } From 5da4bcd72722dfe1b0cba9175d840a7576151d36 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Thu, 22 Jan 2026 15:24:07 +0100 Subject: [PATCH 4/7] Delegate core FileUploadService logic to one function --- .../android/core/fileupload/AndroidFile.kt | 3 +- .../core/fileupload/AndroidFileService.kt | 1 - .../core/fileupload/FileUploadService.kt | 54 +++++-------------- 3 files changed, 15 insertions(+), 43 deletions(-) diff --git a/app/core/core-file-upload/src/androidMain/kotlin/com/hedvig/android/core/fileupload/AndroidFile.kt b/app/core/core-file-upload/src/androidMain/kotlin/com/hedvig/android/core/fileupload/AndroidFile.kt index 40b40ae4f2..647ba067d3 100644 --- a/app/core/core-file-upload/src/androidMain/kotlin/com/hedvig/android/core/fileupload/AndroidFile.kt +++ b/app/core/core-file-upload/src/androidMain/kotlin/com/hedvig/android/core/fileupload/AndroidFile.kt @@ -3,11 +3,11 @@ package com.hedvig.android.core.fileupload import android.content.ContentResolver import android.net.Uri as AndroidUri import android.provider.OpenableColumns +import kotlin.math.max import kotlinx.io.IOException import kotlinx.io.Source import kotlinx.io.asSource import kotlinx.io.buffered -import kotlin.math.max class AndroidFile( override val fileName: String, @@ -15,7 +15,6 @@ class AndroidFile( private val contentResolver: ContentResolver, private val androidUri: AndroidUri, ) : CommonFile { - override fun source(): Source { val inputStream = contentResolver.openInputStream(androidUri) ?: throw IOException("Could not open input stream for uri:$androidUri") diff --git a/app/core/core-file-upload/src/androidMain/kotlin/com/hedvig/android/core/fileupload/AndroidFileService.kt b/app/core/core-file-upload/src/androidMain/kotlin/com/hedvig/android/core/fileupload/AndroidFileService.kt index d32380b0fe..a885f33a03 100644 --- a/app/core/core-file-upload/src/androidMain/kotlin/com/hedvig/android/core/fileupload/AndroidFileService.kt +++ b/app/core/core-file-upload/src/androidMain/kotlin/com/hedvig/android/core/fileupload/AndroidFileService.kt @@ -13,7 +13,6 @@ import kotlin.math.max class AndroidFileService( private val contentResolver: ContentResolver, ) : FileService { - override fun convertToCommonFile(uri: Uri): CommonFile { val androidUri = uri.toAndroidUri() diff --git a/app/core/core-file-upload/src/commonMain/kotlin/com/hedvig/android/core/fileupload/FileUploadService.kt b/app/core/core-file-upload/src/commonMain/kotlin/com/hedvig/android/core/fileupload/FileUploadService.kt index 892d4786e7..36fc99a0df 100644 --- a/app/core/core-file-upload/src/commonMain/kotlin/com/hedvig/android/core/fileupload/FileUploadService.kt +++ b/app/core/core-file-upload/src/commonMain/kotlin/com/hedvig/android/core/fileupload/FileUploadService.kt @@ -56,48 +56,22 @@ class FileUploadService( } } - val response: HttpResponse = client - .safePost(url) { - setBody( - MultiPartFormDataContent( - formData { - files.forEach { file -> - append( - "files", - InputProvider { file.source() }, - Headers.build { - append(HttpHeaders.ContentType, file.mimeType) - append(HttpHeaders.ContentDisposition, """filename="${file.fileName}"""") - }, - ) - } - }, - ), - ) - } - .fold( - ifLeft = { error -> - raise( - when (error) { - is NetworkError.IOError -> ErrorMessage("Network error: ${error.message}", error.throwable) - is NetworkError.UnknownError -> ErrorMessage(error.message, error.throwable) + return uploadWithCustomFormData( + url = url, + formDataBuilder = { + files.forEach { file -> + append( + "files", + InputProvider { file.source() }, + Headers.build { + append(HttpHeaders.ContentType, file.mimeType) + append(HttpHeaders.ContentDisposition, """filename="${file.fileName}"""") }, ) - }, - ifRight = { it }, - ) - - return if (response.status.isSuccess()) { - val responseBody = response.bodyAsText() - logcat { "FileUploadService: Upload successful, response: $responseBody" } - deserializer(responseBody) - } else { - val errorBody = response.bodyAsText() - logcat(LogPriority.ERROR) { - "FileUploadService failed with status ${response.status}: $errorBody" - } - raise(ErrorMessage("File upload failed with status ${response.status}: $errorBody")) - } + } + }, + deserializer = deserializer, + ) } /** From 1b833c89c06214fbb899d10e06bee658fb337ccc Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Thu, 22 Jan 2026 16:11:51 +0100 Subject: [PATCH 5/7] Consolidate file handling to core-file-upload to eliminate duplication Removes duplicate AndroidFile/CommonFile implementations from feature-claim-chat, centralizing all file operations in core-file-upload module. --- .../android/core/fileupload/AndroidFile.kt | 23 ++++ .../android/core/fileupload/CommonFile.kt | 2 + .../claim/chat/data/AudioRecordingManager.kt | 4 +- .../claim/chat/data/file/AndroidFile.kt | 104 ------------------ .../claim/chat/di/ClaimChatModule.android.kt | 9 -- .../feature/claim/chat/ClaimChatViewModel.kt | 7 +- .../claim/chat/data/AudioRecordingManager.kt | 7 +- .../chat/data/SubmitAudioRecordingUseCase.kt | 2 +- .../chat/data/SubmitFileUploadUseCase.kt | 2 +- .../claim/chat/data/UploadFileUseCase.kt | 6 +- .../claim/chat/data/file/CommonFile.kt | 13 --- .../claim/chat/data/file/FileService.kt | 11 -- .../feature/claim/chat/di/ClaimChatModule.kt | 2 +- .../claim/chat/ui/ClaimChatDestination.kt | 6 +- .../claim/chat/data/file/FileService.jvm.kt | 36 ------ .../claim/chat/di/ClaimChatModule.jvm.kt | 5 - .../chat/data/NativeAudioRecordingManager.kt | 2 +- .../claim/chat/data/file/NativeFile.kt | 30 ----- .../claim/chat/data/file/NativeFileService.kt | 17 --- .../claim/chat/di/ClaimChatModule.native.kt | 6 - 20 files changed, 43 insertions(+), 251 deletions(-) delete mode 100644 app/feature/feature-claim-chat/src/androidMain/kotlin/com/hedvig/feature/claim/chat/data/file/AndroidFile.kt delete mode 100644 app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/data/file/CommonFile.kt delete mode 100644 app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/data/file/FileService.kt delete mode 100644 app/feature/feature-claim-chat/src/jvmMain/kotlin/com/hedvig/feature/claim/chat/data/file/FileService.jvm.kt delete mode 100644 app/feature/feature-claim-chat/src/nativeMain/kotlin/com/hedvig/feature/claim/chat/data/file/NativeFile.kt delete mode 100644 app/feature/feature-claim-chat/src/nativeMain/kotlin/com/hedvig/feature/claim/chat/data/file/NativeFileService.kt diff --git a/app/core/core-file-upload/src/androidMain/kotlin/com/hedvig/android/core/fileupload/AndroidFile.kt b/app/core/core-file-upload/src/androidMain/kotlin/com/hedvig/android/core/fileupload/AndroidFile.kt index 647ba067d3..4e49bf985c 100644 --- a/app/core/core-file-upload/src/androidMain/kotlin/com/hedvig/android/core/fileupload/AndroidFile.kt +++ b/app/core/core-file-upload/src/androidMain/kotlin/com/hedvig/android/core/fileupload/AndroidFile.kt @@ -3,6 +3,7 @@ package com.hedvig.android.core.fileupload import android.content.ContentResolver import android.net.Uri as AndroidUri import android.provider.OpenableColumns +import java.io.File import kotlin.math.max import kotlinx.io.IOException import kotlinx.io.Source @@ -12,6 +13,7 @@ import kotlinx.io.buffered class AndroidFile( override val fileName: String, override val mimeType: String, + override val description: String? = null, private val contentResolver: ContentResolver, private val androidUri: AndroidUri, ) : CommonFile { @@ -39,4 +41,25 @@ class AndroidFile( return max(statSize, sizeFromCursor) } + + companion object { + /** + * Creates an AndroidFile from a java.io.File + */ + fun fromFile(file: File, description: String? = null, mimeType: String = ""): CommonFile { + return object : CommonFile { + override val fileName: String = file.name + override val description: String? = description + override val mimeType: String = mimeType + + override fun source(): Source { + return file.inputStream().asSource().buffered() + } + + override fun getSize(): Long { + return file.length() + } + } + } + } } diff --git a/app/core/core-file-upload/src/commonMain/kotlin/com/hedvig/android/core/fileupload/CommonFile.kt b/app/core/core-file-upload/src/commonMain/kotlin/com/hedvig/android/core/fileupload/CommonFile.kt index 7efdc254c8..c627b6dc9b 100644 --- a/app/core/core-file-upload/src/commonMain/kotlin/com/hedvig/android/core/fileupload/CommonFile.kt +++ b/app/core/core-file-upload/src/commonMain/kotlin/com/hedvig/android/core/fileupload/CommonFile.kt @@ -9,6 +9,8 @@ import kotlinx.io.Source interface CommonFile { val fileName: String val mimeType: String + val description: String? + get() = null /** * Returns a Source for streaming the file contents. diff --git a/app/feature/feature-claim-chat/src/androidMain/kotlin/com/hedvig/feature/claim/chat/data/AudioRecordingManager.kt b/app/feature/feature-claim-chat/src/androidMain/kotlin/com/hedvig/feature/claim/chat/data/AudioRecordingManager.kt index be178e0a6d..f1b85b8405 100644 --- a/app/feature/feature-claim-chat/src/androidMain/kotlin/com/hedvig/feature/claim/chat/data/AudioRecordingManager.kt +++ b/app/feature/feature-claim-chat/src/androidMain/kotlin/com/hedvig/feature/claim/chat/data/AudioRecordingManager.kt @@ -2,8 +2,8 @@ package com.hedvig.feature.claim.chat.data import android.media.MediaPlayer import android.media.MediaRecorder -import com.hedvig.feature.claim.chat.data.file.AndroidFile -import com.hedvig.feature.claim.chat.data.file.CommonFile +import com.hedvig.android.core.fileupload.AndroidFile +import com.hedvig.android.core.fileupload.CommonFile import java.io.File import java.util.Timer import java.util.TimerTask diff --git a/app/feature/feature-claim-chat/src/androidMain/kotlin/com/hedvig/feature/claim/chat/data/file/AndroidFile.kt b/app/feature/feature-claim-chat/src/androidMain/kotlin/com/hedvig/feature/claim/chat/data/file/AndroidFile.kt deleted file mode 100644 index 5fa69de053..0000000000 --- a/app/feature/feature-claim-chat/src/androidMain/kotlin/com/hedvig/feature/claim/chat/data/file/AndroidFile.kt +++ /dev/null @@ -1,104 +0,0 @@ -package com.hedvig.feature.claim.chat.data.file - -import android.content.ContentResolver -import android.provider.OpenableColumns -import android.webkit.MimeTypeMap -import com.eygraber.uri.Uri -import com.eygraber.uri.toAndroidUri -import java.io.File -import java.util.Locale -import kotlinx.io.IOException -import kotlinx.io.Source -import kotlinx.io.asSource -import kotlinx.io.buffered -import kotlinx.io.readByteArray - -class AndroidFile( - override val fileName: String, - override val description: String, - override val mimeType: String, - private val getSource: () -> Source, -) : CommonFile { - override fun source(): Source { - return getSource() - } - - override fun readBytes(): ByteArray { - return source().readByteArray() - } - - companion object { - fun fromFile(file: File, description: String = "File", mimeType: String = ""): AndroidFile { - return AndroidFile( - fileName = file.name, - description = description, - mimeType = mimeType, - getSource = { file.inputStream().asSource().buffered() }, - ) - } - } -} - -internal class AndroidFileService( - private val coreFileService: com.hedvig.android.core.fileupload.FileService, - private val contentResolver: ContentResolver, -) : FileService { - override fun convertToCommonFile(uri: Uri): CommonFile { - val androidUri = uri.toAndroidUri() - - val fileName = coreFileService.getFileName(uri) ?: "media" - val mimeType = getMimeType(uri.toString()) - - return AndroidFile( - fileName = fileName, - description = "description", - mimeType = mimeType, - getSource = { - contentResolver.openInputStream(androidUri)?.asSource()?.buffered() - ?: throw IOException("Could not open input stream for uri:$uri") - }, - ) - } - - override fun getMimeType(path: String): String { - val uri = android.net.Uri.parse(path) - - if (uri.scheme == ContentResolver.SCHEME_CONTENT) { - val resolvedMimeType = contentResolver.getType(uri) - if (resolvedMimeType != null) { - return resolvedMimeType - } - } - - val fileExtension = getFileExtension(path) - return MimeTypeMap.getSingleton() - .getMimeTypeFromExtension(fileExtension.lowercase(Locale.getDefault())) - ?: "" - } - - private fun getFileExtension(path: String): String = MimeTypeMap.getFileExtensionFromUrl(path) - - override fun getFileName(uriString: String): String? { - val uri = android.net.Uri.parse(uriString) - if (uri.scheme == ContentResolver.SCHEME_CONTENT) { - val cursor = contentResolver.query(uri, null, null, null, null) - cursor.use { c -> - if (c?.moveToFirst() == true) { - val columnIndex = c.getColumnIndex(OpenableColumns.DISPLAY_NAME) - if (columnIndex >= 0) { - return c.getString(columnIndex) - } - } - } - } - - val cut = uri.path?.lastIndexOf('/') - - cut?.let { c -> - if (c != -1) { - return uri.path?.substring(c + 1) - } - } - return uri.path - } -} diff --git a/app/feature/feature-claim-chat/src/androidMain/kotlin/com/hedvig/feature/claim/chat/di/ClaimChatModule.android.kt b/app/feature/feature-claim-chat/src/androidMain/kotlin/com/hedvig/feature/claim/chat/di/ClaimChatModule.android.kt index 7da161a403..1f1d27349f 100644 --- a/app/feature/feature-claim-chat/src/androidMain/kotlin/com/hedvig/feature/claim/chat/di/ClaimChatModule.android.kt +++ b/app/feature/feature-claim-chat/src/androidMain/kotlin/com/hedvig/feature/claim/chat/di/ClaimChatModule.android.kt @@ -1,20 +1,11 @@ package com.hedvig.feature.claim.chat.di -import android.content.Context import com.hedvig.feature.claim.chat.data.AndroidAudioRecordingManager import com.hedvig.feature.claim.chat.data.AudioRecordingManager -import com.hedvig.feature.claim.chat.data.file.AndroidFileService -import com.hedvig.feature.claim.chat.data.file.FileService import kotlin.time.Clock import org.koin.core.module.Module import org.koin.dsl.module actual val claimChatPlatformModule: Module = module { - single { - AndroidFileService( - coreFileService = get(), - contentResolver = get().contentResolver, - ) - } single { AndroidAudioRecordingManager(Clock.System) } } diff --git a/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/ClaimChatViewModel.kt b/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/ClaimChatViewModel.kt index 909baa9675..cb5b3d0086 100644 --- a/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/ClaimChatViewModel.kt +++ b/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/ClaimChatViewModel.kt @@ -12,6 +12,7 @@ import androidx.compose.runtime.snapshots.Snapshot import androidx.compose.runtime.snapshots.SnapshotStateList import com.eygraber.uri.Uri import com.hedvig.android.core.common.ErrorMessage +import com.hedvig.android.core.fileupload.FileService import com.hedvig.android.core.uidata.UiFile import com.hedvig.android.logger.logcat import com.hedvig.android.molecule.public.MoleculePresenter @@ -44,7 +45,6 @@ import com.hedvig.feature.claim.chat.data.SubmitFormUseCase import com.hedvig.feature.claim.chat.data.SubmitSelectUseCase import com.hedvig.feature.claim.chat.data.SubmitSummaryUseCase import com.hedvig.feature.claim.chat.data.SubmitTaskUseCase -import com.hedvig.feature.claim.chat.data.file.FileService import kotlin.time.Instant import kotlinx.coroutines.launch import kotlinx.datetime.TimeZone @@ -487,8 +487,9 @@ internal class ClaimChatPresenter( is ClaimChatEvent.AddFile -> { try { - val mimeType = fileService.getMimeType(event.uri) - val name = fileService.getFileName(event.uri) ?: event.uri + val uri = Uri.parse(event.uri) + val mimeType = fileService.getMimeType(uri) + val name = fileService.getFileName(uri) ?: event.uri val localFile = UiFile( name = name, localPath = event.uri, diff --git a/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/data/AudioRecordingManager.kt b/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/data/AudioRecordingManager.kt index 9a34d04626..5b516cf091 100644 --- a/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/data/AudioRecordingManager.kt +++ b/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/data/AudioRecordingManager.kt @@ -1,7 +1,6 @@ package com.hedvig.feature.claim.chat.data -import com.hedvig.feature.claim.chat.data.file.CommonFile -import kotlin.time.Clock +import com.hedvig.android.core.fileupload.CommonFile interface AudioRecordingManager { fun startRecording(onStateUpdate: (AudioRecordingStepState.AudioRecording.Recording) -> Unit) @@ -13,8 +12,4 @@ interface AudioRecordingManager { fun cleanup() fun reset() - - companion object { - const val MIN_TEXT_LENGTH = 50 - } } diff --git a/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/data/SubmitAudioRecordingUseCase.kt b/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/data/SubmitAudioRecordingUseCase.kt index 81ddfe6f8b..15e5b04f0f 100644 --- a/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/data/SubmitAudioRecordingUseCase.kt +++ b/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/data/SubmitAudioRecordingUseCase.kt @@ -8,9 +8,9 @@ import com.apollographql.apollo.ApolloClient import com.apollographql.apollo.api.Optional import com.hedvig.android.apollo.safeExecute import com.hedvig.android.core.common.ErrorMessage +import com.hedvig.android.core.fileupload.CommonFile import com.hedvig.android.language.LanguageService import com.hedvig.android.logger.logcat -import com.hedvig.feature.claim.chat.data.file.CommonFile import octopus.ClaimIntentSubmitAudioMutation import octopus.type.ClaimIntentSubmitAudioInput diff --git a/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/data/SubmitFileUploadUseCase.kt b/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/data/SubmitFileUploadUseCase.kt index 6beeacb313..f289449601 100644 --- a/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/data/SubmitFileUploadUseCase.kt +++ b/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/data/SubmitFileUploadUseCase.kt @@ -8,9 +8,9 @@ import com.apollographql.apollo.ApolloClient import com.eygraber.uri.Uri import com.hedvig.android.apollo.safeExecute import com.hedvig.android.core.common.ErrorMessage +import com.hedvig.android.core.fileupload.FileService import com.hedvig.android.language.LanguageService import com.hedvig.android.logger.logcat -import com.hedvig.feature.claim.chat.data.file.FileService import kotlin.jvm.JvmInline import octopus.ClaimIntentSubmitFileUploadMutation import octopus.type.ClaimIntentSubmitFileUploadInput diff --git a/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/data/UploadFileUseCase.kt b/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/data/UploadFileUseCase.kt index 4da75ca930..603d0f6f65 100644 --- a/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/data/UploadFileUseCase.kt +++ b/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/data/UploadFileUseCase.kt @@ -4,11 +4,11 @@ import arrow.core.raise.Raise import arrow.core.raise.context.raise import com.hedvig.android.core.buildconstants.HedvigBuildConstants import com.hedvig.android.core.common.ErrorMessage +import com.hedvig.android.core.fileupload.CommonFile import com.hedvig.android.core.fileupload.FileUploadService import io.ktor.client.request.forms.InputProvider import io.ktor.http.Headers import io.ktor.http.HttpHeaders -import com.hedvig.feature.claim.chat.data.file.CommonFile import kotlinx.serialization.json.Json import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject @@ -24,7 +24,9 @@ internal class UploadFileUseCase( val responseBody = fileUploadService.uploadWithCustomFormData( url = fullUrl, formDataBuilder = { - append("description", commonFile.description) + commonFile.description?.let { description -> + append("description", description) + } append( "files", InputProvider { commonFile.source() }, diff --git a/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/data/file/CommonFile.kt b/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/data/file/CommonFile.kt deleted file mode 100644 index a97eb9a9dd..0000000000 --- a/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/data/file/CommonFile.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.hedvig.feature.claim.chat.data.file - -import kotlinx.io.Source - -interface CommonFile { - val fileName: String - val description: String - val mimeType: String - - fun source(): Source - - fun readBytes(): ByteArray -} diff --git a/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/data/file/FileService.kt b/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/data/file/FileService.kt deleted file mode 100644 index ac09b7cf64..0000000000 --- a/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/data/file/FileService.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.hedvig.feature.claim.chat.data.file - -import com.eygraber.uri.Uri - -internal interface FileService { - fun convertToCommonFile(uri: Uri): CommonFile - - fun getMimeType(path: String): String - - fun getFileName(uriString: String): String? -} diff --git a/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/di/ClaimChatModule.kt b/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/di/ClaimChatModule.kt index 3b14cef46b..47dd746f7a 100644 --- a/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/di/ClaimChatModule.kt +++ b/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/di/ClaimChatModule.kt @@ -2,6 +2,7 @@ package com.hedvig.feature.claim.chat.di import com.apollographql.apollo.ApolloClient import com.hedvig.android.core.buildconstants.HedvigBuildConstants +import com.hedvig.android.core.fileupload.FileService import com.hedvig.android.core.fileupload.FileUploadService import com.hedvig.android.language.LanguageService import com.hedvig.feature.claim.chat.ClaimChatViewModel @@ -19,7 +20,6 @@ import com.hedvig.feature.claim.chat.data.SubmitSelectUseCase import com.hedvig.feature.claim.chat.data.SubmitSummaryUseCase import com.hedvig.feature.claim.chat.data.SubmitTaskUseCase import com.hedvig.feature.claim.chat.data.UploadFileUseCase -import com.hedvig.feature.claim.chat.data.file.FileService import org.koin.core.module.Module import org.koin.core.module.dsl.viewModel import org.koin.dsl.module diff --git a/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/ui/ClaimChatDestination.kt b/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/ui/ClaimChatDestination.kt index 258f4c3478..4e95cedd54 100644 --- a/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/ui/ClaimChatDestination.kt +++ b/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/ui/ClaimChatDestination.kt @@ -455,9 +455,9 @@ private fun ClaimChatScrollableContent( contentType = { it.stepContent::class }, ) { item -> val isCurrentStep = item.id == uiState.steps.lastOrNull()?.id - val showAnimationSequence = isCurrentStep - && item.stepContent !is StepContent.Task - && !uiState.stepsWithShownAnimations.contains(item.id) + val showAnimationSequence = isCurrentStep && + item.stepContent !is StepContent.Task && + !uiState.stepsWithShownAnimations.contains(item.id) val isLastItem = item == uiState.steps.lastOrNull() val heightModifier = if (isLastItem) { diff --git a/app/feature/feature-claim-chat/src/jvmMain/kotlin/com/hedvig/feature/claim/chat/data/file/FileService.jvm.kt b/app/feature/feature-claim-chat/src/jvmMain/kotlin/com/hedvig/feature/claim/chat/data/file/FileService.jvm.kt deleted file mode 100644 index 5bccd63bec..0000000000 --- a/app/feature/feature-claim-chat/src/jvmMain/kotlin/com/hedvig/feature/claim/chat/data/file/FileService.jvm.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.hedvig.feature.claim.chat.data.file - -import com.eygraber.uri.Uri -import kotlinx.io.Source - -internal class JvmFile( - override val fileName: String, - override val description: String, - override val mimeType: String, -) : CommonFile { - override fun source(): Source { - TODO("Not yet implemented") - } - - override fun readBytes(): ByteArray { - TODO("Not yet implemented") - } -} - -internal class JvmFileService : FileService { - override fun convertToCommonFile(uri: Uri): CommonFile { - return JvmFile( - "todo", - "todo", - mimeType = "todo", - ) - } - - override fun getFileName(uriString: String): String? { - TODO("Not yet implemented") - } - - override fun getMimeType(path: String): String { - TODO("Not yet implemented") - } -} diff --git a/app/feature/feature-claim-chat/src/jvmMain/kotlin/com/hedvig/feature/claim/chat/di/ClaimChatModule.jvm.kt b/app/feature/feature-claim-chat/src/jvmMain/kotlin/com/hedvig/feature/claim/chat/di/ClaimChatModule.jvm.kt index dc619a28a9..ee7ae36215 100644 --- a/app/feature/feature-claim-chat/src/jvmMain/kotlin/com/hedvig/feature/claim/chat/di/ClaimChatModule.jvm.kt +++ b/app/feature/feature-claim-chat/src/jvmMain/kotlin/com/hedvig/feature/claim/chat/di/ClaimChatModule.jvm.kt @@ -1,12 +1,7 @@ package com.hedvig.feature.claim.chat.di -import com.hedvig.feature.claim.chat.data.file.FileService -import com.hedvig.feature.claim.chat.data.file.JvmFileService import org.koin.core.module.Module import org.koin.dsl.module actual val claimChatPlatformModule: Module = module { - single { - JvmFileService() - } } diff --git a/app/feature/feature-claim-chat/src/nativeMain/kotlin/com/hedvig/feature/claim/chat/data/NativeAudioRecordingManager.kt b/app/feature/feature-claim-chat/src/nativeMain/kotlin/com/hedvig/feature/claim/chat/data/NativeAudioRecordingManager.kt index 39530a05cf..bab3781edd 100644 --- a/app/feature/feature-claim-chat/src/nativeMain/kotlin/com/hedvig/feature/claim/chat/data/NativeAudioRecordingManager.kt +++ b/app/feature/feature-claim-chat/src/nativeMain/kotlin/com/hedvig/feature/claim/chat/data/NativeAudioRecordingManager.kt @@ -1,6 +1,6 @@ package com.hedvig.feature.claim.chat.data -import com.hedvig.feature.claim.chat.data.file.CommonFile +import com.hedvig.android.core.fileupload.CommonFile import kotlin.time.Clock internal class NativeAudioRecordingManager( diff --git a/app/feature/feature-claim-chat/src/nativeMain/kotlin/com/hedvig/feature/claim/chat/data/file/NativeFile.kt b/app/feature/feature-claim-chat/src/nativeMain/kotlin/com/hedvig/feature/claim/chat/data/file/NativeFile.kt deleted file mode 100644 index ee062adba3..0000000000 --- a/app/feature/feature-claim-chat/src/nativeMain/kotlin/com/hedvig/feature/claim/chat/data/file/NativeFile.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.hedvig.feature.claim.chat.data.file - -import com.eygraber.uri.Uri -import kotlinx.io.Source - -// todo ios -internal class NativeFileService : FileService { - override fun convertToCommonFile(uri: Uri): CommonFile { - return object : CommonFile { - override val fileName: String = "file" - override val description: String = "TODO: iOS file" - - override fun source(): Source { - TODO("iOS file source not yet implemented") - } - - override fun readBytes(): ByteArray { - TODO("iOS file read not yet implemented") - } - } - } - - override fun getFileName(uriString: String): String? { - TODO("Not yet implemented") - } - - override fun getMimeType(path: String): String { - TODO("Not yet implemented") - } -} diff --git a/app/feature/feature-claim-chat/src/nativeMain/kotlin/com/hedvig/feature/claim/chat/data/file/NativeFileService.kt b/app/feature/feature-claim-chat/src/nativeMain/kotlin/com/hedvig/feature/claim/chat/data/file/NativeFileService.kt deleted file mode 100644 index 169fa9b9f9..0000000000 --- a/app/feature/feature-claim-chat/src/nativeMain/kotlin/com/hedvig/feature/claim/chat/data/file/NativeFileService.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.hedvig.feature.claim.chat.data.file - -import com.eygraber.uri.Uri -import kotlinx.io.Source - -internal class NativeFile( - override val fileName: String, - override val description: String, -) : CommonFile { - override fun source(): Source { - TODO("Not yet implemented") - } - - override fun readBytes(): ByteArray { - TODO("Not yet implemented") - } -} diff --git a/app/feature/feature-claim-chat/src/nativeMain/kotlin/com/hedvig/feature/claim/chat/di/ClaimChatModule.native.kt b/app/feature/feature-claim-chat/src/nativeMain/kotlin/com/hedvig/feature/claim/chat/di/ClaimChatModule.native.kt index ddd9b57c55..75adc2ac52 100644 --- a/app/feature/feature-claim-chat/src/nativeMain/kotlin/com/hedvig/feature/claim/chat/di/ClaimChatModule.native.kt +++ b/app/feature/feature-claim-chat/src/nativeMain/kotlin/com/hedvig/feature/claim/chat/di/ClaimChatModule.native.kt @@ -2,17 +2,11 @@ package com.hedvig.feature.claim.chat.di import com.hedvig.feature.claim.chat.data.AudioRecordingManager import com.hedvig.feature.claim.chat.data.NativeAudioRecordingManager -import com.hedvig.feature.claim.chat.data.file.FileService -import com.hedvig.feature.claim.chat.data.file.NativeFileService import kotlin.time.Clock import org.koin.core.module.Module import org.koin.dsl.module actual val claimChatPlatformModule: Module = module { - single { - // TODO: Implement iOS FileService - NativeFileService() - } single { NativeAudioRecordingManager(Clock.System) } From 511b30756fce3b447e6cc5e186a43ade35c60cf9 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Thu, 22 Jan 2026 16:33:25 +0100 Subject: [PATCH 6/7] Use Hedvig gateway for claim file uploads The backend returns back which sub-path they want the upload in, at: ``` fragment AudioRecordingFragment on ClaimIntentStepContentAudioRecording { uploadUri ... ``` In those cases, we want the raw base URL to build on top of --- .../core/buildconstants/CommonHedvigBuildConstants.kt | 8 ++++++++ .../android/core/buildconstants/HedvigBuildConstants.kt | 6 ++++++ .../hedvig/feature/claim/chat/data/UploadFileUseCase.kt | 2 +- .../EnableNotificationsReminderManagerTest.kt | 1 + 4 files changed, 16 insertions(+), 1 deletion(-) diff --git a/app/core/core-build-constants/src/commonMain/kotlin/com/hedvig/android/core/buildconstants/CommonHedvigBuildConstants.kt b/app/core/core-build-constants/src/commonMain/kotlin/com/hedvig/android/core/buildconstants/CommonHedvigBuildConstants.kt index 6e4158540b..7be2f8c3b5 100644 --- a/app/core/core-build-constants/src/commonMain/kotlin/com/hedvig/android/core/buildconstants/CommonHedvigBuildConstants.kt +++ b/app/core/core-build-constants/src/commonMain/kotlin/com/hedvig/android/core/buildconstants/CommonHedvigBuildConstants.kt @@ -13,6 +13,7 @@ internal class CommonHedvigBuildConstants( override val urlGraphqlOctopus: String = appConfigUrlHolder.urlGraphqlOctopus(appBuildConfig.appFlavor) override val urlBaseWeb: String = appConfigUrlHolder.urlBaseWeb(appBuildConfig.appFlavor) override val urlOdyssey: String = appConfigUrlHolder.urlOdyssey(appBuildConfig.appFlavor) + override val urlHedvigGateway: String = appConfigUrlHolder.urlHedvigGateway(appBuildConfig.appFlavor) override val urlBotService: String = appConfigUrlHolder.urlBotService(appBuildConfig.appFlavor) override val urlClaimsService: String = appConfigUrlHolder.urlClaimsService(appBuildConfig.appFlavor) override val deepLinkHosts: List = appConfigUrlHolder.deepLinkHosts(appBuildConfig.appFlavor) @@ -55,6 +56,7 @@ private interface UrlHolder { fun urlGraphqlOctopus(flavor: Flavor): String fun urlBaseWeb(flavor: Flavor): String fun urlOdyssey(flavor: Flavor): String + fun urlHedvigGateway(flavor: Flavor): String fun urlBotService(flavor: Flavor): String fun urlClaimsService(flavor: Flavor): String fun deepLinkHosts(flavor: Flavor): List @@ -80,6 +82,12 @@ private class AppConfigUrlHolder(private val appBuildConfig: AppBuildConfig) : U Develop -> "https://odyssey.dev.hedvigit.com" } + override fun urlHedvigGateway(flavor: Flavor): String = when (appBuildConfig.appFlavor) { + Production -> "https://gateway.hedvig.com" + Staging -> "https://gateway.dev.hedvigit.com" + Develop -> "https://gateway.dev.hedvigit.com" + } + override fun urlBotService(flavor: Flavor): String = when (appBuildConfig.appFlavor) { Production -> "https://gateway.hedvig.com/bot-service" Staging -> "https://gateway.dev.hedvigit.com/bot-service" diff --git a/app/core/core-build-constants/src/commonMain/kotlin/com/hedvig/android/core/buildconstants/HedvigBuildConstants.kt b/app/core/core-build-constants/src/commonMain/kotlin/com/hedvig/android/core/buildconstants/HedvigBuildConstants.kt index 4bcd95da3a..361ed74d30 100644 --- a/app/core/core-build-constants/src/commonMain/kotlin/com/hedvig/android/core/buildconstants/HedvigBuildConstants.kt +++ b/app/core/core-build-constants/src/commonMain/kotlin/com/hedvig/android/core/buildconstants/HedvigBuildConstants.kt @@ -16,6 +16,12 @@ interface HedvigBuildConstants { */ val urlOdyssey: String + /** + * The URL targeting the core hedvig gateway URL. To be used with APIs that return which sub-path they want the next + * request to go to + */ + val urlHedvigGateway: String + /** * The URL targeting bot service backend */ diff --git a/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/data/UploadFileUseCase.kt b/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/data/UploadFileUseCase.kt index 603d0f6f65..4495e81592 100644 --- a/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/data/UploadFileUseCase.kt +++ b/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/data/UploadFileUseCase.kt @@ -20,7 +20,7 @@ internal class UploadFileUseCase( ) { context(_: Raise) suspend fun invoke(commonFile: CommonFile, uploadUrl: String): FileUploadResponse { - val fullUrl = "${buildConstants.urlBaseWeb}$uploadUrl" + val fullUrl = "${buildConstants.urlHedvigGateway}$uploadUrl" val responseBody = fileUploadService.uploadWithCustomFormData( url = fullUrl, formDataBuilder = { diff --git a/app/member-reminders/member-reminders-public/src/test/kotlin/com/hedvig/android/memberreminders/EnableNotificationsReminderManagerTest.kt b/app/member-reminders/member-reminders-public/src/test/kotlin/com/hedvig/android/memberreminders/EnableNotificationsReminderManagerTest.kt index f4d694a0a0..a28e6b4a47 100644 --- a/app/member-reminders/member-reminders-public/src/test/kotlin/com/hedvig/android/memberreminders/EnableNotificationsReminderManagerTest.kt +++ b/app/member-reminders/member-reminders-public/src/test/kotlin/com/hedvig/android/memberreminders/EnableNotificationsReminderManagerTest.kt @@ -123,6 +123,7 @@ private val TestHedvigBuildConstants = object : HedvigBuildConstants { override val urlGraphqlOctopus: String = "" override val urlBaseWeb: String = "" override val urlOdyssey: String = "" + override val urlHedvigGateway: String = "" override val urlBotService: String = "" override val urlClaimsService: String = "" override val deepLinkHosts: List = listOf("") From 34fc82f575afee53baee3406342e005b09573d6c Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Thu, 22 Jan 2026 16:44:24 +0100 Subject: [PATCH 7/7] Remove redundant parameter --- .../com/hedvig/android/core/fileupload/FileUploadModule.kt | 1 - .../com/hedvig/android/core/fileupload/FileUploadService.kt | 1 - 2 files changed, 2 deletions(-) diff --git a/app/core/core-file-upload/src/commonMain/kotlin/com/hedvig/android/core/fileupload/FileUploadModule.kt b/app/core/core-file-upload/src/commonMain/kotlin/com/hedvig/android/core/fileupload/FileUploadModule.kt index ff2dfe834b..f17992164a 100644 --- a/app/core/core-file-upload/src/commonMain/kotlin/com/hedvig/android/core/fileupload/FileUploadModule.kt +++ b/app/core/core-file-upload/src/commonMain/kotlin/com/hedvig/android/core/fileupload/FileUploadModule.kt @@ -11,7 +11,6 @@ val fileUploadModule = module { single { FileUploadService( client = get(), - fileService = get(), ) } diff --git a/app/core/core-file-upload/src/commonMain/kotlin/com/hedvig/android/core/fileupload/FileUploadService.kt b/app/core/core-file-upload/src/commonMain/kotlin/com/hedvig/android/core/fileupload/FileUploadService.kt index 36fc99a0df..85d05d44d7 100644 --- a/app/core/core-file-upload/src/commonMain/kotlin/com/hedvig/android/core/fileupload/FileUploadService.kt +++ b/app/core/core-file-upload/src/commonMain/kotlin/com/hedvig/android/core/fileupload/FileUploadService.kt @@ -21,7 +21,6 @@ import io.ktor.http.isSuccess class FileUploadService( private val client: HttpClient, - private val fileService: FileService, ) { /** * Uploads files to a backend service with optional file size validation.