From 818b6d1ca34bb02a5db422aa20ad21b092cabfb0 Mon Sep 17 00:00:00 2001 From: Florent Champigny Date: Fri, 28 Nov 2025 15:41:13 +0100 Subject: [PATCH 1/3] feat: [NETWORK] replay --- .../70.json | 20 ++--- .../features/network/list/NetworkViewModel.kt | 11 +++ .../network/list/mapper/NetworkUiMapper.kt | 1 + .../network/list/model/NetworkAction.kt | 2 + .../list/model/NetworkItemViewState.kt | 4 + .../network/list/view/NetworkItemView.kt | 9 ++- .../datasource/NetworkReplayDataSource.kt | 7 ++ .../repository/NetworkRepositoryImpl.kt | 14 ++++ .../local/network/mapper/MapperToDomain.kt | 1 + .../local/network/mapper/MapperToEntity.kt | 1 + .../network/models/FloconNetworkCallEntity.kt | 1 + .../com/flocon/data/remote/network/DI.kt | 3 + .../datasource/NetworkReplayDataSourceImpl.kt | 78 +++++++++++++++++++ .../io/github/openflocon/domain/network/DI.kt | 2 + .../models/FloconNetworkCallDomainModel.kt | 1 + .../network/repository/NetworkRepository.kt | 5 ++ .../usecase/ReplayNetworkCallUseCase.kt | 24 ++++++ 17 files changed, 173 insertions(+), 11 deletions(-) create mode 100644 FloconDesktop/data/core/src/commonMain/kotlin/io/github/openflocon/data/core/network/datasource/NetworkReplayDataSource.kt create mode 100644 FloconDesktop/data/remote/src/commonMain/kotlin/com/flocon/data/remote/network/datasource/NetworkReplayDataSourceImpl.kt create mode 100644 FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/network/usecase/ReplayNetworkCallUseCase.kt diff --git a/FloconDesktop/composeApp/schemas/io.github.openflocon.flocondesktop.common.db.AppDatabase/70.json b/FloconDesktop/composeApp/schemas/io.github.openflocon.flocondesktop.common.db.AppDatabase/70.json index 4b2ce7528..a4307da20 100644 --- a/FloconDesktop/composeApp/schemas/io.github.openflocon.flocondesktop.common.db.AppDatabase/70.json +++ b/FloconDesktop/composeApp/schemas/io.github.openflocon.flocondesktop.common.db.AppDatabase/70.json @@ -2,11 +2,11 @@ "formatVersion": 1, "database": { "version": 70, - "identityHash": "3977ab4681020f5d331fc22db26c02c5", + "identityHash": "3703817a8a5d3760fd1ff9cbac797695", "entities": [ { "tableName": "FloconNetworkCallEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`callId` TEXT NOT NULL, `deviceId` TEXT NOT NULL, `packageName` TEXT NOT NULL, `appInstance` INTEGER NOT NULL, `type` TEXT NOT NULL, `request_url` TEXT NOT NULL, `request_method` TEXT NOT NULL, `request_startTime` INTEGER NOT NULL, `request_startTimeFormatted` TEXT NOT NULL, `request_byteSizeFormatted` TEXT NOT NULL, `request_requestHeaders` TEXT NOT NULL, `request_requestBody` TEXT, `request_requestByteSize` INTEGER NOT NULL, `request_isMocked` INTEGER NOT NULL, `request_domainFormatted` TEXT NOT NULL, `request_methodFormatted` TEXT NOT NULL, `request_queryFormatted` TEXT NOT NULL, `request_graphql_query` TEXT, `request_graphql_operationType` TEXT, `request_websocket_event` TEXT, `response_durationMs` REAL, `response_durationFormatted` TEXT, `response_responseContentType` TEXT, `response_responseBody` TEXT, `response_responseHeaders` TEXT, `response_responseByteSize` INTEGER, `response_responseByteSizeFormatted` TEXT, `response_responseError` TEXT, `response_isImage` INTEGER, `response_statusFormatted` TEXT, `response_graphql_isSuccess` INTEGER, `response_graphql_responseHttpCode` INTEGER, `response_http_responseHttpCode` INTEGER, `response_grpc_responseStatus` TEXT, PRIMARY KEY(`callId`), FOREIGN KEY(`deviceId`, `packageName`) REFERENCES `DeviceAppEntity`(`deviceId`, `packageName`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`callId` TEXT NOT NULL, `deviceId` TEXT NOT NULL, `packageName` TEXT NOT NULL, `appInstance` INTEGER NOT NULL, `type` TEXT NOT NULL, `isReplayed` INTEGER NOT NULL, `request_url` TEXT NOT NULL, `request_method` TEXT NOT NULL, `request_startTime` INTEGER NOT NULL, `request_startTimeFormatted` TEXT NOT NULL, `request_byteSizeFormatted` TEXT NOT NULL, `request_requestHeaders` TEXT NOT NULL, `request_requestBody` TEXT, `request_requestByteSize` INTEGER NOT NULL, `request_isMocked` INTEGER NOT NULL, `request_domainFormatted` TEXT NOT NULL, `request_methodFormatted` TEXT NOT NULL, `request_queryFormatted` TEXT NOT NULL, `request_graphql_query` TEXT, `request_graphql_operationType` TEXT, `request_websocket_event` TEXT, `response_durationMs` REAL, `response_durationFormatted` TEXT, `response_responseContentType` TEXT, `response_responseBody` TEXT, `response_responseHeaders` TEXT, `response_responseByteSize` INTEGER, `response_responseByteSizeFormatted` TEXT, `response_responseError` TEXT, `response_isImage` INTEGER, `response_statusFormatted` TEXT, `response_graphql_isSuccess` INTEGER, `response_graphql_responseHttpCode` INTEGER, `response_http_responseHttpCode` INTEGER, `response_grpc_responseStatus` TEXT, PRIMARY KEY(`callId`), FOREIGN KEY(`deviceId`, `packageName`) REFERENCES `DeviceAppEntity`(`deviceId`, `packageName`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "callId", @@ -38,6 +38,12 @@ "affinity": "TEXT", "notNull": true }, + { + "fieldPath": "isReplayed", + "columnName": "isReplayed", + "affinity": "INTEGER", + "notNull": true + }, { "fieldPath": "request.url", "columnName": "request_url", @@ -745,7 +751,7 @@ }, { "tableName": "DeviceImageEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `packageName` TEXT NOT NULL, `url` TEXT NOT NULL, `time` INTEGER NOT NULL, `headersJsonEncoded` TEXT NOT NULL, PRIMARY KEY(`deviceId`, `packageName`, `url`, `time`), FOREIGN KEY(`deviceId`, `packageName`) REFERENCES `DeviceAppEntity`(`deviceId`, `packageName`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `packageName` TEXT NOT NULL, `url` TEXT NOT NULL, `time` INTEGER NOT NULL, PRIMARY KEY(`deviceId`, `packageName`, `url`, `time`), FOREIGN KEY(`deviceId`, `packageName`) REFERENCES `DeviceAppEntity`(`deviceId`, `packageName`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "deviceId", @@ -770,12 +776,6 @@ "columnName": "time", "affinity": "INTEGER", "notNull": true - }, - { - "fieldPath": "headersJsonEncoded", - "columnName": "headersJsonEncoded", - "affinity": "TEXT", - "notNull": true } ], "primaryKey": { @@ -1654,7 +1654,7 @@ ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3977ab4681020f5d331fc22db26c02c5')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3703817a8a5d3760fd1ff9cbac797695')" ] } } \ No newline at end of file diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/list/NetworkViewModel.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/list/NetworkViewModel.kt index 52cf6b3fe..ec1f38886 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/list/NetworkViewModel.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/list/NetworkViewModel.kt @@ -32,6 +32,7 @@ import io.github.openflocon.domain.network.usecase.ResetCurrentDeviceHttpRequest import io.github.openflocon.domain.network.usecase.badquality.ObserveAllNetworkBadQualitiesUseCase import io.github.openflocon.domain.network.usecase.mocks.ObserveNetworkMocksUseCase import io.github.openflocon.domain.network.usecase.mocks.ObserveNetworkWebsocketIdsUseCase +import io.github.openflocon.domain.network.usecase.ReplayNetworkCallUseCase import io.github.openflocon.flocondesktop.common.utils.stateInWhileSubscribed import io.github.openflocon.flocondesktop.core.data.settings.usecase.ObserveNetworkSettingsUseCase import io.github.openflocon.flocondesktop.core.data.settings.usecase.SaveNetworkSettingsUseCase @@ -71,6 +72,8 @@ import org.koin.core.component.inject class NetworkViewModel( observeNetworkRequestsUseCase: ObserveNetworkRequestsUseCase, private val observeNetworkRequestsByIdUseCase: ObserveNetworkRequestsByIdUseCase, + private val generateCurlCommandUseCase: GenerateCurlCommandUseCase, + private val resetCurrentDeviceHttpRequestsUseCase: ResetCurrentDeviceHttpRequestsUseCase, private val removeHttpRequestsBeforeUseCase: RemoveHttpRequestsBeforeUseCase, private val removeNetworkRequestUseCase: RemoveNetworkRequestUseCase, private val mocksUseCase: ObserveNetworkMocksUseCase, @@ -95,6 +98,7 @@ class NetworkViewModel( private val getNetworkRequestsUseCase: GetNetworkRequestsUseCase by inject() private val feedbackDisplayer: FeedbackDisplayer by inject() private val exportNetworkCallsToCsv: ExportNetworkCallsToCsvUseCase by inject() + private val replayNetworkCallUseCase: ReplayNetworkCallUseCase by inject() private val contentState = MutableStateFlow( ContentUiState( @@ -236,6 +240,7 @@ class NetworkViewModel( is NetworkAction.OpenBadNetworkQuality -> openBadNetworkQuality() is NetworkAction.CloseBadNetworkQuality -> closeBadNetworkQuality() is NetworkAction.CopyCUrl -> onCopyCUrl(action) + is NetworkAction.Replay -> onReplay(action) is NetworkAction.CopyUrl -> onCopyUrl(action) is NetworkAction.Remove -> onRemove(action) is NetworkAction.RemoveLinesAbove -> onRemoveLinesAbove(action) @@ -411,6 +416,12 @@ class NetworkViewModel( } } + private fun onReplay(action: NetworkAction.Replay) { + viewModelScope.launch(dispatcherProvider.viewModel) { + replayNetworkCallUseCase(action.item.uuid) + } + } + private fun onCopyUrl(action: NetworkAction.CopyUrl) { viewModelScope.launch(dispatcherProvider.viewModel) { val domainModel = observeNetworkRequestsByIdUseCase(action.item.uuid).firstOrNull() diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/list/mapper/NetworkUiMapper.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/list/mapper/NetworkUiMapper.kt index de932ce36..0c87e0236 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/list/mapper/NetworkUiMapper.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/list/mapper/NetworkUiMapper.kt @@ -19,6 +19,7 @@ fun FloconNetworkCallDomainModel.toUi( method = getMethodUi(this), status = getStatusUi(this), isMocked = request.isMocked, + isReplayed = isReplayed, isFromOldAppInstance = deviceIdAndPackageName?.appInstance?.let { it != appInstance } ?: false ) } diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/list/model/NetworkAction.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/list/model/NetworkAction.kt index 2a5766294..a661d069a 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/list/model/NetworkAction.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/list/model/NetworkAction.kt @@ -38,6 +38,8 @@ sealed interface NetworkAction { data class CopyCUrl(val item: NetworkItemViewState) : NetworkAction + data class Replay(val item: NetworkItemViewState) : NetworkAction + data class Remove(val item: NetworkItemViewState) : NetworkAction data class RemoveLinesAbove(val item: NetworkItemViewState) : NetworkAction diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/list/model/NetworkItemViewState.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/list/model/NetworkItemViewState.kt index 15e628c41..5e9aea17a 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/list/model/NetworkItemViewState.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/list/model/NetworkItemViewState.kt @@ -15,6 +15,7 @@ data class NetworkItemViewState( val status: NetworkStatusUi, val method: NetworkMethodUi, val isMocked: Boolean, + val isReplayed: Boolean, val isFromOldAppInstance: Boolean, ) { @@ -83,6 +84,7 @@ fun previewNetworkItemViewState(): NetworkItemViewState = NetworkItemViewState( ), isMocked = false, isFromOldAppInstance = false, + isReplayed = false, ) fun previewNetworkItemViewStateError(): NetworkItemViewState = NetworkItemViewState( @@ -99,6 +101,7 @@ fun previewNetworkItemViewStateError(): NetworkItemViewState = NetworkItemViewSt ), isMocked = false, isFromOldAppInstance = false, + isReplayed = false, ) fun previewGraphQlItemViewState(): NetworkItemViewState = NetworkItemViewState( @@ -115,4 +118,5 @@ fun previewGraphQlItemViewState(): NetworkItemViewState = NetworkItemViewState( ), isMocked = false, isFromOldAppInstance = false, + isReplayed = false, ) diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/list/view/NetworkItemView.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/list/view/NetworkItemView.kt index dd89e928c..d7465ee41 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/list/view/NetworkItemView.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/list/view/NetworkItemView.kt @@ -51,6 +51,8 @@ import io.github.openflocon.library.designsystem.common.buildMenu import io.github.openflocon.library.designsystem.components.FloconSurface import org.jetbrains.compose.ui.tooling.preview.Preview +private val replayColor = Color(0xFF242D44) + @Composable fun NetworkItemView( state: NetworkItemViewState, @@ -84,7 +86,9 @@ fun NetworkItemView( } else Modifier ) .then( - if (state.isMocked) { + if(state.isReplayed) { + Modifier.background(replayColor) + } else if (state.isMocked) { Modifier.background(FloconTheme.colorPalette.accent) } else Modifier, ) @@ -211,6 +215,9 @@ private fun contextualActions( item( label = "Create Mock", onClick = { onActionCallback(NetworkAction.CreateMock(state)) }) + item( + label = "Replay", + onClick = { onActionCallback(NetworkAction.Replay(state)) }) } separator() subMenu(label = "Filter") { diff --git a/FloconDesktop/data/core/src/commonMain/kotlin/io/github/openflocon/data/core/network/datasource/NetworkReplayDataSource.kt b/FloconDesktop/data/core/src/commonMain/kotlin/io/github/openflocon/data/core/network/datasource/NetworkReplayDataSource.kt new file mode 100644 index 000000000..dba4b39cc --- /dev/null +++ b/FloconDesktop/data/core/src/commonMain/kotlin/io/github/openflocon/data/core/network/datasource/NetworkReplayDataSource.kt @@ -0,0 +1,7 @@ +package io.github.openflocon.data.core.network.datasource + +import io.github.openflocon.domain.network.models.FloconNetworkCallDomainModel + +interface NetworkReplayDataSource { + suspend fun replay(request: FloconNetworkCallDomainModel): FloconNetworkCallDomainModel +} diff --git a/FloconDesktop/data/core/src/commonMain/kotlin/io/github/openflocon/data/core/network/repository/NetworkRepositoryImpl.kt b/FloconDesktop/data/core/src/commonMain/kotlin/io/github/openflocon/data/core/network/repository/NetworkRepositoryImpl.kt index 539733dc3..ed167c68b 100644 --- a/FloconDesktop/data/core/src/commonMain/kotlin/io/github/openflocon/data/core/network/repository/NetworkRepositoryImpl.kt +++ b/FloconDesktop/data/core/src/commonMain/kotlin/io/github/openflocon/data/core/network/repository/NetworkRepositoryImpl.kt @@ -6,6 +6,7 @@ import io.github.openflocon.data.core.network.datasource.NetworkLocalWebsocketDa import io.github.openflocon.data.core.network.datasource.NetworkMocksLocalDataSource import io.github.openflocon.data.core.network.datasource.NetworkQualityLocalDataSource import io.github.openflocon.data.core.network.datasource.NetworkRemoteDataSource +import io.github.openflocon.data.core.network.datasource.NetworkReplayDataSource import io.github.openflocon.domain.Protocol import io.github.openflocon.domain.common.DispatcherProvider import io.github.openflocon.domain.device.models.DeviceIdAndPackageNameDomainModel @@ -36,6 +37,7 @@ class NetworkRepositoryImpl( private val networkQualityLocalDataSource: NetworkQualityLocalDataSource, private val networkImageRepository: NetworkImageRepository, private val networkRemoteDataSource: NetworkRemoteDataSource, + private val networkReplayDataSource: NetworkReplayDataSource, ) : NetworkRepository, NetworkMocksRepository, MessagesReceiverRepository, @@ -447,4 +449,16 @@ class NetworkRepositoryImpl( } } + override suspend fun replayRequest( + deviceIdAndPackageName: DeviceIdAndPackageNameDomainModel, + request: FloconNetworkCallDomainModel + ) { + withContext(dispatcherProvider.data) { + val replayedRequest = networkReplayDataSource.replay(request) + networkLocalDataSource.save( + deviceIdAndPackageName = deviceIdAndPackageName, + call = replayedRequest + ) + } + } } diff --git a/FloconDesktop/data/local/src/commonMain/kotlin/io/github/openflocon/data/local/network/mapper/MapperToDomain.kt b/FloconDesktop/data/local/src/commonMain/kotlin/io/github/openflocon/data/local/network/mapper/MapperToDomain.kt index 1925ba523..5f9d829f4 100644 --- a/FloconDesktop/data/local/src/commonMain/kotlin/io/github/openflocon/data/local/network/mapper/MapperToDomain.kt +++ b/FloconDesktop/data/local/src/commonMain/kotlin/io/github/openflocon/data/local/network/mapper/MapperToDomain.kt @@ -12,6 +12,7 @@ fun FloconNetworkCallEntity.toDomainModel(): FloconNetworkCallDomainModel { appInstance = appInstance, request = toRequestDomainModel(), response = response?.toDomainModel(), + isReplayed = isReplayed, ) } diff --git a/FloconDesktop/data/local/src/commonMain/kotlin/io/github/openflocon/data/local/network/mapper/MapperToEntity.kt b/FloconDesktop/data/local/src/commonMain/kotlin/io/github/openflocon/data/local/network/mapper/MapperToEntity.kt index 622b28f1f..9fde6d6ab 100644 --- a/FloconDesktop/data/local/src/commonMain/kotlin/io/github/openflocon/data/local/network/mapper/MapperToEntity.kt +++ b/FloconDesktop/data/local/src/commonMain/kotlin/io/github/openflocon/data/local/network/mapper/MapperToEntity.kt @@ -20,6 +20,7 @@ fun FloconNetworkCallDomainModel.toEntity( deviceId = deviceIdAndPackageName.deviceId, packageName = deviceIdAndPackageName.packageName, appInstance = deviceIdAndPackageName.appInstance, + isReplayed = isReplayed, type = when (this.request.specificInfos) { is FloconNetworkCallDomainModel.Request.SpecificInfos.Http -> FloconNetworkCallType.HTTP is FloconNetworkCallDomainModel.Request.SpecificInfos.GraphQl -> FloconNetworkCallType.GRAPHQL diff --git a/FloconDesktop/data/local/src/commonMain/kotlin/io/github/openflocon/data/local/network/models/FloconNetworkCallEntity.kt b/FloconDesktop/data/local/src/commonMain/kotlin/io/github/openflocon/data/local/network/models/FloconNetworkCallEntity.kt index 130d659bc..a39843b9c 100644 --- a/FloconDesktop/data/local/src/commonMain/kotlin/io/github/openflocon/data/local/network/models/FloconNetworkCallEntity.kt +++ b/FloconDesktop/data/local/src/commonMain/kotlin/io/github/openflocon/data/local/network/models/FloconNetworkCallEntity.kt @@ -33,6 +33,7 @@ data class FloconNetworkCallEntity( val appInstance: Long, // the start time of the mobile app val type: FloconNetworkCallType, + val isReplayed: Boolean, @Embedded(prefix = "request_") val request: FloconNetworkRequestEmbedded, diff --git a/FloconDesktop/data/remote/src/commonMain/kotlin/com/flocon/data/remote/network/DI.kt b/FloconDesktop/data/remote/src/commonMain/kotlin/com/flocon/data/remote/network/DI.kt index b8e2a8661..6b99494d6 100644 --- a/FloconDesktop/data/remote/src/commonMain/kotlin/com/flocon/data/remote/network/DI.kt +++ b/FloconDesktop/data/remote/src/commonMain/kotlin/com/flocon/data/remote/network/DI.kt @@ -1,11 +1,14 @@ package com.flocon.data.remote.network import com.flocon.data.remote.network.datasource.NetworkRemoteDataSourceImpl +import com.flocon.data.remote.network.datasource.NetworkReplayDataSourceImpl import io.github.openflocon.data.core.network.datasource.NetworkRemoteDataSource +import io.github.openflocon.data.core.network.datasource.NetworkReplayDataSource import org.koin.core.module.dsl.singleOf import org.koin.dsl.bind import org.koin.dsl.module internal val networkModule = module { singleOf(::NetworkRemoteDataSourceImpl) bind NetworkRemoteDataSource::class + singleOf(::NetworkReplayDataSourceImpl) bind NetworkReplayDataSource::class } diff --git a/FloconDesktop/data/remote/src/commonMain/kotlin/com/flocon/data/remote/network/datasource/NetworkReplayDataSourceImpl.kt b/FloconDesktop/data/remote/src/commonMain/kotlin/com/flocon/data/remote/network/datasource/NetworkReplayDataSourceImpl.kt new file mode 100644 index 000000000..f834d4e24 --- /dev/null +++ b/FloconDesktop/data/remote/src/commonMain/kotlin/com/flocon/data/remote/network/datasource/NetworkReplayDataSourceImpl.kt @@ -0,0 +1,78 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package com.flocon.data.remote.network.datasource + +import io.github.openflocon.data.core.network.datasource.NetworkReplayDataSource +import io.github.openflocon.domain.common.ByteFormatter +import io.github.openflocon.domain.common.time.formatDuration +import io.github.openflocon.domain.common.time.formatTimestamp +import io.github.openflocon.domain.network.models.FloconNetworkCallDomainModel +import io.github.openflocon.domain.network.models.FloconNetworkCallDomainModel.Response.Success.SpecificInfos +import io.ktor.client.HttpClient +import io.ktor.client.request.request +import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsText +import io.ktor.http.HttpMethod +import io.ktor.http.content.TextContent +import io.ktor.util.toMap +import kotlin.time.Clock +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +internal class NetworkReplayDataSourceImpl : NetworkReplayDataSource { + + private val client = HttpClient() + + override suspend fun replay(request: FloconNetworkCallDomainModel): FloconNetworkCallDomainModel { + val startTime = Clock.System.now().toEpochMilliseconds() + val response = try { + val result = client.request(request.request.url) { + method = HttpMethod.parse(request.request.method) + request.request.headers.forEach { (key, value) -> + headers.append(key, value) + } + request.request.body?.let { + setBody(TextContent(it, io.ktor.http.ContentType.Application.Json)) // Assuming JSON for now + } + } + val endTime = Clock.System.now().toEpochMilliseconds() + val duration = (endTime - startTime).toDouble() + val body = result.bodyAsText() + val size = body.length.toLong() + + FloconNetworkCallDomainModel.Response.Success( + headers = result.headers.toMap().mapValues { it.value.joinToString(",") }, + body = body, + specificInfos = SpecificInfos.Http( + httpCode = result.status.value + ), + durationMs = duration, + durationFormatted = formatDuration(duration), + byteSize = size, + byteSizeFormatted = ByteFormatter.formatBytes(size), + isImage = false, // TODO: check content type + statusFormatted = result.status.value.toString(), + contentType = result.headers["Content-Type"] + ) + } catch (e: Exception) { + val endTime = Clock.System.now().toEpochMilliseconds() + val duration = (endTime - startTime).toDouble() + FloconNetworkCallDomainModel.Response.Failure( + issue = e.message ?: "Unknown error", + durationMs = duration, + durationFormatted = formatDuration(duration), + statusFormatted = "Error" + ) + } + + return request.copy( + callId = Uuid.random().toString(), + request = request.request.copy( + startTime = startTime, + startTimeFormatted = formatTimestamp(startTime), + ), + response = response, + isReplayed = true, + ) + } +} diff --git a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/network/DI.kt b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/network/DI.kt index 6e4d3aba3..3593299ba 100644 --- a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/network/DI.kt +++ b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/network/DI.kt @@ -11,6 +11,7 @@ import io.github.openflocon.domain.network.usecase.ObserveNetworkRequestsUseCase import io.github.openflocon.domain.network.usecase.RemoveHttpRequestsBeforeUseCase import io.github.openflocon.domain.network.usecase.RemoveNetworkRequestUseCase import io.github.openflocon.domain.network.usecase.RemoveOldSessionsNetworkRequestUseCase +import io.github.openflocon.domain.network.usecase.ReplayNetworkCallUseCase import io.github.openflocon.domain.network.usecase.ResetCurrentDeviceHttpRequestsUseCase import io.github.openflocon.domain.network.usecase.UpdateNetworkFilterUseCase import io.github.openflocon.domain.network.usecase.badquality.DeleteBadQualityUseCase @@ -43,6 +44,7 @@ internal val networkModule = module { factoryOf(::ExportNetworkCallsToCsvUseCase) factoryOf(::DecodeJwtTokenUseCase) factoryOf(::RemoveOldSessionsNetworkRequestUseCase) + factoryOf(::ReplayNetworkCallUseCase) // filters factoryOf(::GetNetworkFilterUseCase) factoryOf(::ObserveNetworkFilterUseCase) diff --git a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/network/models/FloconNetworkCallDomainModel.kt b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/network/models/FloconNetworkCallDomainModel.kt index e931cb427..9ee8b87d3 100644 --- a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/network/models/FloconNetworkCallDomainModel.kt +++ b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/network/models/FloconNetworkCallDomainModel.kt @@ -7,6 +7,7 @@ data class FloconNetworkCallDomainModel( val appInstance: AppInstance, val request: Request, val response: Response?, + val isReplayed: Boolean = false, ) { data class Request( diff --git a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/network/repository/NetworkRepository.kt b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/network/repository/NetworkRepository.kt index b622d4138..216c911a4 100644 --- a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/network/repository/NetworkRepository.kt +++ b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/network/repository/NetworkRepository.kt @@ -52,4 +52,9 @@ interface NetworkRepository { suspend fun deleteOldRequests( deviceIdAndPackageName: DeviceIdAndPackageNameDomainModel ) + + suspend fun replayRequest( + deviceIdAndPackageName: DeviceIdAndPackageNameDomainModel, + request: FloconNetworkCallDomainModel + ) } diff --git a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/network/usecase/ReplayNetworkCallUseCase.kt b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/network/usecase/ReplayNetworkCallUseCase.kt new file mode 100644 index 000000000..50c59752a --- /dev/null +++ b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/network/usecase/ReplayNetworkCallUseCase.kt @@ -0,0 +1,24 @@ +package io.github.openflocon.domain.network.usecase + +import io.github.openflocon.domain.device.usecase.GetCurrentDeviceIdAndPackageNameUseCase +import io.github.openflocon.domain.device.usecase.ObserveCurrentDeviceIdAndPackageNameUseCase +import io.github.openflocon.domain.network.repository.NetworkRepository +import kotlinx.coroutines.flow.firstOrNull + +class ReplayNetworkCallUseCase( + private val networkRepository: NetworkRepository, + private val getCurrentDeviceIdAndPackageNameUseCase: GetCurrentDeviceIdAndPackageNameUseCase, +) { + suspend operator fun invoke(callId: String) { + val deviceIdAndPackageName = getCurrentDeviceIdAndPackageNameUseCase() ?: return + val call = networkRepository.observeRequest( + deviceIdAndPackageName = deviceIdAndPackageName, + requestId = callId + ).firstOrNull() ?: return + + networkRepository.replayRequest( + deviceIdAndPackageName = deviceIdAndPackageName, + request = call + ) + } +} From 783c44a3bb34b379901be6739dfd1a397f256f18 Mon Sep 17 00:00:00 2001 From: Florent Champigny Date: Fri, 28 Nov 2025 15:41:57 +0100 Subject: [PATCH 2/3] revert db file --- .../70.json | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/FloconDesktop/composeApp/schemas/io.github.openflocon.flocondesktop.common.db.AppDatabase/70.json b/FloconDesktop/composeApp/schemas/io.github.openflocon.flocondesktop.common.db.AppDatabase/70.json index a4307da20..4b2ce7528 100644 --- a/FloconDesktop/composeApp/schemas/io.github.openflocon.flocondesktop.common.db.AppDatabase/70.json +++ b/FloconDesktop/composeApp/schemas/io.github.openflocon.flocondesktop.common.db.AppDatabase/70.json @@ -2,11 +2,11 @@ "formatVersion": 1, "database": { "version": 70, - "identityHash": "3703817a8a5d3760fd1ff9cbac797695", + "identityHash": "3977ab4681020f5d331fc22db26c02c5", "entities": [ { "tableName": "FloconNetworkCallEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`callId` TEXT NOT NULL, `deviceId` TEXT NOT NULL, `packageName` TEXT NOT NULL, `appInstance` INTEGER NOT NULL, `type` TEXT NOT NULL, `isReplayed` INTEGER NOT NULL, `request_url` TEXT NOT NULL, `request_method` TEXT NOT NULL, `request_startTime` INTEGER NOT NULL, `request_startTimeFormatted` TEXT NOT NULL, `request_byteSizeFormatted` TEXT NOT NULL, `request_requestHeaders` TEXT NOT NULL, `request_requestBody` TEXT, `request_requestByteSize` INTEGER NOT NULL, `request_isMocked` INTEGER NOT NULL, `request_domainFormatted` TEXT NOT NULL, `request_methodFormatted` TEXT NOT NULL, `request_queryFormatted` TEXT NOT NULL, `request_graphql_query` TEXT, `request_graphql_operationType` TEXT, `request_websocket_event` TEXT, `response_durationMs` REAL, `response_durationFormatted` TEXT, `response_responseContentType` TEXT, `response_responseBody` TEXT, `response_responseHeaders` TEXT, `response_responseByteSize` INTEGER, `response_responseByteSizeFormatted` TEXT, `response_responseError` TEXT, `response_isImage` INTEGER, `response_statusFormatted` TEXT, `response_graphql_isSuccess` INTEGER, `response_graphql_responseHttpCode` INTEGER, `response_http_responseHttpCode` INTEGER, `response_grpc_responseStatus` TEXT, PRIMARY KEY(`callId`), FOREIGN KEY(`deviceId`, `packageName`) REFERENCES `DeviceAppEntity`(`deviceId`, `packageName`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`callId` TEXT NOT NULL, `deviceId` TEXT NOT NULL, `packageName` TEXT NOT NULL, `appInstance` INTEGER NOT NULL, `type` TEXT NOT NULL, `request_url` TEXT NOT NULL, `request_method` TEXT NOT NULL, `request_startTime` INTEGER NOT NULL, `request_startTimeFormatted` TEXT NOT NULL, `request_byteSizeFormatted` TEXT NOT NULL, `request_requestHeaders` TEXT NOT NULL, `request_requestBody` TEXT, `request_requestByteSize` INTEGER NOT NULL, `request_isMocked` INTEGER NOT NULL, `request_domainFormatted` TEXT NOT NULL, `request_methodFormatted` TEXT NOT NULL, `request_queryFormatted` TEXT NOT NULL, `request_graphql_query` TEXT, `request_graphql_operationType` TEXT, `request_websocket_event` TEXT, `response_durationMs` REAL, `response_durationFormatted` TEXT, `response_responseContentType` TEXT, `response_responseBody` TEXT, `response_responseHeaders` TEXT, `response_responseByteSize` INTEGER, `response_responseByteSizeFormatted` TEXT, `response_responseError` TEXT, `response_isImage` INTEGER, `response_statusFormatted` TEXT, `response_graphql_isSuccess` INTEGER, `response_graphql_responseHttpCode` INTEGER, `response_http_responseHttpCode` INTEGER, `response_grpc_responseStatus` TEXT, PRIMARY KEY(`callId`), FOREIGN KEY(`deviceId`, `packageName`) REFERENCES `DeviceAppEntity`(`deviceId`, `packageName`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "callId", @@ -38,12 +38,6 @@ "affinity": "TEXT", "notNull": true }, - { - "fieldPath": "isReplayed", - "columnName": "isReplayed", - "affinity": "INTEGER", - "notNull": true - }, { "fieldPath": "request.url", "columnName": "request_url", @@ -751,7 +745,7 @@ }, { "tableName": "DeviceImageEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `packageName` TEXT NOT NULL, `url` TEXT NOT NULL, `time` INTEGER NOT NULL, PRIMARY KEY(`deviceId`, `packageName`, `url`, `time`), FOREIGN KEY(`deviceId`, `packageName`) REFERENCES `DeviceAppEntity`(`deviceId`, `packageName`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `packageName` TEXT NOT NULL, `url` TEXT NOT NULL, `time` INTEGER NOT NULL, `headersJsonEncoded` TEXT NOT NULL, PRIMARY KEY(`deviceId`, `packageName`, `url`, `time`), FOREIGN KEY(`deviceId`, `packageName`) REFERENCES `DeviceAppEntity`(`deviceId`, `packageName`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "deviceId", @@ -776,6 +770,12 @@ "columnName": "time", "affinity": "INTEGER", "notNull": true + }, + { + "fieldPath": "headersJsonEncoded", + "columnName": "headersJsonEncoded", + "affinity": "TEXT", + "notNull": true } ], "primaryKey": { @@ -1654,7 +1654,7 @@ ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3703817a8a5d3760fd1ff9cbac797695')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3977ab4681020f5d331fc22db26c02c5')" ] } } \ No newline at end of file From e013e8cdfaa8c28b236c10034471245e5e6decf0 Mon Sep 17 00:00:00 2001 From: Florent Champigny Date: Fri, 28 Nov 2025 15:42:47 +0100 Subject: [PATCH 3/3] revert db file --- .../flocondesktop/features/network/list/NetworkViewModel.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/list/NetworkViewModel.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/list/NetworkViewModel.kt index ec1f38886..f03176a98 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/list/NetworkViewModel.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/list/NetworkViewModel.kt @@ -72,8 +72,6 @@ import org.koin.core.component.inject class NetworkViewModel( observeNetworkRequestsUseCase: ObserveNetworkRequestsUseCase, private val observeNetworkRequestsByIdUseCase: ObserveNetworkRequestsByIdUseCase, - private val generateCurlCommandUseCase: GenerateCurlCommandUseCase, - private val resetCurrentDeviceHttpRequestsUseCase: ResetCurrentDeviceHttpRequestsUseCase, private val removeHttpRequestsBeforeUseCase: RemoveHttpRequestsBeforeUseCase, private val removeNetworkRequestUseCase: RemoveNetworkRequestUseCase, private val mocksUseCase: ObserveNetworkMocksUseCase,