refact: [NETWORK] filter from data (#297)

Co-authored-by: Florent Champigny <florent@bere.al>
This commit is contained in:
Florent CHAMPIGNY 2025-10-03 15:38:18 +02:00 • committed by GitHub
parent ce19c49d95
commit 57cc3e9711
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 133 additions and 175 deletions

View file

@ -13,6 +13,8 @@ import io.github.openflocon.domain.network.models.BadQualityConfigDomainModel
import io.github.openflocon.domain.network.models.FloconNetworkCallDomainModel
import io.github.openflocon.domain.network.models.MockNetworkDomainModel
import io.github.openflocon.domain.network.models.NetworkFilterDomainModel
import io.github.openflocon.domain.network.models.NetworkFilterDomainModel.Filters
import io.github.openflocon.domain.network.models.NetworkSortDomainModel
import io.github.openflocon.domain.network.models.NetworkTextFilterColumns
import io.github.openflocon.domain.network.usecase.DecodeJwtTokenUseCase
import io.github.openflocon.domain.network.usecase.ExportNetworkCallsToCsvUseCase
@ -118,17 +120,20 @@ class NetworkViewModel(
.flowOn(dispatcherProvider.viewModel)
.stateIn(viewModelScope, started = SharingStarted.WhileSubscribed(5_000), null)
private val filter = combines(
snapshotFlow { _filterText.value }.map { it.takeIf { it.isNotBlank() } },
headerDelegate.textFiltersState.map { it.toDomain() }
).map { (textFilters, filterOnAllColumns) ->
NetworkFilterDomainModel(
filterOnAllColumns = textFilters,
filters = filterOnAllColumns,
)
}
private val sortAndFilter = combines(
headerDelegate.sorted.map { it?.toDomain() }.distinctUntilChanged(),
snapshotFlow { _filterText.value }
.map { it.takeIf { it.isNotBlank() } }
.map {
NetworkFilterDomainModel(
filterOnAllColumns = it
)
}
.distinctUntilChanged()
)
filter,
).distinctUntilChanged()
private val networkItems: Flow<List<FloconNetworkCallDomainModel>> =
sortAndFilter.flatMapLatest { (sorted, filter) ->
@ -171,14 +176,11 @@ class NetworkViewModel(
private val filteredItems: Flow<List<NetworkItemViewState>> = combine(
items,
contentState,
filterConfig,
) { items, content, config ->
) { items, config ->
filterNetworkItemsProcessor(
items = items,
filterState = config.filterState,
allowedMethods = config.allowedMethods,
textFilters = config.textFilters,
)
}
.flowOn(dispatcherProvider.viewModel.limitedParallelism(1))
@ -438,3 +440,35 @@ class NetworkViewModel(
}
}
}
private fun Map<NetworkTextFilterColumns, TextFilterStateUiModel>.toDomain(): List<Filters>? {
return buildList {
this@toDomain.forEach { (column, filter) ->
if (filter.isEnabled) {
val includedFilters = filter.includedFilters.mapNotNull {
it.toDomain()
}
val excludedFilters = filter.excludedFilters.mapNotNull {
it.toDomain()
}
if (includedFilters.isNotEmpty() || excludedFilters.isNotEmpty()) {
add(
Filters(
column = column,
includedFilters = includedFilters,
excludedFilters = excludedFilters,
)
)
}
}
}
}
}
private fun TextFilterStateUiModel.FilterItem.toDomain(): NetworkFilterDomainModel.Filters.FilterItem? {
return if(isActive) {
NetworkFilterDomainModel.Filters.FilterItem(
text = text,
)
} else null
}

View file

@ -206,7 +206,6 @@ private fun TextFilterStateUiModel.performAction(
text = action.text,
isActive = true,
isExcluded = true,
isRegex = action.isRegex,
)
val exists = excludedFilters.any { it.text == newItem.text }
if (exists) {
@ -227,7 +226,6 @@ private fun TextFilterStateUiModel.performAction(
text = action.text,
isActive = true,
isExcluded = false,
isRegex = action.isRegex,
)
val exists = includedFilters.any { it.text == newItem.text }
if (exists) {

View file

@ -22,14 +22,12 @@ fun itemToDomain(item: TextFilterStateUiModel.FilterItem): TextFilterStateDomain
text = item.text,
isActive = item.isActive,
isExcluded = item.isExcluded,
isRegex = item.isRegex,
)
fun itemToUI(item: TextFilterStateDomainModel.FilterItem): TextFilterStateUiModel.FilterItem = TextFilterStateUiModel.FilterItem(
text = item.text,
isActive = item.isActive,
isExcluded = item.isExcluded,
isRegex = item.isRegex,
)

View file

@ -3,8 +3,8 @@ package io.github.openflocon.flocondesktop.features.network.list.model.header
import io.github.openflocon.flocondesktop.features.network.list.model.header.columns.base.filter.TextFilterStateUiModel
sealed interface TextFilterAction {
data class Include(val text: String, val isRegex: Boolean) : TextFilterAction
data class Exclude(val text: String, val isRegex: Boolean) : TextFilterAction
data class Include(val text: String) : TextFilterAction
data class Exclude(val text: String) : TextFilterAction
data class SetIsActive(val item: TextFilterStateUiModel.FilterItem, val isActive: Boolean) : TextFilterAction
data class Delete(val item: TextFilterStateUiModel.FilterItem) : TextFilterAction
}

View file

@ -17,7 +17,6 @@ data class TextFilterStateUiModel(
val text: String,
val isActive: Boolean,
val isExcluded: Boolean,
val isRegex: Boolean,
)
override val isActive: Boolean = allFilters.isNotEmpty() && isEnabled && allFilters.any { it.isActive }
@ -34,11 +33,11 @@ data class TextFilterStateUiModel(
fun previewTextFilterState() = TextFilterStateUiModel(
isEnabled = true,
includedFilters = listOf(
FilterItem(text = "toInclude", isExcluded = false, isActive = true, isRegex = false),
FilterItem(text = "toInclude2", isExcluded = false, isActive = true, isRegex = true),
FilterItem(text = "toInclude", isExcluded = false, isActive = true),
FilterItem(text = "toInclude2", isExcluded = false, isActive = true),
),
excludedFilters = listOf(
FilterItem(text = "toExclude", isExcluded = true, isActive = true, isRegex = false),
FilterItem(text = "toExclude2", isExcluded = true, isActive = false, isRegex = true),
FilterItem(text = "toExclude", isExcluded = true, isActive = true),
FilterItem(text = "toExclude2", isExcluded = true, isActive = false),
),
)

View file

@ -1,104 +1,17 @@
package io.github.openflocon.flocondesktop.features.network.list.processor
import io.github.openflocon.domain.network.models.FloconNetworkCallDomainModel
import io.github.openflocon.domain.network.models.NetworkTextFilterColumns
import io.github.openflocon.flocondesktop.features.network.list.model.TopBarUiState
import io.github.openflocon.flocondesktop.features.network.list.model.NetworkItemViewState
import io.github.openflocon.flocondesktop.features.network.list.model.NetworkMethodUi
import io.github.openflocon.flocondesktop.features.network.list.model.header.columns.base.filter.TextFilterStateUiModel
class FilterNetworkItemsProcessor {
operator fun invoke(
items: List<Pair<FloconNetworkCallDomainModel, NetworkItemViewState>>,
filterState: TopBarUiState,
allowedMethods: List<NetworkMethodUi>,
textFilters: Map<NetworkTextFilterColumns, TextFilterStateUiModel>,
): List<NetworkItemViewState> = items.asSequence()
.filter { item ->
item.second.method in allowedMethods
}
.filter { item ->
val filters = textFilters.filter { it.value.isActive }
if(filters.isEmpty()) true
else {
filters.any { (column, textFilter) ->
textFilter.filter(column, item)
}
}
}
.map { it.second }
.toList()
}
private fun TextFilterStateUiModel.filter(
column: NetworkTextFilterColumns,
item: Pair<FloconNetworkCallDomainModel, NetworkItemViewState>,
): Boolean {
val text = when (column) {
NetworkTextFilterColumns.RequestTime -> item.second.dateFormatted
NetworkTextFilterColumns.Domain -> item.second.domain
NetworkTextFilterColumns.Query -> item.second.type.text
NetworkTextFilterColumns.Status -> item.second.status.text
NetworkTextFilterColumns.Time -> item.second.timeFormatted
}
return filterByText(text)
}
private fun TextFilterStateUiModel.filterByText(text: String?): Boolean {
// 1. If text is null, it always passes the filter (default behavior).
if (text == null) {
return true
}
val activeFilters = this.allFilters.filter { it.isActive }
// 2. If there are no active filters, it always passes.
if (activeFilters.isEmpty()) {
return true
}
val (excludedFilters, includedFilters) = activeFilters.partition { it.isExcluded }
// --- Step 1: Exclusion Check ---
// 4. Check if the text matches ANY exclusion filter.
// If ANY active 'isExcluded' filter matches, the item MUST be rejected (return false).
val isExcluded = excludedFilters.any { it.matches(text) }
if (isExcluded) {
return false // Excluded, so it fails the filter immediately.
}
// --- Step 2: Inclusion Check ---
// 5. If there are no inclusion filters, the item passes (as it wasn't excluded).
if (includedFilters.isEmpty()) {
return true
}
// 6. If not excluded, check if the text matches AT LEAST ONE inclusion filter.
// If ANY non-excluded filter matches, the item is kept (return true).
return includedFilters.any { it.matches(text) }
}
private fun TextFilterStateUiModel.FilterItem.matches(text: String): Boolean {
return if (this.isRegex) {
Regex(this.text, setOf(RegexOption.IGNORE_CASE)).containsMatchIn(text)
} else {
text.contains(this.text, ignoreCase = true)
}
}
/**
* lookup if the request or response body contains the text
*/
private fun FloconNetworkCallDomainModel.contains(text: String) : Boolean {
return request.body?.contains(text, ignoreCase = true) == true || response?.contains(text) == true
}
private fun FloconNetworkCallDomainModel.Response.contains(text: String) : Boolean {
return when(this) {
is FloconNetworkCallDomainModel.Response.Failure -> issue.contains(text, ignoreCase = true)
is FloconNetworkCallDomainModel.Response.Success -> body?.contains(text, ignoreCase = true) == true
}
}

View file

@ -175,7 +175,7 @@ private fun contextualActions(
NetworkAction.HeaderAction.FilterAction(
OnFilterAction.TextFilter(
column = NetworkTextFilterColumns.Domain,
action = TextFilterAction.Include(text = state.domain, isRegex = false)
action = TextFilterAction.Include(text = state.domain)
)
)
)
@ -185,7 +185,7 @@ private fun contextualActions(
NetworkAction.HeaderAction.FilterAction(
OnFilterAction.TextFilter(
column = NetworkTextFilterColumns.Query,
action = TextFilterAction.Include(text = state.type.query, isRegex = false)
action = TextFilterAction.Include(text = state.type.query)
)
)
)
@ -197,7 +197,7 @@ private fun contextualActions(
NetworkAction.HeaderAction.FilterAction(
OnFilterAction.TextFilter(
column = NetworkTextFilterColumns.Domain,
action = TextFilterAction.Exclude(text = state.domain, isRegex = false)
action = TextFilterAction.Exclude(text = state.domain)
)
)
)
@ -207,7 +207,7 @@ private fun contextualActions(
NetworkAction.HeaderAction.FilterAction(
OnFilterAction.TextFilter(
column = NetworkTextFilterColumns.Query,
action = TextFilterAction.Exclude(text = state.type.query, isRegex = false)
action = TextFilterAction.Exclude(text = state.type.query)
)
)
)

View file

@ -58,11 +58,11 @@ fun TextFilterDropdownContent(
modifier = modifier.fillMaxWidth()
) {
TextFilterFieldView(
submitTextField = { text, toInclude, isRegex ->
submitTextField = { text, toInclude ->
if (toInclude) {
textFilterAction.invoke(TextFilterAction.Include(text, isRegex = isRegex))
textFilterAction.invoke(TextFilterAction.Include(text))
} else {
textFilterAction.invoke(TextFilterAction.Exclude(text, isRegex = isRegex))
textFilterAction.invoke(TextFilterAction.Exclude(text))
}
},
modifier = Modifier
@ -196,11 +196,10 @@ private fun FilterItemView(
@Composable
private fun TextFilterFieldView(
submitTextField: (value: String, toInclude: Boolean, isRegex: Boolean) -> Unit,
submitTextField: (value: String, toInclude: Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
var value by remember { mutableStateOf("") }
var isRegex by remember { mutableStateOf(false) }
Row(
modifier = modifier,
@ -213,7 +212,7 @@ private fun TextFilterFieldView(
keyboardActions = KeyboardActions(
onDone = {
// default action -> add as "include filter"
submitTextField(value, true, isRegex)
submitTextField(value, true)
value = "" // reset
},
),
@ -221,15 +220,6 @@ private fun TextFilterFieldView(
textStyle = FloconTheme.typography.bodySmall.copy(
color = FloconTheme.colorPalette.onSecondary,
),
trailingComponent = {
RegexFilterButton(
modifier = Modifier.size(24.dp),
isRegex = isRegex,
onClick = {
isRegex = !isRegex
},
)
},
containerColor = FloconTheme.colorPalette.secondary,
modifier = Modifier
.weight(1f)
@ -238,7 +228,7 @@ private fun TextFilterFieldView(
FloconSmallIconButton(
enabled = value.isNotEmpty(),
onClick = {
submitTextField(value, true, isRegex)
submitTextField(value, true)
value = "" // reset
}
) {
@ -247,7 +237,7 @@ private fun TextFilterFieldView(
FloconSmallIconButton(
enabled = value.isNotEmpty(),
onClick = {
submitTextField(value, false, isRegex)
submitTextField(value, false)
value = "" // reset
}
) {
@ -281,30 +271,6 @@ private fun TextFilterButton(
}
}
@Composable
private fun RegexFilterButton(
onClick: () -> Unit,
isRegex: Boolean,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.size(20.dp)
.clip(RoundedCornerShape(6.dp))
.graphicsLayer { alpha = if (isRegex) 1f else 0.5f }
.background(Color.White)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center,
) {
Text(
".*",
fontSize = 11.sp,
color = FloconTheme.colorPalette.surface,
)
}
}
@Preview
@Composable
private fun TextFilterDropdownContentPreview() {

View file

@ -4,16 +4,16 @@ import io.github.openflocon.data.core.network.datasource.NetworkFilterLocalDataS
import io.github.openflocon.data.local.network.dao.NetworkFilterDao
import io.github.openflocon.data.local.network.mapper.textFilterToDomain
import io.github.openflocon.data.local.network.mapper.textFilterToEntity
import io.github.openflocon.domain.device.models.AppPackageName
import io.github.openflocon.domain.device.models.DeviceId
import io.github.openflocon.domain.device.models.DeviceIdAndPackageNameDomainModel
import io.github.openflocon.domain.models.TextFilterStateDomainModel
import io.github.openflocon.domain.network.models.NetworkTextFilterColumns
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.serialization.json.Json
class NetworkFilterLocalDataSourceRoom(
private val networkFilterDao: NetworkFilterDao,
private val json: Json,
) : NetworkFilterLocalDataSource {
override suspend fun get(
@ -23,9 +23,9 @@ class NetworkFilterLocalDataSourceRoom(
deviceId = deviceAndApp.deviceId,
packageName = deviceAndApp.packageName,
column = column,
)?.let {
textFilterToDomain(it)
}
)?.textFilterToDomain(
json = json,
)
override fun observe(
deviceAndApp: DeviceIdAndPackageNameDomainModel,
@ -34,7 +34,9 @@ class NetworkFilterLocalDataSourceRoom(
packageName = deviceAndApp.packageName,
).map {
it.associate {
it.columnName to textFilterToDomain(it)
it.columnName to it.textFilterToDomain(
json = json,
)
}
}
@ -49,6 +51,7 @@ class NetworkFilterLocalDataSourceRoom(
packageName = deviceAndApp.packageName,
column = column,
domain = newValue,
json = json,
),
)
}

View file

@ -10,6 +10,7 @@ import io.github.openflocon.domain.device.models.DeviceIdAndPackageNameDomainMod
import io.github.openflocon.domain.network.models.FloconNetworkCallDomainModel
import io.github.openflocon.domain.network.models.NetworkFilterDomainModel
import io.github.openflocon.domain.network.models.NetworkSortDomainModel
import io.github.openflocon.domain.network.models.NetworkTextFilterColumns
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
@ -63,24 +64,54 @@ class NetworkLocalDataSourceRoom(
columns.forEachIndexed { index, column ->
appendLine("$column LIKE ? COLLATE NOCASE")
args.add("%$filterText%")
if(index != columns.lastIndex) {
if (index != columns.lastIndex) {
appendLine("OR")
}
}
appendLine(")")
}
filter.filters?.forEach { filter ->
filter.includedFilters.takeIf { it.isNotEmpty() }?.let { included ->
appendLine("AND (")
included.forEachIndexed { includedFilterIndex, filterItem ->
appendLine("\t${filter.column.roomColumnName()} LIKE ? COLLATE NOCASE")
args.add("%${filterItem.text}%")
if (includedFilterIndex != included.lastIndex) {
appendLine("\tOR")
}
}
appendLine(")")
}
filter.excludedFilters.takeIf { it.isNotEmpty() }?.let { excluded ->
appendLine("AND (")
excluded.forEachIndexed { excludedFilterIndex, filterItem ->
appendLine("\t${filter.column.roomColumnName()} NOT LIKE ? COLLATE NOCASE")
args.add("%${filterItem.text}%")
if (excludedFilterIndex != excluded.lastIndex) {
appendLine("\tAND")
}
}
appendLine(")")
}
}
appendLine("ORDER BY $safeColumnName $sortOrder")
}.trimIndent()
val roomQuery = RoomRawQuery(sqlQuery, onBindStatement = {
args.forEachIndexed { index, item ->
when(item) {
when (item) {
is String -> {
it.bindText(index + 1, item)
}
is Int -> {
it.bindInt(index + 1, item)
}
is Long -> {
it.bindLong(index + 1, item)
}
@ -115,7 +146,7 @@ class NetworkLocalDataSourceRoom(
override suspend fun getCall(
deviceIdAndPackageName: DeviceIdAndPackageNameDomainModel,
callId: String,
): FloconNetworkCallDomainModel? {
): FloconNetworkCallDomainModel? {
return floconNetworkDao
.getCallById(
deviceId = deviceIdAndPackageName.deviceId,
@ -182,6 +213,14 @@ class NetworkLocalDataSourceRoom(
}
}
// TODO maybe merge with NetworkSortDomainModel.Column
private fun NetworkTextFilterColumns.roomColumnName(): String = when (this) {
NetworkTextFilterColumns.RequestTime -> "request_startTimeFormatted"
NetworkTextFilterColumns.Domain -> "request_domainFormatted"
NetworkTextFilterColumns.Query -> "request_queryFormatted"
NetworkTextFilterColumns.Status -> "response_statusFormatted"
NetworkTextFilterColumns.Time -> "response_durationMs"
}
private fun NetworkSortDomainModel.Column.roomColumnName(): String = when (this) {
NetworkSortDomainModel.Column.RequestStartTimeFormatted -> "request_startTimeFormatted"

View file

@ -12,17 +12,16 @@ fun textFilterItemToEntity(item: TextFilterStateDomainModel.FilterItem): FilterI
text = item.text,
isActive = item.isActive,
isExcluded = item.isExcluded,
isRegex = item.isRegex,
)
fun textFilterItemToDomain(item: FilterItemSavedEntity): TextFilterStateDomainModel.FilterItem = TextFilterStateDomainModel.FilterItem(
text = item.text,
isActive = item.isActive,
isExcluded = item.isExcluded,
isRegex = item.isRegex,
)
fun textFilterToEntity(
json: Json,
deviceId: DeviceId,
packageName: AppPackageName,
column: NetworkTextFilterColumns,
@ -36,16 +35,16 @@ fun textFilterToEntity(
packageName = packageName,
columnName = column,
isEnabled = domain.isEnabled,
itemsAsJson = Json.encodeToString(itemsEntity),
itemsAsJson = json.encodeToString(itemsEntity),
)
}
fun textFilterToDomain(
entity: NetworkFilterEntity,
fun NetworkFilterEntity.textFilterToDomain(
json: Json,
): TextFilterStateDomainModel {
val itemsEntity = Json.decodeFromString<List<FilterItemSavedEntity>>(entity.itemsAsJson)
val itemsEntity = json.decodeFromString<List<FilterItemSavedEntity>>(itemsAsJson)
return TextFilterStateDomainModel(
isEnabled = entity.isEnabled,
isEnabled = isEnabled,
items = itemsEntity.map {
textFilterItemToDomain(it)
},

View file

@ -7,5 +7,4 @@ data class FilterItemSavedEntity(
val text: String,
val isActive: Boolean,
val isExcluded: Boolean,
val isRegex: Boolean,
)

View file

@ -8,6 +8,5 @@ data class TextFilterStateDomainModel(
val text: String,
val isActive: Boolean,
val isExcluded: Boolean,
val isRegex: Boolean,
)
}

View file

@ -2,4 +2,15 @@ package io.github.openflocon.domain.network.models
data class NetworkFilterDomainModel(
val filterOnAllColumns: String?,
)
val filters: List<Filters>?,
) {
data class Filters(
val column: NetworkTextFilterColumns,
val includedFilters: List<FilterItem>,
val excludedFilters: List<FilterItem>,
) {
data class FilterItem(
val text: String,
)
}
}