diff --git a/FloconDesktop/data/core/src/commonMain/kotlin/io/github/openflocon/data/core/network/datasource/NetworkLocalDataSource.kt b/FloconDesktop/data/core/src/commonMain/kotlin/io/github/openflocon/data/core/network/datasource/NetworkLocalDataSource.kt index 5d64a80f5..6ff38c03d 100644 --- a/FloconDesktop/data/core/src/commonMain/kotlin/io/github/openflocon/data/core/network/datasource/NetworkLocalDataSource.kt +++ b/FloconDesktop/data/core/src/commonMain/kotlin/io/github/openflocon/data/core/network/datasource/NetworkLocalDataSource.kt @@ -3,6 +3,7 @@ package io.github.openflocon.data.core.network.datasource import androidx.paging.PagingData import io.github.openflocon.domain.device.models.DeviceIdAndPackageNameDomainModel import io.github.openflocon.domain.network.models.FloconNetworkCallDomainModel +import io.github.openflocon.domain.network.models.NetworkCallWithDeviceId import io.github.openflocon.domain.network.models.NetworkFilterDomainModel import io.github.openflocon.domain.network.models.NetworkSortDomainModel import kotlinx.coroutines.flow.Flow @@ -54,5 +55,11 @@ interface NetworkLocalDataSource { suspend fun deleteRequestOnDifferentSession(deviceIdAndPackageName: DeviceIdAndPackageNameDomainModel) + suspend fun getAllRequests( + deviceId: String? = null, + startTimestamp: Long? = null, + endTimestamp: Long? = null, + ): List + suspend fun clear() } 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 372050076..d7728865f 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 @@ -15,6 +15,7 @@ import io.github.openflocon.domain.messages.repository.MessagesReceiverRepositor import io.github.openflocon.domain.network.models.BadQualityConfigDomainModel import io.github.openflocon.domain.network.models.BadQualityConfigId import io.github.openflocon.domain.network.models.FloconNetworkCallDomainModel +import io.github.openflocon.domain.network.models.NetworkCallWithDeviceId import io.github.openflocon.domain.network.models.FloconNetworkResponseOnlyDomainModel import io.github.openflocon.domain.network.models.MockNetworkDomainModel import io.github.openflocon.domain.network.models.NetworkFilterDomainModel @@ -98,6 +99,15 @@ class NetworkRepositoryImpl( ) } + override suspend fun getAllNetworkCalls( + deviceId: String?, + startTimestamp: Long?, + endTimestamp: Long?, + ): List = + withContext(dispatcherProvider.data) { + networkLocalDataSource.getAllRequests(deviceId, startTimestamp, endTimestamp) + } + override suspend fun getRequests( deviceIdAndPackageName: DeviceIdAndPackageNameDomainModel, ids: List diff --git a/FloconDesktop/data/local/src/commonMain/kotlin/io/github/openflocon/data/local/network/dao/FloconNetworkDao.kt b/FloconDesktop/data/local/src/commonMain/kotlin/io/github/openflocon/data/local/network/dao/FloconNetworkDao.kt index 0150cc5de..0e9234169 100644 --- a/FloconDesktop/data/local/src/commonMain/kotlin/io/github/openflocon/data/local/network/dao/FloconNetworkDao.kt +++ b/FloconDesktop/data/local/src/commonMain/kotlin/io/github/openflocon/data/local/network/dao/FloconNetworkDao.kt @@ -68,6 +68,34 @@ interface FloconNetworkDao { callId: String, ): FloconNetworkCallEntity? + @Query( + """ + SELECT * FROM FloconNetworkCallEntity + WHERE (:startTimestamp IS NULL OR request_startTime >= :startTimestamp) + AND (:endTimestamp IS NULL OR request_startTime <= :endTimestamp) + ORDER BY request_startTime ASC + """, + ) + suspend fun getAllRequests( + startTimestamp: Long? = null, + endTimestamp: Long? = null, + ): List + + @Query( + """ + SELECT * FROM FloconNetworkCallEntity + WHERE deviceId = :deviceId + AND (:startTimestamp IS NULL OR request_startTime >= :startTimestamp) + AND (:endTimestamp IS NULL OR request_startTime <= :endTimestamp) + ORDER BY request_startTime ASC + """, + ) + suspend fun getAllRequestsByDevice( + deviceId: String, + startTimestamp: Long? = null, + endTimestamp: Long? = null, + ): List + @Query("DELETE FROM FloconNetworkCallEntity") suspend fun clearAll() diff --git a/FloconDesktop/data/local/src/commonMain/kotlin/io/github/openflocon/data/local/network/datasource/NetworkLocalDataSourceRoom.kt b/FloconDesktop/data/local/src/commonMain/kotlin/io/github/openflocon/data/local/network/datasource/NetworkLocalDataSourceRoom.kt index 42b896814..858244eb6 100644 --- a/FloconDesktop/data/local/src/commonMain/kotlin/io/github/openflocon/data/local/network/datasource/NetworkLocalDataSourceRoom.kt +++ b/FloconDesktop/data/local/src/commonMain/kotlin/io/github/openflocon/data/local/network/datasource/NetworkLocalDataSourceRoom.kt @@ -12,6 +12,7 @@ import io.github.openflocon.data.local.network.mapper.toEntity import io.github.openflocon.data.local.network.models.FloconNetworkCallEntity import io.github.openflocon.domain.device.models.DeviceIdAndPackageNameDomainModel import io.github.openflocon.domain.network.models.FloconNetworkCallDomainModel +import io.github.openflocon.domain.network.models.NetworkCallWithDeviceId import io.github.openflocon.domain.network.models.NetworkFilterDomainModel import io.github.openflocon.domain.network.models.NetworkSortDomainModel import io.github.openflocon.domain.network.models.NetworkTextFilterColumns @@ -260,6 +261,23 @@ class NetworkLocalDataSourceRoom( ) } + override suspend fun getAllRequests( + deviceId: String?, + startTimestamp: Long?, + endTimestamp: Long?, + ): List { + val entities = if (deviceId != null) { + floconNetworkDao.getAllRequestsByDevice(deviceId, startTimestamp, endTimestamp) + } else { + floconNetworkDao.getAllRequests(startTimestamp, endTimestamp) + } + return entities.mapNotNull { entity -> + entity.toDomainModel()?.let { domain -> + NetworkCallWithDeviceId(deviceId = entity.deviceId, call = domain) + } + } + } + override suspend fun clear() { floconNetworkDao.clearAll() } diff --git a/FloconDesktop/data/remote/src/commonMain/kotlin/com/flocon/data/remote/messages/DI.kt b/FloconDesktop/data/remote/src/commonMain/kotlin/com/flocon/data/remote/messages/DI.kt index da6549c1b..f37c4a41e 100644 --- a/FloconDesktop/data/remote/src/commonMain/kotlin/com/flocon/data/remote/messages/DI.kt +++ b/FloconDesktop/data/remote/src/commonMain/kotlin/com/flocon/data/remote/messages/DI.kt @@ -11,7 +11,8 @@ import org.koin.dsl.module internal val messagesModule = module { single { getServer( - json = get() + json = get(), + networkRepository = lazy { get() }, ) } singleOf(::MessageRemoteDataSourceImpl) bind MessageRemoteDataSource::class diff --git a/FloconDesktop/data/remote/src/commonMain/kotlin/com/flocon/data/remote/models/NetworkLogsExportModel.kt b/FloconDesktop/data/remote/src/commonMain/kotlin/com/flocon/data/remote/models/NetworkLogsExportModel.kt new file mode 100644 index 000000000..ac4c8b607 --- /dev/null +++ b/FloconDesktop/data/remote/src/commonMain/kotlin/com/flocon/data/remote/models/NetworkLogsExportModel.kt @@ -0,0 +1,41 @@ +package com.flocon.data.remote.models + +import kotlinx.serialization.Serializable + +@Serializable +internal data class NetworkLogsExportResponse( + val data: List, + val metadata: ExportMetadata, +) + +@Serializable +internal data class ExportMetadata( + val exportedAt: Long, + val totalItems: Int, + val filteredBy: FilterCriteria?, +) + +@Serializable +internal data class FilterCriteria( + val deviceId: String? = null, + val startTimestamp: Long? = null, + val endTimestamp: Long? = null, +) + +@Serializable +internal data class NetworkCallExport( + val callId: String, + val method: String, + val url: String, + val startTime: Long, + val startTimeFormatted: String, + val statusCode: Int? = null, + val durationMs: Double? = null, + val requestHeaders: Map, + val responseHeaders: Map? = null, + val requestBody: String? = null, + val responseBody: String? = null, + val contentType: String? = null, + val deviceId: String, + val appInstance: Long, +) \ No newline at end of file diff --git a/FloconDesktop/data/remote/src/commonMain/kotlin/com/flocon/data/remote/server/Server.kt b/FloconDesktop/data/remote/src/commonMain/kotlin/com/flocon/data/remote/server/Server.kt index 2164a89e1..074d46409 100644 --- a/FloconDesktop/data/remote/src/commonMain/kotlin/com/flocon/data/remote/server/Server.kt +++ b/FloconDesktop/data/remote/src/commonMain/kotlin/com/flocon/data/remote/server/Server.kt @@ -5,6 +5,7 @@ import com.flocon.data.remote.models.FloconIncomingMessageDataModel import com.flocon.data.remote.models.FloconOutgoingMessageDataModel import io.github.openflocon.domain.Constant import io.github.openflocon.domain.messages.models.FloconReceivedFileDomainModel +import io.github.openflocon.domain.network.repository.NetworkRepository import kotlinx.coroutines.flow.Flow import kotlinx.serialization.json.Json import kotlin.uuid.ExperimentalUuidApi @@ -31,4 +32,7 @@ interface Server { @OptIn(ExperimentalUuidApi::class) fun newRequestId(): String = Uuid.random().toString() -expect fun getServer(json: Json): Server +expect fun getServer( + json: Json, + networkRepository: Lazy, +): Server diff --git a/FloconDesktop/data/remote/src/desktopMain/kotlin/com/flocon/data/remote/server/NetworkExportEndpoint.kt b/FloconDesktop/data/remote/src/desktopMain/kotlin/com/flocon/data/remote/server/NetworkExportEndpoint.kt new file mode 100644 index 000000000..776aee751 --- /dev/null +++ b/FloconDesktop/data/remote/src/desktopMain/kotlin/com/flocon/data/remote/server/NetworkExportEndpoint.kt @@ -0,0 +1,125 @@ +package com.flocon.data.remote.server + +import co.touchlab.kermit.Logger +import com.flocon.data.remote.models.ExportMetadata +import com.flocon.data.remote.models.FilterCriteria +import com.flocon.data.remote.models.NetworkCallExport +import com.flocon.data.remote.models.NetworkLogsExportResponse +import io.github.openflocon.domain.network.models.FloconNetworkCallDomainModel +import io.github.openflocon.domain.network.models.NetworkCallWithDeviceId +import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode +import io.ktor.server.application.ApplicationCall +import io.ktor.server.response.respondText +import io.ktor.server.routing.Route +import io.ktor.server.routing.get +import kotlinx.serialization.builtins.MapSerializer +import kotlinx.serialization.json.Json +import kotlinx.serialization.serializer + +fun Route.networkExportRoutes( + json: Json, + // Filtering is done at DB level — timestamps passed through to the query + getNetworkCalls: suspend (deviceId: String?, startTimestamp: Long?, endTimestamp: Long?) -> List, +) { + // 1. GET /api/network-logs — all calls from all devices + get("/api/network-logs") { + val calls = getNetworkCalls(null, null, null) + call.respondJson(json, calls, deviceId = null, startTimestamp = null, endTimestamp = null) + } + + // 2. GET /api/network-logs/{deviceId} — all calls for a specific device + get("/api/network-logs/{deviceId}") { + val deviceId = call.parameters["deviceId"] + val calls = getNetworkCalls(deviceId, null, null) + call.respondJson(json, calls, deviceId = deviceId, startTimestamp = null, endTimestamp = null) + } + + // 3. GET /api/network-logs/{deviceId}/filter?startTimestamp=&endTimestamp= — device + time range + get("/api/network-logs/{deviceId}/filter") { + val deviceId = call.parameters["deviceId"] + val startTimestamp = call.request.queryParameters["startTimestamp"]?.toLongOrNull() + val endTimestamp = call.request.queryParameters["endTimestamp"]?.toLongOrNull() + + val calls = getNetworkCalls(deviceId, startTimestamp, endTimestamp) + call.respondJson(json, calls, deviceId = deviceId, startTimestamp = startTimestamp, endTimestamp = endTimestamp) + } +} + +private suspend fun ApplicationCall.respondJson( + json: Json, + calls: List, + deviceId: String?, + startTimestamp: Long?, + endTimestamp: Long?, +) { + try { + val exportedCalls = calls.map { networkCallWithDeviceId -> + val networkCall = networkCallWithDeviceId.call + val storedDeviceId = networkCallWithDeviceId.deviceId + NetworkCallExport( + callId = networkCall.callId, + method = networkCall.request.method, + url = networkCall.request.url, + startTime = networkCall.request.startTime, + startTimeFormatted = networkCall.request.startTimeFormatted, + statusCode = when (val response = networkCall.response) { + is FloconNetworkCallDomainModel.Response.Success -> + when (val info = response.specificInfos) { + is FloconNetworkCallDomainModel.Response.Success.SpecificInfos.Http -> info.httpCode + else -> null + } + else -> null + }, + durationMs = networkCall.response?.durationMs, + requestHeaders = networkCall.request.headers, + responseHeaders = when (val response = networkCall.response) { + is FloconNetworkCallDomainModel.Response.Success -> response.headers + else -> null + }, + requestBody = networkCall.request.body, + responseBody = when (val response = networkCall.response) { + is FloconNetworkCallDomainModel.Response.Success -> response.body + else -> null + }, + contentType = when (val response = networkCall.response) { + is FloconNetworkCallDomainModel.Response.Success -> response.contentType + else -> null + }, + deviceId = storedDeviceId, + appInstance = networkCall.appInstance, + ) + } + + val response = NetworkLogsExportResponse( + data = exportedCalls, + metadata = ExportMetadata( + exportedAt = System.currentTimeMillis(), + totalItems = exportedCalls.size, + filteredBy = if (deviceId != null || startTimestamp != null || endTimestamp != null) { + FilterCriteria( + deviceId = deviceId, + startTimestamp = startTimestamp, + endTimestamp = endTimestamp, + ) + } else null, + ), + ) + + respondText( + json.encodeToString(NetworkLogsExportResponse.serializer(), response), + ContentType.Application.Json, + HttpStatusCode.OK, + ) + } catch (e: Exception) { + Logger.e("Error exporting network logs", e) + respondText( + json.encodeToString( + MapSerializer(serializer(), serializer()), + mapOf("error" to (e.message ?: "Unknown error")), + ), + ContentType.Application.Json, + HttpStatusCode.InternalServerError, + ) + } +} diff --git a/FloconDesktop/data/remote/src/desktopMain/kotlin/com/flocon/data/remote/server/Server.desktop.kt b/FloconDesktop/data/remote/src/desktopMain/kotlin/com/flocon/data/remote/server/Server.desktop.kt index e092fdd03..ca9649e40 100644 --- a/FloconDesktop/data/remote/src/desktopMain/kotlin/com/flocon/data/remote/server/Server.desktop.kt +++ b/FloconDesktop/data/remote/src/desktopMain/kotlin/com/flocon/data/remote/server/Server.desktop.kt @@ -1,5 +1,9 @@ package com.flocon.data.remote.server +import io.github.openflocon.domain.network.repository.NetworkRepository import kotlinx.serialization.json.Json -actual fun getServer(json: Json): Server = ServerJvm(json) +actual fun getServer( + json: Json, + networkRepository: Lazy, +): Server = ServerJvm(json, networkRepository) diff --git a/FloconDesktop/data/remote/src/desktopMain/kotlin/com/flocon/data/remote/server/ServerJvm.kt b/FloconDesktop/data/remote/src/desktopMain/kotlin/com/flocon/data/remote/server/ServerJvm.kt index ac443e2c4..f6301bc42 100644 --- a/FloconDesktop/data/remote/src/desktopMain/kotlin/com/flocon/data/remote/server/ServerJvm.kt +++ b/FloconDesktop/data/remote/src/desktopMain/kotlin/com/flocon/data/remote/server/ServerJvm.kt @@ -5,6 +5,7 @@ import com.flocon.data.remote.models.FloconDeviceIdAndPackageNameDataModel import com.flocon.data.remote.models.FloconIncomingMessageDataModel import com.flocon.data.remote.models.FloconOutgoingMessageDataModel import io.github.openflocon.domain.messages.models.FloconReceivedFileDomainModel +import io.github.openflocon.domain.network.repository.NetworkRepository import io.ktor.http.content.PartData import io.ktor.http.content.forEachPart import io.ktor.http.content.streamProvider @@ -41,6 +42,7 @@ import kotlin.time.Duration.Companion.seconds class ServerJvm( private val json: Json, + private val networkRepository: Lazy, ) : Server { private val _receivedMessages = Channel() override val receivedMessages = _receivedMessages.receiveAsFlow() @@ -251,6 +253,14 @@ class ServerJvm( call.respondText("file received : ${savedFile?.absolutePath ?: "inconnu"}") } + + // Add network export routes + networkExportRoutes( + json = json, + getNetworkCalls = { deviceId, startTimestamp, endTimestamp -> + networkRepository.value.getAllNetworkCalls(deviceId, startTimestamp, endTimestamp) + } + ) } }.start(wait = false) diff --git a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/network/models/NetworkCallWithDeviceId.kt b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/network/models/NetworkCallWithDeviceId.kt new file mode 100644 index 000000000..600d9edaf --- /dev/null +++ b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/network/models/NetworkCallWithDeviceId.kt @@ -0,0 +1,6 @@ +package io.github.openflocon.domain.network.models + +data class NetworkCallWithDeviceId( + val deviceId: String, + val call: FloconNetworkCallDomainModel, +) 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 56e7dc8e2..58c59d6c7 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 @@ -3,6 +3,7 @@ package io.github.openflocon.domain.network.repository import androidx.paging.PagingData import io.github.openflocon.domain.device.models.DeviceIdAndPackageNameDomainModel import io.github.openflocon.domain.network.models.FloconNetworkCallDomainModel +import io.github.openflocon.domain.network.models.NetworkCallWithDeviceId import io.github.openflocon.domain.network.models.NetworkFilterDomainModel import io.github.openflocon.domain.network.models.NetworkSortDomainModel import kotlinx.coroutines.flow.Flow @@ -53,6 +54,12 @@ interface NetworkRepository { deviceIdAndPackageName: DeviceIdAndPackageNameDomainModel ) + suspend fun getAllNetworkCalls( + deviceId: String? = null, + startTimestamp: Long? = null, + endTimestamp: Long? = null, + ): List + suspend fun replayRequest( deviceIdAndPackageName: DeviceIdAndPackageNameDomainModel, request: FloconNetworkCallDomainModel diff --git a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/network/usecase/ExportNetworkLogsAsJsonUseCase.kt b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/network/usecase/ExportNetworkLogsAsJsonUseCase.kt new file mode 100644 index 000000000..7ebe79149 --- /dev/null +++ b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/network/usecase/ExportNetworkLogsAsJsonUseCase.kt @@ -0,0 +1,49 @@ +package io.github.openflocon.domain.network.usecase + +import io.github.openflocon.domain.common.Either +import io.github.openflocon.domain.common.Failure +import io.github.openflocon.domain.common.Success +import io.github.openflocon.domain.device.models.DeviceIdAndPackageNameDomainModel +import io.github.openflocon.domain.network.models.FloconNetworkCallDomainModel +import io.github.openflocon.domain.network.models.NetworkFilterDomainModel +import io.github.openflocon.domain.network.repository.NetworkRepository + +class ExportNetworkLogsAsJsonUseCase internal constructor( + private val networkRepository: NetworkRepository, +) { + suspend operator fun invoke( + deviceIdAndPackageName: DeviceIdAndPackageNameDomainModel, + filterText: String? = null, + startTimestamp: Long? = null, + endTimestamp: Long? = null, + ): Either> { + return try { + val filter = NetworkFilterDomainModel( + filterOnAllColumns = filterText, + textsFilters = null, + methodFilter = null, + displayOldSessions = true, + ) + val calls = networkRepository.getRequests( + deviceIdAndPackageName = deviceIdAndPackageName, + sortedBy = null, + filter = filter, + ) + + val filtered = calls.filter { call -> + val matchesStartTime = startTimestamp == null || call.request.startTime >= startTimestamp + val matchesEndTime = endTimestamp == null || call.request.startTime <= endTimestamp + + matchesStartTime && matchesEndTime + } + + if (filtered.isEmpty()) { + return Failure(Throwable("No network logs found matching the criteria")) + } + + Success(filtered) + } catch (e: Exception) { + Failure(e) + } + } +}