Feat grpc into network (#21)

* feat: [NETWORK] merge with grpc

* formatted

* formatted

* formatted

---------

Co-authored-by: Florent Champigny <florent@bere.al>
This commit is contained in:
Florent CHAMPIGNY 2025-08-02 16:33:24 +02:00 committed by GitHub
parent 705668c15b
commit 66920edcd4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
78 changed files with 404 additions and 2157 deletions

View file

@ -1,4 +1,3 @@
import com.android.build.gradle.ProguardFiles.getDefaultProguardFile
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
import org.jetbrains.kotlin.gradle.dsl.KotlinVersion

View file

@ -18,9 +18,6 @@ import io.github.openflocon.flocondesktop.features.deeplinks.data.datasource.roo
import io.github.openflocon.flocondesktop.features.deeplinks.data.datasource.room.model.DeeplinkEntity
import io.github.openflocon.flocondesktop.features.files.data.datasources.FloconFileDao
import io.github.openflocon.flocondesktop.features.files.data.datasources.model.FileEntity
import io.github.openflocon.flocondesktop.features.grpc.data.datasource.room.GrpcDao
import io.github.openflocon.flocondesktop.features.grpc.data.datasource.room.model.GrpcCallEntity
import io.github.openflocon.flocondesktop.features.grpc.data.datasource.room.model.GrpcResponseEntity
import io.github.openflocon.flocondesktop.features.images.data.datasources.local.FloconImageDao
import io.github.openflocon.flocondesktop.features.images.data.datasources.local.model.DeviceImageEntity
import io.github.openflocon.flocondesktop.features.network.data.datasource.local.FloconHttpRequestDao
@ -31,7 +28,7 @@ import io.github.openflocon.flocondesktop.features.table.data.datasource.local.m
import kotlinx.coroutines.Dispatchers
@Database(
version = 24,
version = 27,
entities = [
FloconHttpRequestEntity::class,
FileEntity::class,
@ -43,8 +40,6 @@ import kotlinx.coroutines.Dispatchers
DeviceImageEntity::class,
SuccessQueryEntity::class,
DeeplinkEntity::class,
GrpcCallEntity::class,
GrpcResponseEntity::class,
AnalyticsItemEntity::class,
],
)
@ -60,7 +55,6 @@ abstract class AppDatabase : RoomDatabase() {
abstract val imageDao: FloconImageDao
abstract val queryDao: QueryDao
abstract val deeplinkDao: FloconDeeplinkDao
abstract val grpcDao: GrpcDao
abstract val analyticsDao: FloconAnalyticsDao
}

View file

@ -28,9 +28,6 @@ val roomModule =
single {
get<AppDatabase>().deeplinkDao
}
single {
get<AppDatabase>().grpcDao
}
single {
get<AppDatabase>().analyticsDao
}

View file

@ -5,7 +5,6 @@ import io.github.openflocon.flocondesktop.features.dashboard.di.dashboardModule
import io.github.openflocon.flocondesktop.features.database.di.databaseModule
import io.github.openflocon.flocondesktop.features.deeplinks.di.deeplinkModule
import io.github.openflocon.flocondesktop.features.files.di.filesModule
import io.github.openflocon.flocondesktop.features.grpc.di.grpcModule
import io.github.openflocon.flocondesktop.features.images.di.imagesModule
import io.github.openflocon.flocondesktop.features.network.di.networkModule
import io.github.openflocon.flocondesktop.features.sharedpreferences.di.sharedPreferencesModule
@ -19,7 +18,6 @@ val featuresModule =
analyticsModule,
databaseModule,
filesModule,
grpcModule,
imagesModule,
messagesModule,
networkModule,

View file

@ -1,99 +0,0 @@
package io.github.openflocon.flocondesktop.features.grpc.data
import io.github.openflocon.flocondesktop.DeviceId
import io.github.openflocon.flocondesktop.FloconIncomingMessageDataModel
import io.github.openflocon.flocondesktop.Protocol
import io.github.openflocon.flocondesktop.common.coroutines.dispatcherprovider.DispatcherProvider
import io.github.openflocon.flocondesktop.features.grpc.data.datasource.LocalGrpcDataSource
import io.github.openflocon.flocondesktop.features.grpc.data.model.fromdevice.GrpcRequestDataModel
import io.github.openflocon.flocondesktop.features.grpc.data.model.fromdevice.GrpcResponseDataModel
import io.github.openflocon.flocondesktop.features.grpc.domain.model.GrpcCallDomainModel
import io.github.openflocon.flocondesktop.features.grpc.domain.model.GrpcCallId
import io.github.openflocon.flocondesktop.features.grpc.domain.repository.GRPCRepository
import io.github.openflocon.flocondesktop.messages.domain.repository.sub.MessagesReceiverRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
class GRPCRepositoryImpl(
private val dispatcherProvider: DispatcherProvider,
private val localGrpcDataSource: LocalGrpcDataSource,
) : GRPCRepository,
MessagesReceiverRepository {
private val grpcParser =
Json {
ignoreUnknownKeys = true
}
override val pluginName = listOf(Protocol.FromDevice.GRPC.Plugin)
override suspend fun onMessageReceived(
deviceId: String,
message: FloconIncomingMessageDataModel,
) {
withContext(dispatcherProvider.data) {
when (message.method) {
Protocol.FromDevice.GRPC.Method.LogNetworkRequest -> decodeGrpcRequest(message)?.let {
toDomain(it)
}?.let {
localGrpcDataSource.saveRequest(
deviceId = deviceId,
callId = it.callId,
request = it.request,
)
}
Protocol.FromDevice.GRPC.Method.LogNetworkResponse -> decodeGrpcResponse(message)?.let {
toDomain(
it,
)
}?.let {
localGrpcDataSource.saveResponse(
deviceId = deviceId,
callId = it.callId,
response = it.response,
)
}
}
}
}
private fun decodeGrpcRequest(message: FloconIncomingMessageDataModel): GrpcRequestDataModel? = try {
grpcParser.decodeFromString<GrpcRequestDataModel>(message.body)
} catch (t: Throwable) {
t.printStackTrace()
null
}
private fun decodeGrpcResponse(message: FloconIncomingMessageDataModel): GrpcResponseDataModel? = try {
grpcParser.decodeFromString<GrpcResponseDataModel>(message.body)
} catch (t: Throwable) {
t.printStackTrace()
null
}
override fun observeCalls(deviceId: DeviceId): Flow<List<GrpcCallDomainModel>> = localGrpcDataSource.observeCalls(
deviceId = deviceId,
).flowOn(dispatcherProvider.data)
override fun observeCall(
currentDeviceId: DeviceId,
callId: GrpcCallId,
): Flow<GrpcCallDomainModel?> = localGrpcDataSource.observeCall(
deviceId = currentDeviceId,
callId = callId,
).flowOn(dispatcherProvider.data)
override suspend fun deleteCall(deviceId: DeviceId, callId: GrpcCallId) = withContext(dispatcherProvider.data) {
localGrpcDataSource.deleteCall(deviceId = deviceId, callId = callId)
}
override suspend fun deleteCallsBefore(deviceId: DeviceId, callId: GrpcCallId) = withContext(dispatcherProvider.data) {
localGrpcDataSource.deleteCallsBefore(deviceId = deviceId, callId = callId)
}
override suspend fun deleteCallsForDevice(deviceId: DeviceId) = withContext(dispatcherProvider.data) {
localGrpcDataSource.clearDeviceCalls(deviceId = deviceId)
}
}

View file

@ -1,21 +0,0 @@
package io.github.openflocon.flocondesktop.features.grpc.data.datasource
import io.github.openflocon.flocondesktop.DeviceId
import io.github.openflocon.flocondesktop.features.grpc.domain.model.GrpcCallDomainModel
import io.github.openflocon.flocondesktop.features.grpc.domain.model.GrpcCallId
import io.github.openflocon.flocondesktop.features.grpc.domain.model.GrpcRequestDomainModel
import io.github.openflocon.flocondesktop.features.grpc.domain.model.GrpcResponseDomainModel
import kotlinx.coroutines.flow.Flow
interface LocalGrpcDataSource {
suspend fun saveRequest(deviceId: DeviceId, callId: GrpcCallId, request: GrpcRequestDomainModel)
suspend fun saveResponse(deviceId: DeviceId, callId: GrpcCallId, response: GrpcResponseDomainModel)
fun observeCalls(deviceId: DeviceId): Flow<List<GrpcCallDomainModel>>
fun observeCall(deviceId: DeviceId, callId: GrpcCallId): Flow<GrpcCallDomainModel?>
suspend fun clearDeviceCalls(deviceId: DeviceId)
suspend fun deleteCall(deviceId: DeviceId, callId: GrpcCallId)
suspend fun deleteCallsBefore(deviceId: DeviceId, callId: GrpcCallId)
suspend fun clear()
}

View file

@ -1,110 +0,0 @@
package io.github.openflocon.flocondesktop.features.grpc.data.datasource.room
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Transaction
import io.github.openflocon.flocondesktop.features.grpc.data.datasource.room.model.GrpcCallEntity
import io.github.openflocon.flocondesktop.features.grpc.data.datasource.room.model.GrpcCallWithDetails
import io.github.openflocon.flocondesktop.features.grpc.data.datasource.room.model.GrpcResponseEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface GrpcDao {
@Insert
suspend fun insertGrpcCall(call: GrpcCallEntity)
@Insert
suspend fun insertGrpcResponse(response: GrpcResponseEntity)
@Transaction
@Query(
"""
SELECT * FROM GrpcCallEntity
WHERE deviceId = :deviceId
ORDER BY timestamp ASC
""",
)
fun observeCallsWithDetails(deviceId: String): Flow<List<GrpcCallWithDetails>>
@Transaction
@Query(
"""
SELECT * FROM GrpcCallEntity
WHERE deviceId = :deviceId
AND callId = :callId
LIMIT 1
""",
)
fun observeCallWithDetails(deviceId: String, callId: String): Flow<GrpcCallWithDetails?>
@Query("DELETE FROM GrpcCallEntity WHERE deviceId = :deviceId")
suspend fun clearDeviceRequests(deviceId: String)
@Query(
"""
DELETE FROM GrpcResponseEntity
WHERE response_call_id IN (
SELECT callId
FROM GrpcCallEntity
WHERE deviceId = :deviceId
)
""",
)
suspend fun clearDeviceResponses(deviceId: String)
@Transaction
suspend fun clearDeviceData(deviceId: String) {
clearDeviceResponses(deviceId)
clearDeviceRequests(deviceId)
}
@Query("DELETE FROM GrpcCallEntity WHERE callId = :requestId")
suspend fun deleteRequestById(requestId: String)
@Query("DELETE FROM GrpcResponseEntity WHERE response_call_id = :requestId")
suspend fun deleteResponseById(requestId: String)
@Transaction
suspend fun deleteCallById(requestId: String) {
deleteResponseById(requestId)
deleteRequestById(requestId)
}
@Transaction
suspend fun deleteCallsBeforeTimestamp(deviceId: String, timestamp: Long) {
// First, get call IDs of requests to be deleted
val callIdsToDelete = getCallIdsBeforeTimestamp(deviceId, timestamp)
// Delete associated headers and responses
for (callId in callIdsToDelete) {
deleteCallById(callId)
}
}
@Query("SELECT callId FROM GrpcCallEntity WHERE deviceId = :deviceId AND timestamp < :timestamp")
suspend fun getCallIdsBeforeTimestamp(deviceId: String, timestamp: Long): List<String>
@Query("DELETE FROM GrpcCallEntity")
suspend fun deleteAllRequests()
@Query("DELETE FROM GrpcResponseEntity")
suspend fun deleteAllResponses()
@Transaction
suspend fun clearAllData() {
deleteAllResponses()
deleteAllRequests()
}
@Query(
"""
SELECT timestamp
FROM GrpcCallEntity
WHERE deviceId = :deviceId
AND callId = :callId
LIMIT 1
""",
)
suspend fun getCallTimestamp(deviceId: String, callId: String): Long?
}

View file

@ -1,88 +0,0 @@
package io.github.openflocon.flocondesktop.features.grpc.data.datasource.room
import io.github.openflocon.flocondesktop.DeviceId
import io.github.openflocon.flocondesktop.features.grpc.data.datasource.LocalGrpcDataSource
import io.github.openflocon.flocondesktop.features.grpc.data.datasource.room.model.GrpcCallEntity
import io.github.openflocon.flocondesktop.features.grpc.data.datasource.room.model.GrpcResponseEntity
import io.github.openflocon.flocondesktop.features.grpc.domain.model.GrpcCallDomainModel
import io.github.openflocon.flocondesktop.features.grpc.domain.model.GrpcCallId
import io.github.openflocon.flocondesktop.features.grpc.domain.model.GrpcRequestDomainModel
import io.github.openflocon.flocondesktop.features.grpc.domain.model.GrpcResponseDomainModel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class LocalGrpcDataSourceImpl(
private val grpcDao: GrpcDao,
) : LocalGrpcDataSource {
override suspend fun saveRequest(
deviceId: String,
callId: String,
request: GrpcRequestDomainModel,
) {
val headersMap = request.headers.associate { it.key to it.value }
val requestEntity = GrpcCallEntity(
callId = callId,
deviceId = deviceId,
request = GrpcCallEntity.Request(
timestamp = request.timestamp,
authority = request.authority,
method = request.method,
data = request.data,
headers = headersMap,
),
)
grpcDao.insertGrpcCall(requestEntity)
}
override suspend fun saveResponse(
deviceId: String,
callId: String,
response: GrpcResponseDomainModel,
) {
val headersMap = response.headers.associate { it.key to it.value }
val responseEntity = GrpcResponseEntity(
callId = callId,
responseTimestamp = response.timestamp,
status = response.status,
resultType = when (response.result) {
is GrpcResponseDomainModel.CallResult.Success -> "success"
is GrpcResponseDomainModel.CallResult.Error -> "error"
},
resultData = when (response.result) {
is GrpcResponseDomainModel.CallResult.Success -> response.result.data
is GrpcResponseDomainModel.CallResult.Error -> response.result.cause
},
headers = headersMap,
)
grpcDao.insertGrpcResponse(responseEntity)
}
override fun observeCalls(deviceId: String): Flow<List<GrpcCallDomainModel>> = grpcDao.observeCallsWithDetails(deviceId).map { entities ->
entities.map { it.toDomainModel() }
}
override fun observeCall(deviceId: DeviceId, callId: GrpcCallId) = grpcDao.observeCallWithDetails(deviceId, callId = callId).map {
it?.toDomainModel()
}
override suspend fun clearDeviceCalls(deviceId: String) {
grpcDao.clearDeviceData(deviceId)
}
override suspend fun deleteCall(deviceId: String, callId: GrpcCallId) {
grpcDao.deleteCallById(callId)
}
override suspend fun deleteCallsBefore(deviceId: String, callId: GrpcCallId) {
val timestamp = grpcDao.getCallTimestamp(deviceId = deviceId, callId = callId) ?: return
grpcDao.deleteCallsBeforeTimestamp(
deviceId = deviceId,
timestamp = timestamp,
)
}
override suspend fun clear() {
grpcDao.clearAllData()
}
}

View file

@ -1,49 +0,0 @@
package io.github.openflocon.flocondesktop.features.grpc.data.datasource.room
import io.github.openflocon.flocondesktop.features.grpc.data.datasource.room.model.GrpcCallWithDetails
import io.github.openflocon.flocondesktop.features.grpc.domain.model.GrpcCallDomainModel
import io.github.openflocon.flocondesktop.features.grpc.domain.model.GrpcHeaderDomainModel
import io.github.openflocon.flocondesktop.features.grpc.domain.model.GrpcRequestDomainModel
import io.github.openflocon.flocondesktop.features.grpc.domain.model.GrpcResponseDomainModel
fun GrpcCallWithDetails.toDomainModel(): GrpcCallDomainModel {
val requestDomain = with(call.request) {
GrpcRequestDomainModel(
timestamp = timestamp,
authority = authority,
method = method,
headers = headers
.map { GrpcHeaderDomainModel(key = it.key, value = it.value) },
data = data,
)
}
val responseDomain = response?.let {
val callResult = when (it.resultType) {
"success" -> GrpcResponseDomainModel.CallResult.Success(it.resultData ?: "")
"error" -> GrpcResponseDomainModel.CallResult.Error(
it.resultData ?: "Unknown error",
)
else -> error("Unknown result type")
}
GrpcResponseDomainModel(
timestamp = it.responseTimestamp,
status = it.status,
headers = response.headers
.map { header ->
GrpcHeaderDomainModel(
key = header.key,
value = header.value,
)
},
result = callResult,
)
}
return GrpcCallDomainModel(
id = call.callId,
request = requestDomain,
response = responseDomain,
)
}

View file

@ -1,26 +0,0 @@
package io.github.openflocon.flocondesktop.features.grpc.data.datasource.room.model
import androidx.room.Embedded
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
indices = [
Index(value = ["deviceId"]),
Index(value = ["callId"], unique = true),
],
)
data class GrpcCallEntity(
@PrimaryKey val callId: String, // GrpcCallId will be the primary key here
val deviceId: String,
@Embedded val request: GrpcCallEntity.Request,
) {
data class Request(
val timestamp: Long,
val authority: String,
val method: String,
val data: String?,
val headers: Map<String, String>,
)
}

View file

@ -1,13 +0,0 @@
package io.github.openflocon.flocondesktop.features.grpc.data.datasource.room.model
import androidx.room.Embedded
import androidx.room.Relation
data class GrpcCallWithDetails(
@Embedded val call: GrpcCallEntity,
@Relation(
parentColumn = "callId",
entityColumn = "response_call_id",
)
val response: GrpcResponseEntity?, // Response can be null if not yet received
)

View file

@ -1,15 +0,0 @@
package io.github.openflocon.flocondesktop.features.grpc.data.datasource.room.model
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity
data class GrpcResponseEntity(
@PrimaryKey @ColumnInfo(name = "response_call_id") val callId: String, // Linked to GrpcCallEntity's callId
val responseTimestamp: Long,
val status: String,
val resultType: String, // "success" or "error"
val resultData: String?, // Data for success, cause for error
val headers: Map<String, String>,
)

View file

@ -1,22 +0,0 @@
package io.github.openflocon.flocondesktop.features.grpc.data.di
import io.github.openflocon.flocondesktop.features.grpc.data.GRPCRepositoryImpl
import io.github.openflocon.flocondesktop.features.grpc.data.datasource.LocalGrpcDataSource
import io.github.openflocon.flocondesktop.features.grpc.data.datasource.room.LocalGrpcDataSourceImpl
import io.github.openflocon.flocondesktop.features.grpc.domain.repository.GRPCRepository
import io.github.openflocon.flocondesktop.messages.domain.repository.sub.MessagesReceiverRepository
import org.koin.core.module.dsl.bind
import org.koin.core.module.dsl.factoryOf
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module
val grpcDataModule =
module {
factoryOf(::GRPCRepositoryImpl) {
bind<GRPCRepository>()
bind<MessagesReceiverRepository>()
}
singleOf(::LocalGrpcDataSourceImpl) {
bind<LocalGrpcDataSource>()
}
}

View file

@ -1,50 +0,0 @@
package io.github.openflocon.flocondesktop.features.grpc.data
import io.github.openflocon.flocondesktop.features.grpc.data.model.GrpcRequestDomainModelWrapper
import io.github.openflocon.flocondesktop.features.grpc.data.model.GrpcResponseDomainModelWrapper
import io.github.openflocon.flocondesktop.features.grpc.data.model.fromdevice.GrpcHeaderDataModel
import io.github.openflocon.flocondesktop.features.grpc.data.model.fromdevice.GrpcRequestDataModel
import io.github.openflocon.flocondesktop.features.grpc.data.model.fromdevice.GrpcResponseDataModel
import io.github.openflocon.flocondesktop.features.grpc.domain.model.GrpcHeaderDomainModel
import io.github.openflocon.flocondesktop.features.grpc.domain.model.GrpcRequestDomainModel
import io.github.openflocon.flocondesktop.features.grpc.domain.model.GrpcResponseDomainModel
fun toDomain(dataModel: GrpcRequestDataModel): GrpcRequestDomainModelWrapper? = GrpcRequestDomainModelWrapper(
callId = dataModel.id,
request = GrpcRequestDomainModel(
timestamp = dataModel.timestamp,
authority = dataModel.authority,
method = dataModel.method,
headers = dataModel.headers.map {
toDomain(it)
},
data = dataModel.data,
),
)
fun toDomain(dataModel: GrpcResponseDataModel): GrpcResponseDomainModelWrapper? {
return GrpcResponseDomainModelWrapper(
callId = dataModel.id,
response = GrpcResponseDomainModel(
timestamp = dataModel.timestamp,
status = dataModel.status,
headers = dataModel.headers.map {
toDomain(it)
},
result = when {
dataModel.data != null -> GrpcResponseDomainModel.CallResult.Success(
data = dataModel.data,
)
dataModel.cause != null -> GrpcResponseDomainModel.CallResult.Error(
cause = dataModel.cause,
)
else -> return null
},
),
)
}
private fun toDomain(model: GrpcHeaderDataModel): GrpcHeaderDomainModel = GrpcHeaderDomainModel(
key = model.key,
value = model.value,
)

View file

@ -1,9 +0,0 @@
package io.github.openflocon.flocondesktop.features.grpc.data.model
import io.github.openflocon.flocondesktop.features.grpc.domain.model.GrpcCallId
import io.github.openflocon.flocondesktop.features.grpc.domain.model.GrpcRequestDomainModel
data class GrpcRequestDomainModelWrapper(
val callId: GrpcCallId,
val request: GrpcRequestDomainModel,
)

View file

@ -1,9 +0,0 @@
package io.github.openflocon.flocondesktop.features.grpc.data.model
import io.github.openflocon.flocondesktop.features.grpc.domain.model.GrpcCallId
import io.github.openflocon.flocondesktop.features.grpc.domain.model.GrpcResponseDomainModel
data class GrpcResponseDomainModelWrapper(
val callId: GrpcCallId,
val response: GrpcResponseDomainModel,
)

View file

@ -1,9 +0,0 @@
package io.github.openflocon.flocondesktop.features.grpc.data.model.fromdevice
import kotlinx.serialization.Serializable
@Serializable
data class GrpcHeaderDataModel(
val key: String,
val value: String,
)

View file

@ -1,13 +0,0 @@
package io.github.openflocon.flocondesktop.features.grpc.data.model.fromdevice
import kotlinx.serialization.Serializable
@Serializable
data class GrpcRequestDataModel(
val id: String,
val timestamp: Long,
val authority: String,
val method: String,
val headers: List<GrpcHeaderDataModel>,
val data: String? = null,
)

View file

@ -1,13 +0,0 @@
package io.github.openflocon.flocondesktop.features.grpc.data.model.fromdevice
import kotlinx.serialization.Serializable
@Serializable
data class GrpcResponseDataModel(
val id: String,
val timestamp: Long,
val status: String,
val cause: String? = null,
val headers: List<GrpcHeaderDataModel>,
val data: String? = null,
)

View file

@ -1,15 +0,0 @@
package io.github.openflocon.flocondesktop.features.grpc.di
import io.github.openflocon.flocondesktop.features.grpc.data.di.grpcDataModule
import io.github.openflocon.flocondesktop.features.grpc.domain.di.grpcDomainModule
import io.github.openflocon.flocondesktop.features.grpc.ui.di.grpcUiModule
import org.koin.dsl.module
val grpcModule =
module {
includes(
grpcDataModule,
grpcDomainModule,
grpcUiModule,
)
}

View file

@ -1,15 +0,0 @@
package io.github.openflocon.flocondesktop.features.grpc.domain
import io.github.openflocon.flocondesktop.core.domain.device.GetCurrentDeviceIdUseCase
import io.github.openflocon.flocondesktop.features.grpc.domain.model.GrpcCallId
import io.github.openflocon.flocondesktop.features.grpc.domain.repository.GRPCRepository
class DeleteGrpcCallBeforeUseCase(
private val grpcRepository: GRPCRepository,
private val getCurrentDeviceIdUseCase: GetCurrentDeviceIdUseCase,
) {
suspend operator fun invoke(callId: GrpcCallId) {
val deviceId = getCurrentDeviceIdUseCase() ?: return
grpcRepository.deleteCallsBefore(deviceId = deviceId, callId = callId)
}
}

View file

@ -1,15 +0,0 @@
package io.github.openflocon.flocondesktop.features.grpc.domain
import io.github.openflocon.flocondesktop.core.domain.device.GetCurrentDeviceIdUseCase
import io.github.openflocon.flocondesktop.features.grpc.domain.model.GrpcCallId
import io.github.openflocon.flocondesktop.features.grpc.domain.repository.GRPCRepository
class DeleteGrpcCallUseCase(
private val grpcRepository: GRPCRepository,
private val getCurrentDeviceIdUseCase: GetCurrentDeviceIdUseCase,
) {
suspend operator fun invoke(callId: GrpcCallId) {
val deviceId = getCurrentDeviceIdUseCase() ?: return
grpcRepository.deleteCall(deviceId, callId)
}
}

View file

@ -1,22 +0,0 @@
package io.github.openflocon.flocondesktop.features.grpc.domain
import io.github.openflocon.flocondesktop.core.domain.device.ObserveCurrentDeviceIdUseCase
import io.github.openflocon.flocondesktop.features.grpc.domain.model.GrpcCallDomainModel
import io.github.openflocon.flocondesktop.features.grpc.domain.model.GrpcCallId
import io.github.openflocon.flocondesktop.features.grpc.domain.repository.GRPCRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
class ObserveGrpcCallByIdUseCase(
private val grpcRepository: GRPCRepository,
private val observeCurrentDeviceIdUseCase: ObserveCurrentDeviceIdUseCase,
) {
operator fun invoke(callId: GrpcCallId): Flow<GrpcCallDomainModel?> = observeCurrentDeviceIdUseCase().flatMapLatest { currentDeviceId ->
if (currentDeviceId == null) {
flowOf(null)
} else {
grpcRepository.observeCall(currentDeviceId, callId = callId)
}
}
}

View file

@ -1,21 +0,0 @@
package io.github.openflocon.flocondesktop.features.grpc.domain
import io.github.openflocon.flocondesktop.core.domain.device.ObserveCurrentDeviceIdUseCase
import io.github.openflocon.flocondesktop.features.grpc.domain.model.GrpcCallDomainModel
import io.github.openflocon.flocondesktop.features.grpc.domain.repository.GRPCRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
class ObserveGrpcCallsUseCase(
private val grpcRepository: GRPCRepository,
private val observeCurrentDeviceIdUseCase: ObserveCurrentDeviceIdUseCase,
) {
operator fun invoke(): Flow<List<GrpcCallDomainModel>> = observeCurrentDeviceIdUseCase().flatMapLatest { currentDeviceId ->
if (currentDeviceId == null) {
flowOf(emptyList())
} else {
grpcRepository.observeCalls(currentDeviceId)
}
}
}

View file

@ -1,14 +0,0 @@
package io.github.openflocon.flocondesktop.features.grpc.domain
import io.github.openflocon.flocondesktop.core.domain.device.GetCurrentDeviceIdUseCase
import io.github.openflocon.flocondesktop.features.grpc.domain.repository.GRPCRepository
class ResetCurrentDeviceGrpcCallsUseCase(
private val grpcRepository: GRPCRepository,
private val getCurrentDeviceIdUseCase: GetCurrentDeviceIdUseCase,
) {
suspend operator fun invoke() {
val deviceId = getCurrentDeviceIdUseCase() ?: return
grpcRepository.deleteCallsForDevice(deviceId = deviceId)
}
}

View file

@ -1,18 +0,0 @@
package io.github.openflocon.flocondesktop.features.grpc.domain.di
import io.github.openflocon.flocondesktop.features.grpc.domain.DeleteGrpcCallBeforeUseCase
import io.github.openflocon.flocondesktop.features.grpc.domain.DeleteGrpcCallUseCase
import io.github.openflocon.flocondesktop.features.grpc.domain.ObserveGrpcCallByIdUseCase
import io.github.openflocon.flocondesktop.features.grpc.domain.ObserveGrpcCallsUseCase
import io.github.openflocon.flocondesktop.features.grpc.domain.ResetCurrentDeviceGrpcCallsUseCase
import org.koin.core.module.dsl.factoryOf
import org.koin.dsl.module
val grpcDomainModule =
module {
factoryOf(::ObserveGrpcCallsUseCase)
factoryOf(::ObserveGrpcCallByIdUseCase)
factoryOf(::DeleteGrpcCallUseCase)
factoryOf(::DeleteGrpcCallBeforeUseCase)
factoryOf(::ResetCurrentDeviceGrpcCallsUseCase)
}

View file

@ -1,7 +0,0 @@
package io.github.openflocon.flocondesktop.features.grpc.domain.model
data class GrpcCallDomainModel(
val id: String,
val request: GrpcRequestDomainModel,
val response: GrpcResponseDomainModel?,
)

View file

@ -1,3 +0,0 @@
package io.github.openflocon.flocondesktop.features.grpc.domain.model
typealias GrpcCallId = String

View file

@ -1,6 +0,0 @@
package io.github.openflocon.flocondesktop.features.grpc.domain.model
data class GrpcHeaderDomainModel(
val key: String,
val value: String,
)

View file

@ -1,9 +0,0 @@
package io.github.openflocon.flocondesktop.features.grpc.domain.model
data class GrpcRequestDomainModel(
val timestamp: Long,
val authority: String,
val method: String,
val headers: List<GrpcHeaderDomainModel>,
val data: String?,
)

View file

@ -1,18 +0,0 @@
package io.github.openflocon.flocondesktop.features.grpc.domain.model
data class GrpcResponseDomainModel(
val timestamp: Long,
val status: String,
val headers: List<GrpcHeaderDomainModel>,
val result: CallResult,
) {
sealed interface CallResult {
data class Success(
val data: String,
) : CallResult
data class Error(
val cause: String,
) : CallResult
}
}

View file

@ -1,15 +0,0 @@
package io.github.openflocon.flocondesktop.features.grpc.domain.repository
import io.github.openflocon.flocondesktop.DeviceId
import io.github.openflocon.flocondesktop.features.grpc.domain.model.GrpcCallDomainModel
import io.github.openflocon.flocondesktop.features.grpc.domain.model.GrpcCallId
import kotlinx.coroutines.flow.Flow
interface GRPCRepository {
fun observeCalls(deviceId: DeviceId): Flow<List<GrpcCallDomainModel>>
fun observeCall(currentDeviceId: DeviceId, callId: GrpcCallId): Flow<GrpcCallDomainModel?>
suspend fun deleteCall(deviceId: DeviceId, callId: GrpcCallId)
suspend fun deleteCallsBefore(deviceId: DeviceId, callId: GrpcCallId)
suspend fun deleteCallsForDevice(deviceId: DeviceId)
}

View file

@ -1,119 +0,0 @@
package io.github.openflocon.flocondesktop.features.grpc.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import io.github.openflocon.flocondesktop.common.coroutines.dispatcherprovider.DispatcherProvider
import io.github.openflocon.flocondesktop.common.ui.feedback.FeedbackDisplayer
import io.github.openflocon.flocondesktop.copyToClipboard
import io.github.openflocon.flocondesktop.features.grpc.domain.DeleteGrpcCallBeforeUseCase
import io.github.openflocon.flocondesktop.features.grpc.domain.DeleteGrpcCallUseCase
import io.github.openflocon.flocondesktop.features.grpc.domain.ObserveGrpcCallByIdUseCase
import io.github.openflocon.flocondesktop.features.grpc.domain.ObserveGrpcCallsUseCase
import io.github.openflocon.flocondesktop.features.grpc.domain.ResetCurrentDeviceGrpcCallsUseCase
import io.github.openflocon.flocondesktop.features.grpc.ui.mapper.toDetailUi
import io.github.openflocon.flocondesktop.features.grpc.ui.mapper.toUi
import io.github.openflocon.flocondesktop.features.grpc.ui.model.GrpcDetailViewState
import io.github.openflocon.flocondesktop.features.grpc.ui.model.GrpcItemViewState
import io.github.openflocon.flocondesktop.features.grpc.ui.model.OnGrpcItemUserAction
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class GRPCViewModel(
private val dispatcherProvider: DispatcherProvider,
private val feedbackDisplayer: FeedbackDisplayer,
observeGrpcCallsUseCase: ObserveGrpcCallsUseCase,
private val observeGrpcCallByIdUseCase: ObserveGrpcCallByIdUseCase,
private val deleteGrpcCallBeforeUseCase: DeleteGrpcCallBeforeUseCase,
private val deleteGrpcCallUseCase: DeleteGrpcCallUseCase,
private val resetCurrentDeviceGrpcCallsUseCase: ResetCurrentDeviceGrpcCallsUseCase,
) : ViewModel() {
val state: StateFlow<List<GrpcItemViewState>> =
observeGrpcCallsUseCase()
.map { list -> list.map { toUi(it) } }
.flowOn(dispatcherProvider.viewModel)
.stateIn(viewModelScope, started = SharingStarted.WhileSubscribed(5_000), emptyList())
private val clickedCallId = MutableStateFlow<String?>(null)
val detailState: StateFlow<GrpcDetailViewState?> =
clickedCallId
.flatMapLatest { id ->
if (id == null) {
flowOf(null)
} else {
observeGrpcCallByIdUseCase(id)
.distinctUntilChanged()
.map {
it?.let {
toDetailUi(it)
}
}
}
}
.flowOn(dispatcherProvider.viewModel)
.stateIn(viewModelScope, started = SharingStarted.WhileSubscribed(5_000), null)
fun onGrpcItemUserAction(action: OnGrpcItemUserAction) {
viewModelScope.launch {
when (action) {
is OnGrpcItemUserAction.OnClicked -> {
clickedCallId.update {
if (it == action.item.callId) {
null
} else {
action.item.callId
}
}
}
is OnGrpcItemUserAction.Remove -> {
deleteGrpcCallUseCase(callId = action.item.callId)
}
is OnGrpcItemUserAction.RemoveLinesAbove -> {
deleteGrpcCallBeforeUseCase(callId = action.item.callId)
}
is OnGrpcItemUserAction.CopyMethod -> {
val domainModel = observeGrpcCallByIdUseCase(action.item.callId).firstOrNull()
?: return@launch
copyToClipboard(domainModel.request.method)
}
is OnGrpcItemUserAction.CopyUrl -> {
val domainModel = observeGrpcCallByIdUseCase(action.item.callId).firstOrNull()
?: return@launch
copyToClipboard(domainModel.request.authority)
}
}
}
}
fun onCopyText(text: String) {
copyToClipboard(text)
feedbackDisplayer.displayMessage("copied")
}
fun closeDetailPanel() {
viewModelScope.launch {
clickedCallId.update { null }
}
}
fun onReset() {
viewModelScope.launch(dispatcherProvider.viewModel) {
resetCurrentDeviceGrpcCallsUseCase()
}
}
}

View file

@ -1,10 +0,0 @@
package io.github.openflocon.flocondesktop.features.grpc.ui.di
import io.github.openflocon.flocondesktop.features.grpc.ui.GRPCViewModel
import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.module
val grpcUiModule =
module {
viewModelOf(::GRPCViewModel)
}

View file

@ -1,73 +0,0 @@
package io.github.openflocon.flocondesktop.features.grpc.ui.mapper
import io.github.openflocon.flocondesktop.features.grpc.domain.model.GrpcCallDomainModel
import io.github.openflocon.flocondesktop.features.grpc.domain.model.GrpcHeaderDomainModel
import io.github.openflocon.flocondesktop.features.grpc.domain.model.GrpcResponseDomainModel
import io.github.openflocon.flocondesktop.features.grpc.ui.model.GrpcDetailViewState
import io.github.openflocon.flocondesktop.features.grpc.ui.model.GrpcItemViewState
import io.github.openflocon.flocondesktop.features.network.ui.mapper.formatDuration
import io.github.openflocon.flocondesktop.features.network.ui.mapper.formatTimestamp
import io.github.openflocon.flocondesktop.features.network.ui.model.NetworkDetailHeaderUi
fun toUi(domainModel: GrpcCallDomainModel): GrpcItemViewState = GrpcItemViewState(
callId = domainModel.id,
method = domainModel.request.method,
url = domainModel.request.authority,
status = if (domainModel.response == null) {
GrpcItemViewState.StatusViewState.Waiting(text = "Waiting")
} else {
when (domainModel.response.result) {
is GrpcResponseDomainModel.CallResult.Error ->
GrpcItemViewState.StatusViewState.Failure(domainModel.response.result.cause)
is GrpcResponseDomainModel.CallResult.Success ->
GrpcItemViewState.StatusViewState.Success(text = "Success")
}
},
requestTimeFormatted = formatTimestamp(domainModel.request.timestamp),
durationFormatted = domainModel.response?.let {
val duration = it.timestamp - domainModel.request.timestamp
formatDuration(duration.toDouble())
},
)
fun toDetailUi(domainModel: GrpcCallDomainModel): GrpcDetailViewState = GrpcDetailViewState(
method = domainModel.request.method,
url = domainModel.request.authority,
status = if (domainModel.response == null) {
GrpcItemViewState.StatusViewState.Waiting(text = "Waiting")
} else {
when (domainModel.response.result) {
is GrpcResponseDomainModel.CallResult.Error ->
GrpcItemViewState.StatusViewState.Failure(domainModel.response.result.cause)
is GrpcResponseDomainModel.CallResult.Success ->
GrpcItemViewState.StatusViewState.Success(text = "Success")
}
},
requestTimeFormatted = formatTimestamp(domainModel.request.timestamp),
durationFormatted = domainModel.response?.let {
val duration = it.timestamp - domainModel.request.timestamp
formatDuration(duration.toDouble())
},
requestBody = domainModel.request.data,
requestHeaders = domainModel.request.headers.map {
toUi(it)
},
response = domainModel.response?.let {
GrpcDetailViewState.ResponseViewState(
headers = it.headers.map {
toUi(it)
},
result = when (it.result) {
is GrpcResponseDomainModel.CallResult.Error -> GrpcDetailViewState.DetailPayload.Failure(it.result.cause)
is GrpcResponseDomainModel.CallResult.Success -> GrpcDetailViewState.DetailPayload.Success(it.result.data)
},
)
},
)
fun toUi(header: GrpcHeaderDomainModel) = NetworkDetailHeaderUi(
name = header.key,
value = header.value,
)

View file

@ -1,58 +0,0 @@
package io.github.openflocon.flocondesktop.features.grpc.ui.model
import androidx.compose.runtime.Immutable
import io.github.openflocon.flocondesktop.features.network.ui.model.NetworkDetailHeaderUi
import io.github.openflocon.flocondesktop.features.network.ui.model.previewNetworkDetailHeaderUi
@Immutable
data class GrpcDetailViewState(
val url: String, // authority
val requestTimeFormatted: String,
val durationFormatted: String?,
val method: String,
val status: GrpcItemViewState.StatusViewState,
// request
val requestBody: String?,
val requestHeaders: List<NetworkDetailHeaderUi>,
val response: ResponseViewState?,
) {
data class ResponseViewState(
val headers: List<NetworkDetailHeaderUi>,
val result: DetailPayload,
)
sealed interface DetailPayload {
data class Success(val body: String) : DetailPayload
data class Failure(val cause: String) : DetailPayload
}
}
fun previewGrpcDetailViewState() = GrpcDetailViewState(
url = "google.com.test",
requestTimeFormatted = "00:00:00.0000",
durationFormatted = "333ms",
method = "public.get.methodName",
status = GrpcItemViewState.StatusViewState.Success("OK"),
requestBody = "request body",
requestHeaders = listOf(
previewNetworkDetailHeaderUi(),
previewNetworkDetailHeaderUi(),
previewNetworkDetailHeaderUi(),
previewNetworkDetailHeaderUi(),
previewNetworkDetailHeaderUi(),
),
response = GrpcDetailViewState.ResponseViewState(
headers = listOf(
previewNetworkDetailHeaderUi(),
previewNetworkDetailHeaderUi(),
previewNetworkDetailHeaderUi(),
previewNetworkDetailHeaderUi(),
previewNetworkDetailHeaderUi(),
),
result = GrpcDetailViewState.DetailPayload.Success("response body"),
),
)

View file

@ -1,14 +0,0 @@
package io.github.openflocon.flocondesktop.features.grpc.ui.model
import androidx.compose.runtime.Immutable
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@Immutable
data class GrpcItemColumnWidths(
val requestTimeFormatted: Dp = 90.dp,
val url: Float = 1f, // weight
val method: Float = 2f, // weight
val status: Dp = 80.dp,
val durationFormatted: Dp = 65.dp,
)

View file

@ -1,41 +0,0 @@
package io.github.openflocon.flocondesktop.features.grpc.ui.model
import androidx.compose.runtime.Immutable
@Immutable
data class GrpcItemViewState(
val callId: String,
val requestTimeFormatted: String,
val url: String, // authority
val method: String,
val status: StatusViewState,
val durationFormatted: String?,
) {
@Immutable
sealed interface StatusViewState {
val text: String
@Immutable
data class Success(override val text: String) : StatusViewState
@Immutable
data class Waiting(override val text: String) : StatusViewState
@Immutable
data class Failure(override val text: String) : StatusViewState
}
fun contains(text: String): Boolean = listOf(callId, requestTimeFormatted, url, method, status.text, durationFormatted).any {
it?.contains(text, ignoreCase = true) == true
}
}
fun previewGrpcItemViewState(): GrpcItemViewState = GrpcItemViewState(
callId = "0",
requestTimeFormatted = "00:00:00.0000",
url = "google.com.test",
method = "public.get.methodName",
status = GrpcItemViewState.StatusViewState.Success("OK"),
durationFormatted = "333ms",
)

View file

@ -1,23 +0,0 @@
package io.github.openflocon.flocondesktop.features.grpc.ui.model
sealed interface OnGrpcItemUserAction {
data class OnClicked(
val item: GrpcItemViewState,
) : OnGrpcItemUserAction
data class CopyUrl(
val item: GrpcItemViewState,
) : OnGrpcItemUserAction
data class CopyMethod(
val item: GrpcItemViewState,
) : OnGrpcItemUserAction
data class Remove(
val item: GrpcItemViewState,
) : OnGrpcItemUserAction
data class RemoveLinesAbove(
val item: GrpcItemViewState,
) : OnGrpcItemUserAction
}

View file

@ -1,151 +0,0 @@
package io.github.openflocon.flocondesktop.features.grpc.ui.view
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import io.github.openflocon.flocondesktop.common.ui.FloconColors
import io.github.openflocon.flocondesktop.common.ui.FloconTheme
import io.github.openflocon.flocondesktop.features.grpc.ui.GRPCViewModel
import io.github.openflocon.flocondesktop.features.grpc.ui.model.GrpcDetailViewState
import io.github.openflocon.flocondesktop.features.grpc.ui.model.GrpcItemColumnWidths
import io.github.openflocon.flocondesktop.features.grpc.ui.model.GrpcItemViewState
import io.github.openflocon.flocondesktop.features.grpc.ui.model.OnGrpcItemUserAction
import io.github.openflocon.flocondesktop.features.grpc.ui.model.previewGrpcItemViewState
import io.github.openflocon.flocondesktop.features.grpc.ui.view.header.GrpcFilterBar
import io.github.openflocon.flocondesktop.features.grpc.ui.view.header.GrpcItemHeaderView
import org.jetbrains.compose.ui.tooling.preview.Preview
import org.koin.compose.viewmodel.koinViewModel
@Composable
fun GRPCScreen(modifier: Modifier = Modifier) {
val viewModel: GRPCViewModel = koinViewModel()
val state: List<GrpcItemViewState> by viewModel.state.collectAsStateWithLifecycle()
val detailState: GrpcDetailViewState? by viewModel.detailState.collectAsStateWithLifecycle()
GRPCScreen(
grpcItems = state,
modifier = modifier,
detailState = detailState,
onReset = viewModel::onReset,
closeDetailPanel = viewModel::closeDetailPanel,
onCopyText = viewModel::onCopyText,
onGrpcItemUserAction = viewModel::onGrpcItemUserAction,
)
}
@Composable
private fun GRPCScreen(
grpcItems: List<GrpcItemViewState>,
onGrpcItemUserAction: (OnGrpcItemUserAction) -> Unit,
onReset: () -> Unit,
detailState: GrpcDetailViewState?,
closeDetailPanel: () -> Unit,
onCopyText: (text: String) -> Unit,
modifier: Modifier = Modifier,
) {
val columnWidths: GrpcItemColumnWidths =
remember { GrpcItemColumnWidths() } // Default widths provided
var filteredItems by remember { mutableStateOf<List<GrpcItemViewState>>(emptyList()) }
Surface(modifier = modifier) {
Box(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.fillMaxSize()) {
Text(
text = "Grpc",
modifier = Modifier
.fillMaxWidth()
.background(FloconColors.pannel)
.padding(all = 12.dp),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
)
GrpcFilterBar(
modifier =
Modifier
.fillMaxWidth()
.background(FloconColors.pannel)
.padding(horizontal = 12.dp),
grpcItems = grpcItems,
onResetClicked = onReset,
onItemsChange = {
filteredItems = it
},
)
GrpcItemHeaderView(
columnWidths = columnWidths,
modifier = Modifier.fillMaxWidth(),
)
LazyColumn(
modifier =
Modifier
.fillMaxSize()
.clickable(
interactionSource = null,
indication = null,
enabled = detailState != null,
) {
closeDetailPanel()
},
) {
items(filteredItems) {
GrpcItemView(
state = it,
columnWidths = columnWidths,
modifier = Modifier.fillMaxWidth(),
onUserAction = onGrpcItemUserAction,
)
}
}
}
detailState?.let {
GrpcDetailView(
modifier =
Modifier
.align(Alignment.TopEnd)
.fillMaxHeight()
.width(500.dp),
state = it,
onCopy = onCopyText,
)
}
}
}
}
@Composable
@Preview
private fun GRPCScreenPreview() {
FloconTheme {
GRPCScreen(
grpcItems = List(10) {
previewGrpcItemViewState()
},
onReset = {},
detailState = null,
closeDetailPanel = {},
onCopyText = {},
onGrpcItemUserAction = {},
)
}
}

View file

@ -1,258 +0,0 @@
package io.github.openflocon.flocondesktop.features.grpc.ui.view
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.github.openflocon.flocondesktop.common.ui.FloconColors
import io.github.openflocon.flocondesktop.common.ui.FloconTheme
import io.github.openflocon.flocondesktop.features.grpc.ui.model.GrpcDetailViewState
import io.github.openflocon.flocondesktop.features.grpc.ui.model.previewGrpcDetailViewState
import io.github.openflocon.flocondesktop.features.network.ui.view.detail.CodeBlockView
import io.github.openflocon.flocondesktop.features.network.ui.view.detail.DetailHeadersView
import io.github.openflocon.flocondesktop.features.network.ui.view.detail.DetailLineTextView
import io.github.openflocon.flocondesktop.features.network.ui.view.detail.DetailLineView
import io.github.openflocon.flocondesktop.features.network.ui.view.detail.DetailSectionTitleView
import io.github.openflocon.flocondesktop.features.network.ui.view.detail.ExpandedSectionView
import org.jetbrains.compose.ui.tooling.preview.Preview
@Composable
fun GrpcDetailView(
state: GrpcDetailViewState,
onCopy: (String) -> Unit, // Le lambda onCopy remplace ClipboardManager
modifier: Modifier = Modifier,
) {
val scrollState = rememberScrollState()
var isRequestExpanded by remember { mutableStateOf(true) }
var isRequestBodyExpanded by remember { mutableStateOf(true) }
var isRequestHeadersExpanded by remember { mutableStateOf(true) }
var isResponseExpanded by remember { mutableStateOf(true) }
var isResponseHeadersExpanded by remember { mutableStateOf(true) }
var isResponseBodyExpanded by remember { mutableStateOf(true) }
val linesLabelWidth: Dp = 130.dp
val headersLabelWidth: Dp = 150.dp
Column(
modifier =
modifier
.background(FloconColors.background)
.verticalScroll(scrollState) // Rendre le contenu défilable
.padding(all = 12.dp),
) {
DetailSectionTitleView(
isExpanded = isRequestExpanded,
title = "Request",
onCopy = null,
onToggle = {
isRequestExpanded = it
},
)
ExpandedSectionView(
modifier = Modifier.fillMaxWidth(),
isExpanded = isRequestExpanded,
) {
Column(
modifier =
Modifier
.background(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = RoundedCornerShape(12.dp),
).padding(horizontal = 8.dp, vertical = 4.dp),
) {
DetailLineTextView(
modifier = Modifier.fillMaxWidth(),
label = "Url",
value = state.url,
labelWidth = linesLabelWidth,
)
DetailLineTextView(
modifier = Modifier.fillMaxWidth(),
label = "Method",
labelWidth = linesLabelWidth,
value = state.method,
)
DetailLineView(
modifier = Modifier.fillMaxWidth(),
label = "Status",
labelWidth = linesLabelWidth,
) {
GrpcStatusView(status = state.status)
}
DetailLineTextView(
modifier = Modifier.fillMaxWidth(),
label = "Request Time",
value = state.requestTimeFormatted,
labelWidth = linesLabelWidth,
)
state.durationFormatted?.let {
DetailLineTextView(
modifier = Modifier.fillMaxWidth(),
label = "Duration",
value = it,
labelWidth = linesLabelWidth,
)
}
}
// headers
DetailSectionTitleView(
isExpanded = isRequestHeadersExpanded,
title = "Request Headers",
onCopy = null,
onToggle = {
isRequestHeadersExpanded = it
},
)
ExpandedSectionView(
modifier = Modifier.fillMaxWidth(),
isExpanded = isRequestHeadersExpanded,
) {
DetailHeadersView(
headers = state.requestHeaders,
modifier = Modifier.fillMaxWidth(),
labelWidth = headersLabelWidth,
)
}
// body
DetailSectionTitleView(
isExpanded = isRequestBodyExpanded,
title = "Request Body",
onCopy = {
onCopy(state.requestBody ?: "")
},
onToggle = {
isRequestBodyExpanded = it
},
)
ExpandedSectionView(
modifier = Modifier.fillMaxWidth(),
isExpanded = isRequestBodyExpanded,
) {
CodeBlockView(
code = state.requestBody ?: "",
modifier = Modifier.fillMaxWidth(),
)
}
}
HorizontalDivider(
modifier =
Modifier
.fillMaxWidth()
.padding(start = 12.dp)
.padding(vertical = 12.dp),
)
state.response?.let { response ->
DetailSectionTitleView(
isExpanded = isResponseExpanded,
title = "Response",
onCopy = null,
onToggle = {
isResponseExpanded = it
},
)
ExpandedSectionView(
modifier = Modifier.fillMaxWidth(),
isExpanded = isResponseExpanded,
) {
// headers
DetailSectionTitleView(
isExpanded = isResponseHeadersExpanded,
title = "Response Headers",
onCopy = null,
onToggle = {
isResponseHeadersExpanded = it
},
)
ExpandedSectionView(
modifier = Modifier.fillMaxWidth(),
isExpanded = isResponseHeadersExpanded,
) {
DetailHeadersView(
headers = response.headers,
modifier = Modifier.fillMaxWidth(),
labelWidth = headersLabelWidth,
)
}
when (val r = response.result) {
is GrpcDetailViewState.DetailPayload.Failure -> {
// body
DetailSectionTitleView(
isExpanded = isResponseBodyExpanded,
title = "Response Error",
onCopy = {
onCopy(r.cause)
},
onToggle = {
isResponseBodyExpanded = it
},
)
ExpandedSectionView(
modifier = Modifier.fillMaxWidth(),
isExpanded = isResponseBodyExpanded,
) {
CodeBlockView(
code = r.cause,
modifier = Modifier.fillMaxWidth(),
)
}
}
is GrpcDetailViewState.DetailPayload.Success -> {
// body
DetailSectionTitleView(
isExpanded = isResponseBodyExpanded,
title = "Response Body",
onCopy = {
onCopy(r.body)
},
onToggle = {
isResponseBodyExpanded = it
},
)
ExpandedSectionView(
modifier = Modifier.fillMaxWidth(),
isExpanded = isResponseBodyExpanded,
) {
CodeBlockView(
code = r.body,
modifier = Modifier.fillMaxWidth(),
)
}
}
}
}
}
}
}
@Preview
@Composable
private fun GrpcDetailViewPreview() {
FloconTheme {
GrpcDetailView(
state = previewGrpcDetailViewState(),
modifier = Modifier.padding(16.dp), // Padding pour la preview
onCopy = { },
)
}
}

View file

@ -1,153 +0,0 @@
package io.github.openflocon.flocondesktop.features.grpc.ui.view
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import io.github.openflocon.flocondesktop.common.ui.ContextualItem
import io.github.openflocon.flocondesktop.common.ui.ContextualView
import io.github.openflocon.flocondesktop.features.grpc.ui.model.GrpcItemColumnWidths
import io.github.openflocon.flocondesktop.features.grpc.ui.model.GrpcItemViewState
import io.github.openflocon.flocondesktop.features.grpc.ui.model.OnGrpcItemUserAction
import io.github.openflocon.flocondesktop.features.grpc.ui.model.previewGrpcItemViewState
import org.jetbrains.compose.ui.tooling.preview.Preview
@Composable
fun GrpcItemView(
state: GrpcItemViewState,
columnWidths: GrpcItemColumnWidths = GrpcItemColumnWidths(), // Default widths provided
onUserAction: (OnGrpcItemUserAction) -> Unit,
modifier: Modifier = Modifier,
) {
// Use MaterialTheme.typography for consistent text sizes
val bodySmall = MaterialTheme.typography.bodySmall // Typically 12.sp or similar
val labelSmall = MaterialTheme.typography.labelSmall // Even smaller, good for labels/tags
ContextualView(
listOf(
ContextualItem(
id = "copy_url",
text = "Copy url",
),
ContextualItem(
id = "copy_method",
text = "Copy Method",
),
ContextualItem(
id = "remove",
text = "Remove",
),
ContextualItem(
id = "remove_lines_above",
text = "Remove lines above ",
),
),
onSelect = {
when (it.id) {
"copy_url" -> onUserAction(OnGrpcItemUserAction.CopyUrl(state))
"copy_method" -> onUserAction(OnGrpcItemUserAction.CopyMethod(state))
"remove" -> onUserAction(OnGrpcItemUserAction.Remove(state))
"remove_lines_above" -> onUserAction(OnGrpcItemUserAction.RemoveLinesAbove(state))
}
},
) {
Row(
modifier = modifier
.padding(horizontal = 8.dp, vertical = 4.dp) // Padding for the entire item
.clip(shape = RoundedCornerShape(8.dp))
.clickable(onClick = {
onUserAction(OnGrpcItemUserAction.OnClicked(state))
})
.background(
color = MaterialTheme.colorScheme.surface, // Use surface color for the item background
)
.padding(horizontal = 8.dp, vertical = 6.dp),
// Inner padding for content
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
// Date - Fixed width from data class
Box(
modifier = Modifier.width(columnWidths.requestTimeFormatted),
contentAlignment = Alignment.Center,
) {
Text(
state.requestTimeFormatted,
style = bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
)
}
// Request Url - Fixed width from data class
Box(
modifier = Modifier.weight(columnWidths.url),
contentAlignment = Alignment.Center,
) {
Text(
state.url,
style = bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
)
}
// Method - Takes remaining space (weight)
Box(
modifier = Modifier.weight(columnWidths.method),
) {
Text(
state.method,
style = bodySmall,
color = MaterialTheme.colorScheme.onSurface,
)
}
// Status - Fixed width from data class
// TODO add a badge here
Box(
modifier = Modifier.width(columnWidths.status),
contentAlignment = Alignment.Center,
) {
GrpcStatusView(
state.status,
)
}
// Duration - Fixed width from data class
Box(
modifier = Modifier.width(columnWidths.durationFormatted),
contentAlignment = Alignment.Center,
) {
Text(
state.durationFormatted ?: "", // reserve this space
style = bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
)
}
}
}
}
@Composable
@Preview
private fun ItemViewPreview() {
MaterialTheme {
GrpcItemView(
modifier = Modifier.fillMaxWidth(),
state = previewGrpcItemViewState(),
onUserAction = {},
)
}
}

View file

@ -1,97 +0,0 @@
package io.github.openflocon.flocondesktop.features.grpc.ui.view
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.github.openflocon.flocondesktop.common.ui.FloconTheme
import io.github.openflocon.flocondesktop.features.grpc.ui.model.GrpcItemViewState
import org.jetbrains.compose.ui.tooling.preview.Preview
// Custom colors for networkStatusUi/method views to integrate better with the theme
val successBackground = Color(0xFF28A745).copy(alpha = 0.3f) // Muted green for success
val successText = Color(0xFF28A745) // Brighter green for text
val errorBackground = Color(0xFFDC3545).copy(alpha = 0.3f) // Muted red for error
val errorText = Color(0xFFDC3545) // Brighter red for text
private val waitingBackground = Color(0xFF6C757D).copy(alpha = 0.3f) // Muted gray for OTHER
private val waitingText = Color(0xFF6C757D)
@Composable
fun GrpcStatusView(
status: GrpcItemViewState.StatusViewState,
textSize: TextUnit = 12.sp,
modifier: Modifier = Modifier,
) {
Box(
modifier =
modifier
.background(
color = when (status) {
is GrpcItemViewState.StatusViewState.Failure -> errorBackground
is GrpcItemViewState.StatusViewState.Success -> successBackground
is GrpcItemViewState.StatusViewState.Waiting -> waitingBackground
},
shape = RoundedCornerShape(20.dp), // Pill shape
).padding(horizontal = 8.dp, vertical = 4.dp),
// Padding inside the tag
contentAlignment = Alignment.Center, // Center content if Box is larger than text
) {
Text(
modifier = modifier,
text = status.text,
textAlign = TextAlign.Center,
fontSize = textSize,
color = when (status) {
is GrpcItemViewState.StatusViewState.Failure -> errorText
is GrpcItemViewState.StatusViewState.Success -> successText
is GrpcItemViewState.StatusViewState.Waiting -> waitingText
},
style = MaterialTheme.typography.labelSmall, // Use typography for consistency
)
}
}
@Composable
@Preview
private fun StatusView_Preview() {
FloconTheme {
GrpcStatusView(
status =
GrpcItemViewState.StatusViewState.Success("OK"),
)
}
}
@Composable
@Preview
private fun StatusView_Failure_Preview() {
FloconTheme {
GrpcStatusView(
status =
GrpcItemViewState.StatusViewState.Failure("Error"),
)
}
}
@Composable
@Preview
private fun StatusView_Waiting_Preview() {
FloconTheme {
GrpcStatusView(
status =
GrpcItemViewState.StatusViewState.Waiting("Waiting"),
)
}
}

View file

@ -1,79 +0,0 @@
package io.github.openflocon.flocondesktop.features.grpc.ui.view.header
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import flocondesktop.composeapp.generated.resources.Res
import flocondesktop.composeapp.generated.resources.bin
import io.github.openflocon.flocondesktop.features.grpc.ui.model.GrpcItemViewState
import io.github.openflocon.flocondesktop.features.network.ui.view.components.FilterBar
import org.jetbrains.compose.resources.painterResource
@Composable
fun GrpcFilterBar(
grpcItems: List<GrpcItemViewState>,
onItemsChange: (List<GrpcItemViewState>) -> Unit,
onResetClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
var filterText by remember {
mutableStateOf("")
}
val onItemsChangeCallback by rememberUpdatedState(onItemsChange)
val filteredGrpcItems: List<GrpcItemViewState> =
remember(grpcItems, filterText) {
if (filterText.isBlank()) {
grpcItems
} else {
grpcItems.filter {
it.contains(filterText)
}
}
}
LaunchedEffect(filteredGrpcItems) {
onItemsChangeCallback(filteredGrpcItems)
}
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
FilterBar(
placeholderText = "Filter",
modifier = Modifier.weight(1f),
onTextChange = {
filterText = it
},
)
Box(
modifier = Modifier
.clip(RoundedCornerShape(4.dp))
.clickable(onClick = onResetClicked)
.padding(all = 8.dp),
) {
Image(
painter = painterResource(Res.drawable.bin),
contentDescription = null,
modifier = Modifier.size(20.dp),
)
}
}
}

View file

@ -1,53 +0,0 @@
package io.github.openflocon.flocondesktop.features.grpc.ui.view.header
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import io.github.openflocon.flocondesktop.common.ui.FloconColors
import io.github.openflocon.flocondesktop.features.grpc.ui.model.GrpcItemColumnWidths
import io.github.openflocon.flocondesktop.features.network.ui.view.components.HeaderLabelItem
@Composable
fun GrpcItemHeaderView(
modifier: Modifier = Modifier,
columnWidths: GrpcItemColumnWidths = GrpcItemColumnWidths(), // Default widths provided
) {
Row(
modifier =
modifier
.background(FloconColors.pannel)
.padding(horizontal = 8.dp, vertical = 4.dp) // Padding for the entire item
.padding(horizontal = 8.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.Bottom,
) {
// Date - Fixed width from data class
HeaderLabelItem(
modifier = Modifier.width(columnWidths.requestTimeFormatted),
text = "Request Time",
)
HeaderLabelItem(
modifier = Modifier.weight(columnWidths.url),
text = "Url",
)
HeaderLabelItem(
modifier = Modifier.weight(columnWidths.method),
contentAlignment = Alignment.TopStart,
text = "Method",
)
HeaderLabelItem(
modifier = Modifier.width(columnWidths.status),
text = "Status",
)
HeaderLabelItem(
modifier = Modifier.width(columnWidths.durationFormatted),
text = "Duration",
)
}
}

View file

@ -84,7 +84,6 @@ object FloconHttpRequestGenerator {
),
response = FloconHttpRequestDomainModel.Response(
body = responseBodyContent,
httpCode = 200,
byteSize = 1500,
headers =
mapOf(
@ -93,7 +92,9 @@ object FloconHttpRequestGenerator {
"X-Response-ID" to "res-$index",
),
),
type = FloconHttpRequestDomainModel.Type.Http,
type = FloconHttpRequestDomainModel.Type.Http(
httpCode = 200,
),
)
}
}

View file

@ -8,7 +8,6 @@ import io.github.openflocon.flocondesktop.features.network.data.datasource.local
import io.github.openflocon.flocondesktop.features.network.data.model.FloconHttpRequestDataModel
import io.github.openflocon.flocondesktop.features.network.data.parser.graphql.computeIsGraphQlSuccess
import io.github.openflocon.flocondesktop.features.network.data.parser.graphql.extractGraphQl
import io.github.openflocon.flocondesktop.features.network.data.parser.graphql.model.GraphQlResponseBody
import io.github.openflocon.flocondesktop.features.network.domain.model.FloconHttpRequestDomainModel
import io.github.openflocon.flocondesktop.features.network.domain.repository.NetworkImageRepository
import io.github.openflocon.flocondesktop.features.network.domain.repository.NetworkRepository
@ -112,22 +111,30 @@ class NetworkRepositoryImpl(
byteSize = decoded.requestSize ?: 0L,
),
response = FloconHttpRequestDomainModel.Response(
httpCode = decoded.responseHttpCode!!,
contentType = decoded.responseContentType,
body = decoded.responseBody,
headers = decoded.responseHeaders!!,
byteSize = decoded.responseSize ?: 0L,
),
type = when {
graphQl != null -> FloconHttpRequestDomainModel.Type.GraphQl(
query = graphQl.request.queryName ?: "anonymous",
operationType = graphQl.request.operationType,
isSuccess = computeIsGraphQlSuccess(
responseHttpCode = decoded.responseHttpCode,
response = graphQl.response,
)
decoded.floconNetworkType == "grpc" -> FloconHttpRequestDomainModel.Type.Grpc(
responseStatus = decoded.responseGrpcStatus!!,
)
graphQl != null -> {
val httpCode = decoded.responseHttpCode!! // mandatory for graphQl
FloconHttpRequestDomainModel.Type.GraphQl(
query = graphQl.request.queryName ?: "anonymous",
operationType = graphQl.request.operationType,
isSuccess = computeIsGraphQlSuccess(
responseHttpCode = httpCode,
response = graphQl.response,
),
httpCode = httpCode,
)
}
else -> FloconHttpRequestDomainModel.Type.Http(
httpCode = decoded.responseHttpCode!!, // mandatory for http
)
else -> FloconHttpRequestDomainModel.Type.Http
},
)
} catch (t: Throwable) {

View file

@ -33,7 +33,7 @@ class NetworkLocalDataSourceRoom(
override fun observeRequests(deviceId: DeviceId): Flow<List<FloconHttpRequestDomainModel>> = floconHttpRequestDao
.observeRequests(deviceId)
.map { entities ->
entities.map { it.toDomainModel() }
entities.mapNotNull { it.toDomainModel() }
}.flowOn(dispatcherProvider.data)
override suspend fun save(

View file

@ -8,13 +8,32 @@ fun FloconHttpRequestDomainModel.toEntity(deviceId: String): FloconHttpRequestEn
uuid = this.uuid,
infos = this.toInfosEntity(),
deviceId = deviceId,
http = when (val t = this.type) {
is FloconHttpRequestDomainModel.Type.Http -> FloconHttpRequestEntity.HttpEmbedded(
responseHttpCode = t.httpCode,
)
is FloconHttpRequestDomainModel.Type.GraphQl,
is FloconHttpRequestDomainModel.Type.Grpc,
-> null
},
graphql = when (val t = this.type) {
is FloconHttpRequestDomainModel.Type.GraphQl -> FloconHttpRequestEntity.FloconHttpRequestGraphQlEntity(
is FloconHttpRequestDomainModel.Type.GraphQl -> FloconHttpRequestEntity.GraphQlEmbedded(
query = t.query,
operationType = t.operationType,
isSuccess = t.isSuccess,
responseHttpCode = t.httpCode,
)
else -> null
is FloconHttpRequestDomainModel.Type.Http,
is FloconHttpRequestDomainModel.Type.Grpc,
-> null
},
grpc = when (val t = this.type) {
is FloconHttpRequestDomainModel.Type.Grpc -> FloconHttpRequestEntity.GrpcEmbedded(
responseStatus = t.responseStatus,
)
is FloconHttpRequestDomainModel.Type.Http,
is FloconHttpRequestDomainModel.Type.GraphQl,
-> null
},
)
@ -26,37 +45,47 @@ private fun FloconHttpRequestDomainModel.toInfosEntity(): FloconHttpRequestInfos
requestHeaders = this.request.headers,
requestBody = this.request.body,
requestByteSize = this.request.byteSize,
responseHttpCode = this.response.httpCode,
responseContentType = this.response.contentType,
responseBody = this.response.body,
responseHeaders = this.response.headers,
responseByteSize = this.response.byteSize,
)
fun FloconHttpRequestEntity.toDomainModel(): FloconHttpRequestDomainModel = FloconHttpRequestDomainModel(
uuid = this.uuid,
url = this.infos.url,
durationMs = this.infos.durationMs,
request = FloconHttpRequestDomainModel.Request(
method = this.infos.method,
startTime = this.infos.startTime,
headers = this.infos.requestHeaders,
body = this.infos.requestBody,
byteSize = this.infos.requestByteSize,
),
response = FloconHttpRequestDomainModel.Response(
httpCode = this.infos.responseHttpCode,
contentType = this.infos.responseContentType,
body = this.infos.responseBody,
headers = this.infos.responseHeaders,
byteSize = this.infos.responseByteSize,
),
type = when {
this.graphql != null -> FloconHttpRequestDomainModel.Type.GraphQl(
query = this.graphql.query,
operationType = this.graphql.operationType,
isSuccess = this.graphql.isSuccess,
)
else -> FloconHttpRequestDomainModel.Type.Http
},
)
fun FloconHttpRequestEntity.toDomainModel(): FloconHttpRequestDomainModel? {
return FloconHttpRequestDomainModel(
uuid = this.uuid,
url = this.infos.url,
durationMs = this.infos.durationMs,
request = FloconHttpRequestDomainModel.Request(
method = this.infos.method,
startTime = this.infos.startTime,
headers = this.infos.requestHeaders,
body = this.infos.requestBody,
byteSize = this.infos.requestByteSize,
),
response = FloconHttpRequestDomainModel.Response(
contentType = this.infos.responseContentType,
body = this.infos.responseBody,
headers = this.infos.responseHeaders,
byteSize = this.infos.responseByteSize,
),
type = when {
this.graphql != null -> FloconHttpRequestDomainModel.Type.GraphQl(
query = this.graphql.query,
operationType = this.graphql.operationType,
isSuccess = this.graphql.isSuccess,
httpCode = this.graphql.responseHttpCode,
)
this.http != null -> FloconHttpRequestDomainModel.Type.Http(
httpCode = this.http.responseHttpCode,
)
this.grpc != null -> FloconHttpRequestDomainModel.Type.Grpc(
responseStatus = this.grpc.responseStatus,
)
else -> return null
},
)
}

View file

@ -13,12 +13,27 @@ data class FloconHttpRequestEntity(
val infos: FloconHttpRequestInfosEntity,
// if it's a graphql method, this item is not null
@Embedded(prefix = "graphql_")
val graphql: FloconHttpRequestGraphQlEntity?,
val graphql: GraphQlEmbedded?,
@Embedded(prefix = "http_")
val http: HttpEmbedded?,
@Embedded(prefix = "grpc_")
val grpc: GrpcEmbedded?,
) {
data class FloconHttpRequestGraphQlEntity(
data class GraphQlEmbedded(
val query: String,
val operationType: String,
val isSuccess: Boolean,
val responseHttpCode: Int,
)
data class HttpEmbedded(
val responseHttpCode: Int,
)
data class GrpcEmbedded(
val responseStatus: String,
)
}
@ -30,7 +45,6 @@ data class FloconHttpRequestInfosEntity(
val requestHeaders: Map<String, String>,
val requestBody: String?,
val requestByteSize: Long,
val responseHttpCode: Int,
val responseContentType: String?,
val responseBody: String?,
val responseHeaders: Map<String, String>,

View file

@ -4,6 +4,8 @@ import kotlinx.serialization.Serializable
@Serializable
data class FloconHttpRequestDataModel(
val floconNetworkType: String? = null,
val url: String? = null,
val method: String? = null,
val startTime: Long? = null,
@ -18,4 +20,5 @@ data class FloconHttpRequestDataModel(
val responseBody: String? = null,
val responseHeaders: Map<String, String>? = null,
val responseSize: Long? = null,
val responseGrpcStatus: String? = null,
)

View file

@ -59,8 +59,8 @@ private fun extractOperationName(query: String): String? {
fun computeIsGraphQlSuccess(
responseHttpCode: Int,
response: GraphQlResponseBody?
response: GraphQlResponseBody?,
): Boolean {
if(responseHttpCode !in 200..299) return false
if (responseHttpCode !in 200..299) return false
return response?.errors?.takeUnless { it.isEmpty() } == null
}

View file

@ -17,7 +17,6 @@ data class FloconHttpRequestDomainModel(
)
data class Response(
val httpCode: Int, // ex: 200
val contentType: String? = null,
val body: String? = null,
val headers: Map<String, String>,
@ -26,10 +25,16 @@ data class FloconHttpRequestDomainModel(
sealed interface Type {
data class GraphQl(
val httpCode: Int, // ex: 200
val query: String,
val operationType: String,
val isSuccess: Boolean,
) : Type
data object Http : Type
data class Http(
val httpCode: Int, // ex: 200
) : Type
data class Grpc(
val responseStatus: String,
) : Type
}
}

View file

@ -0,0 +1,23 @@
package io.github.openflocon.flocondesktop.features.network.ui.mapper
import io.github.openflocon.flocondesktop.features.network.domain.model.FloconHttpRequestDomainModel
import io.github.openflocon.flocondesktop.features.network.ui.model.NetworkMethodUi
import io.github.openflocon.flocondesktop.features.network.ui.model.NetworkMethodUi.OTHER
fun getMethodUi(httpRequest: FloconHttpRequestDomainModel): NetworkMethodUi = when (val t = httpRequest.type) {
is FloconHttpRequestDomainModel.Type.GraphQl -> when (t.operationType.lowercase()) {
"query" -> NetworkMethodUi.GraphQl.QUERY
"mutation" -> NetworkMethodUi.GraphQl.MUTATION
else -> OTHER(t.operationType, icon = null)
}
is FloconHttpRequestDomainModel.Type.Http -> toHttpMethodUi(httpRequest.request.method)
is FloconHttpRequestDomainModel.Type.Grpc -> NetworkMethodUi.Grpc
}
fun toHttpMethodUi(httpMethod: String): NetworkMethodUi = when (httpMethod.lowercase()) {
"get" -> NetworkMethodUi.Http.GET
"put" -> NetworkMethodUi.Http.PUT
"post" -> NetworkMethodUi.Http.POST
"delete" -> NetworkMethodUi.Http.DELETE
else -> NetworkMethodUi.OTHER(httpMethod, icon = null)
}

View file

@ -5,11 +5,12 @@ import io.github.openflocon.flocondesktop.common.ui.JsonPrettyPrinter
import io.github.openflocon.flocondesktop.features.network.domain.model.FloconHttpRequestDomainModel
import io.github.openflocon.flocondesktop.features.network.ui.model.NetworkDetailHeaderUi
import io.github.openflocon.flocondesktop.features.network.ui.model.NetworkDetailViewState
import io.github.openflocon.flocondesktop.features.network.ui.model.NetworkStatusUi
fun toDetailUi(request: FloconHttpRequestDomainModel): NetworkDetailViewState = NetworkDetailViewState(
fullUrl = request.url,
method = toMethodUi(request.request.method),
status = toNetworkStatusUi(request.response.httpCode),
method = toDetailMethodUi(request),
status = toDetailNetworkStatusUi(request.type),
requestTimeFormatted = request.request.startTime.let { formatTimestamp(it) },
durationFormatted = formatDuration(request.durationMs),
// request
@ -24,6 +25,13 @@ fun toDetailUi(request: FloconHttpRequestDomainModel): NetworkDetailViewState =
graphQlSection = graphQlSection(request),
)
private fun toDetailNetworkStatusUi(type: FloconHttpRequestDomainModel.Type): NetworkStatusUi = when (type) {
is FloconHttpRequestDomainModel.Type.Grpc -> toGrpcNetworkStatusUi(type)
// here for grphql we want the http code, the graphql status will be displayed on the specific graphql section
is FloconHttpRequestDomainModel.Type.GraphQl -> toNetworkStatusUi(code = type.httpCode)
is FloconHttpRequestDomainModel.Type.Http -> toNetworkStatusUi(code = type.httpCode)
}
fun graphQlSection(request: FloconHttpRequestDomainModel): NetworkDetailViewState.GraphQlSection? = (request.type as? FloconHttpRequestDomainModel.Type.GraphQl)?.let {
NetworkDetailViewState.GraphQlSection(
queryName = request.type.query,
@ -43,3 +51,12 @@ fun toNetworkHeadersUi(headers: Map<String, String>?): List<NetworkDetailHeaderU
)
}.sortedBy { it.name }
} ?: emptyList()
fun toDetailMethodUi(request: FloconHttpRequestDomainModel): NetworkDetailViewState.Method = when (val t = request.type) {
is FloconHttpRequestDomainModel.Type.Grpc -> NetworkDetailViewState.Method.MethodName(
name = request.request.method,
)
is FloconHttpRequestDomainModel.Type.GraphQl,
is FloconHttpRequestDomainModel.Type.Http,
-> NetworkDetailViewState.Method.Http(toHttpMethodUi(request.request.method))
}

View file

@ -3,8 +3,6 @@ package io.github.openflocon.flocondesktop.features.network.ui.mapper
import io.github.openflocon.flocondesktop.common.ui.ByteFormatter
import io.github.openflocon.flocondesktop.features.network.domain.model.FloconHttpRequestDomainModel
import io.github.openflocon.flocondesktop.features.network.ui.model.NetworkItemViewState
import io.github.openflocon.flocondesktop.features.network.ui.model.NetworkMethodUi
import io.github.openflocon.flocondesktop.features.network.ui.model.NetworkStatusUi
import io.ktor.http.Url
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
@ -55,56 +53,10 @@ fun toUi(httpRequest: FloconHttpRequestDomainModel): NetworkItemViewState = Netw
status = getStatusUi(httpRequest),
)
fun toTypeUi(httpRequest: FloconHttpRequestDomainModel): NetworkItemViewState.NetworkTypeUi = when (val t = httpRequest.type) {
is FloconHttpRequestDomainModel.Type.GraphQl -> NetworkItemViewState.NetworkTypeUi.GraphQl(
queryName = t.query,
)
FloconHttpRequestDomainModel.Type.Http -> {
val query = extractPath(httpRequest.url)
NetworkItemViewState.NetworkTypeUi.Url(
query = query,
)
}
}
fun getMethodUi(httpRequest: FloconHttpRequestDomainModel): NetworkMethodUi = when (val t = httpRequest.type) {
is FloconHttpRequestDomainModel.Type.GraphQl -> when (t.operationType.lowercase()) {
"query" -> NetworkMethodUi.GraphQl.QUERY
"mutation" -> NetworkMethodUi.GraphQl.MUTATION
else -> NetworkMethodUi.OTHER(t.operationType, icon = null)
}
is FloconHttpRequestDomainModel.Type.Http -> toMethodUi(httpRequest.request.method)
}
fun getStatusUi(httpRequest: FloconHttpRequestDomainModel): NetworkStatusUi = when (val t = httpRequest.type) {
is FloconHttpRequestDomainModel.Type.GraphQl -> toGraphQlNetworkStatusUi(isSuccess = t.isSuccess)
is FloconHttpRequestDomainModel.Type.Http -> toNetworkStatusUi(httpRequest.response.httpCode)
}
fun getDomainUi(httpRequest: FloconHttpRequestDomainModel): String = when (val t = httpRequest.type) {
is FloconHttpRequestDomainModel.Type.GraphQl -> extractDomainAndPath(httpRequest.url)
is FloconHttpRequestDomainModel.Type.Http -> extractDomain(httpRequest.url)
}
fun toNetworkStatusUi(code: Int): NetworkStatusUi = NetworkStatusUi(
text = code.toString(),
isSuccess = code >= 200 && code < 300,
)
fun toGraphQlNetworkStatusUi(isSuccess: Boolean): NetworkStatusUi {
return NetworkStatusUi(
text = if (isSuccess) "Success" else "Error",
isSuccess = isSuccess,
)
}
fun toMethodUi(httpMethod: String): NetworkMethodUi = when (httpMethod.lowercase()) {
"get" -> NetworkMethodUi.Http.GET
"put" -> NetworkMethodUi.Http.PUT
"post" -> NetworkMethodUi.Http.POST
"delete" -> NetworkMethodUi.Http.DELETE
else -> NetworkMethodUi.OTHER(httpMethod, icon = null)
is FloconHttpRequestDomainModel.Type.Grpc -> extractDomain(httpRequest.url)
}
fun formatDuration(duration: Double): String = duration.milliseconds.toString(unit = DurationUnit.MILLISECONDS)

View file

@ -0,0 +1,28 @@
package io.github.openflocon.flocondesktop.features.network.ui.mapper
import io.github.openflocon.flocondesktop.features.network.domain.model.FloconHttpRequestDomainModel
import io.github.openflocon.flocondesktop.features.network.ui.model.NetworkStatusUi
fun getStatusUi(httpRequest: FloconHttpRequestDomainModel): NetworkStatusUi = when (val t = httpRequest.type) {
is FloconHttpRequestDomainModel.Type.GraphQl -> toGraphQlNetworkStatusUi(isSuccess = t.isSuccess)
is FloconHttpRequestDomainModel.Type.Http -> toNetworkStatusUi(t.httpCode)
is FloconHttpRequestDomainModel.Type.Grpc -> toGrpcNetworkStatusUi(t)
}
fun toNetworkStatusUi(code: Int): NetworkStatusUi = NetworkStatusUi(
text = code.toString(),
isSuccess = code >= 200 && code < 300,
)
fun toGraphQlNetworkStatusUi(isSuccess: Boolean): NetworkStatusUi = NetworkStatusUi(
text = if (isSuccess) "Success" else "Error",
isSuccess = isSuccess,
)
fun toGrpcNetworkStatusUi(type: FloconHttpRequestDomainModel.Type.Grpc): NetworkStatusUi {
val isSuccess = type.responseStatus == "OK"
return NetworkStatusUi(
text = type.responseStatus,
isSuccess = isSuccess,
)
}

View file

@ -0,0 +1,23 @@
package io.github.openflocon.flocondesktop.features.network.ui.mapper
import io.github.openflocon.flocondesktop.features.network.domain.model.FloconHttpRequestDomainModel
import io.github.openflocon.flocondesktop.features.network.ui.model.NetworkItemViewState
fun toTypeUi(networkRequest: FloconHttpRequestDomainModel): NetworkItemViewState.NetworkTypeUi = when (val t = networkRequest.type) {
is FloconHttpRequestDomainModel.Type.GraphQl -> NetworkItemViewState.NetworkTypeUi.GraphQl(
queryName = t.query,
)
is FloconHttpRequestDomainModel.Type.Http -> {
val query = extractPath(networkRequest.url)
NetworkItemViewState.NetworkTypeUi.Url(
query = query,
)
}
is FloconHttpRequestDomainModel.Type.Grpc -> {
NetworkItemViewState.NetworkTypeUi.Grpc(
method = networkRequest.request.method,
)
}
}

View file

@ -7,7 +7,8 @@ data class NetworkDetailViewState(
val fullUrl: String,
val requestTimeFormatted: String,
val durationFormatted: String,
val method: NetworkMethodUi,
val method: Method,
val status: NetworkStatusUi,
val graphQlSection: GraphQlSection?,
@ -21,9 +22,19 @@ data class NetworkDetailViewState(
val responseSize: String,
val responseHeaders: List<NetworkDetailHeaderUi>,
) {
@Immutable
data class GraphQlSection(
val queryName: String,
val method: NetworkMethodUi,
val status: NetworkStatusUi,
)
@Immutable
sealed interface Method {
@Immutable
data class Http(val method: NetworkMethodUi) : Method
@Immutable
data class MethodName(val name: String) : Method
}
}

View file

@ -38,6 +38,13 @@ data class NetworkItemViewState(
) : NetworkTypeUi {
override fun contains(text: String): Boolean = queryName.contains(text, ignoreCase = true)
}
@Immutable
data class Grpc(
val method: String,
) : NetworkTypeUi {
override fun contains(text: String): Boolean = method.contains(text, ignoreCase = true)
}
}
}

View file

@ -44,6 +44,11 @@ sealed interface NetworkMethodUi {
}
}
data object Grpc : NetworkMethodUi {
override val text = "gRPC"
override val icon = null
}
data class OTHER(
override val text: String,
override val icon: DrawableResource?,

View file

@ -11,6 +11,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@ -93,7 +94,19 @@ fun NetworkDetailView(
label = "Method",
labelWidth = linesLabelWidth,
) {
MethodView(method = state.method)
when (val m = state.method) {
is NetworkDetailViewState.Method.Http -> MethodView(method = m.method)
is NetworkDetailViewState.Method.MethodName -> {
Text(
text = m.name,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(2f)
.background(color = FloconColors.pannel.copy(alpha = 0.8f), shape = RoundedCornerShape(4.dp))
.padding(horizontal = 8.dp, vertical = 6.dp),
)
}
}
}
DetailLineView(
modifier = Modifier.fillMaxWidth(),
@ -269,7 +282,7 @@ private fun NetworkDetailViewPreview() {
state =
NetworkDetailViewState(
fullUrl = "http://www.google.com",
method = NetworkMethodUi.Http.GET,
method = NetworkDetailViewState.Method.Http(NetworkMethodUi.Http.GET),
status =
NetworkStatusUi(
text = "200",

View file

@ -36,7 +36,7 @@ import org.jetbrains.compose.ui.tooling.preview.Preview
data class NetworkItemColumnWidths(
val dateWidth: Dp = 90.dp,
val methodWidth: Dp = 70.dp,
val statusCodeWidth: Dp = 60.dp,
val statusCodeWidth: Dp = 65.dp,
val requestSizeWidth: Dp = 65.dp,
val responseSizeWidth: Dp = 65.dp,
val timeWidth: Dp = 60.dp,
@ -151,6 +151,19 @@ fun NetworkItemView(
.padding(horizontal = 8.dp, vertical = 6.dp),
)
}
is NetworkItemViewState.NetworkTypeUi.Grpc -> {
Text(
text = type.method,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
style = bodySmall,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(2f)
.background(color = FloconColors.pannel.copy(alpha = 0.8f), shape = RoundedCornerShape(4.dp))
.padding(horizontal = 8.dp, vertical = 6.dp),
)
}
}
}

View file

@ -40,8 +40,11 @@ private val deleteMethodText = Color(0xFFDC3545)
private val otherMethodBackground = Color(0xFF6C757D).copy(alpha = 0.3f) // Muted gray for OTHER
private val otherMethodText = Color(0xFF6C757D)
private val grpcQueryMethodBackground = Color(0XAAE235A9).copy(alpha = 0.8f) // Muted gray for OTHER
private val grpcQueryMethodText = Color(0XAAFFFFFF)
private val graphQlQueryMethodBackground = Color(0XAAE235A9).copy(alpha = 0.8f) // Muted gray for OTHER
private val graphQlQueryMethodText = Color(0XAAFFFFFF)
private val grpcMethodBackground = Color(0xff71CCCB)
private val grpcMethodText = Color(0xff244B5A)
@Composable
fun MethodView(
@ -56,8 +59,9 @@ fun MethodView(
is NetworkMethodUi.OTHER -> otherMethodBackground to otherMethodText
is NetworkMethodUi.Http.POST -> postMethodBackground to postMethodText
is NetworkMethodUi.Http.PUT -> putMethodBackground to putMethodText
is NetworkMethodUi.GraphQl.QUERY -> grpcQueryMethodBackground to grpcQueryMethodText
is NetworkMethodUi.GraphQl.MUTATION -> grpcQueryMethodBackground to grpcQueryMethodText
is NetworkMethodUi.GraphQl.QUERY -> graphQlQueryMethodBackground to graphQlQueryMethodText
is NetworkMethodUi.GraphQl.MUTATION -> graphQlQueryMethodBackground to graphQlQueryMethodText
is NetworkMethodUi.Grpc -> grpcMethodBackground to grpcMethodText
}
NetworkTag(
@ -123,3 +127,11 @@ private fun MethodView_GraphQlQuery_Preview() {
MethodView(method = NetworkMethodUi.GraphQl.QUERY)
}
}
@Composable
@Preview
private fun MethodView_Ggrpc_Preview() {
FloconTheme {
MethodView(method = NetworkMethodUi.Grpc)
}
}

View file

@ -1,11 +1,13 @@
package io.github.openflocon.flocondesktop.features.network.ui.view.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.text.TextAutoSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.sp
import io.github.openflocon.flocondesktop.common.ui.FloconTheme
@ -25,18 +27,22 @@ fun StatusView(
textSize: TextUnit = 12.sp,
modifier: Modifier = Modifier,
) {
Text(
modifier = modifier,
text = status.text.toString(),
textAlign = TextAlign.Center,
fontSize = textSize,
color =
when (status.isSuccess) {
true -> successTagText
false -> errorTagText
},
style = MaterialTheme.typography.labelSmall, // Use typography for consistency
)
Box(modifier = modifier, contentAlignment = Alignment.Center) {
BasicText(
text = status.text.toString(),
autoSize = TextAutoSize.StepBased(
maxFontSize = textSize,
minFontSize = 8.sp,
),
maxLines = 1,
style = MaterialTheme.typography.labelSmall.copy(
color = when (status.isSuccess) {
true -> successTagText
false -> errorTagText
},
),
)
}
}
@Composable

View file

@ -17,7 +17,6 @@ import io.github.openflocon.flocondesktop.features.dashboard.ui.view.DashboardSc
import io.github.openflocon.flocondesktop.features.database.ui.view.DatabaseScreen
import io.github.openflocon.flocondesktop.features.deeplinks.ui.view.DeeplinkScreen
import io.github.openflocon.flocondesktop.features.files.ui.view.FilesScreen
import io.github.openflocon.flocondesktop.features.grpc.ui.view.GRPCScreen
import io.github.openflocon.flocondesktop.features.images.ui.view.ImagesScreen
import io.github.openflocon.flocondesktop.features.network.ui.view.NetworkScreen
import io.github.openflocon.flocondesktop.features.sharedpreferences.ui.view.SharedPreferencesScreen
@ -93,12 +92,6 @@ private fun MainScreen(
.fillMaxSize(),
)
SubScreen.GRPC ->
GRPCScreen(
modifier = Modifier
.fillMaxSize(),
)
SubScreen.Files ->
FilesScreen(
modifier = Modifier

View file

@ -75,7 +75,6 @@ fun buildLeftPanelState(selectedId: String?) = LeftPanelState(
items = listOf(
item(subScreen = SubScreen.Network, selectedId = selectedId),
item(subScreen = SubScreen.Images, selectedId = selectedId),
item(subScreen = SubScreen.GRPC, selectedId = selectedId),
),
),
LeftPannelSection(

View file

@ -7,8 +7,6 @@ enum class SubScreen {
Network,
Images, // network images
GRPC,
// storage
Database,
Files, // device files (context.cache, context.files)

View file

@ -6,7 +6,6 @@ import flocondesktop.composeapp.generated.resources.dashboard
import flocondesktop.composeapp.generated.resources.database
import flocondesktop.composeapp.generated.resources.deeplinks
import flocondesktop.composeapp.generated.resources.files
import flocondesktop.composeapp.generated.resources.grpc
import flocondesktop.composeapp.generated.resources.images
import flocondesktop.composeapp.generated.resources.network
import flocondesktop.composeapp.generated.resources.settings
@ -23,7 +22,6 @@ fun SubScreen.displayName(): String = when (this) {
SubScreen.Files -> "Files"
SubScreen.Tables -> "Tables"
SubScreen.Images -> "Images"
SubScreen.GRPC -> "gRPC"
SubScreen.SharedPreferences -> "SharedPreferences"
SubScreen.Dashboard -> "Dashboard"
SubScreen.Settings -> "Settings"
@ -37,7 +35,6 @@ fun SubScreen.icon(): DrawableResource = when (this) {
SubScreen.Database -> Res.drawable.database
SubScreen.Files -> Res.drawable.files
SubScreen.Tables -> Res.drawable.tables
SubScreen.GRPC -> Res.drawable.grpc
SubScreen.Images -> Res.drawable.images
SubScreen.SharedPreferences -> Res.drawable.sharedpreference
SubScreen.Settings -> Res.drawable.settings