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..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 @@ -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 @@ -95,6 +96,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 +238,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 +414,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 + ) + } +}