Skip to content

Commit c1ad2aa

Browse files
committed
v4.6.0: Latency sparkline, source stats, search history, query type chart
- Home: DNS latency sparkline (live mini-graph of last 20 response times) - Home: Average latency display next to query rate (e.g. "42 ms") - Home: Search history chips (last 6 recent searches from DataStore) - Sources: Summary stats row (total domains, total size, unhealthy count) - Stats: Query type distribution chart with color-coded bars - AppPreferences: search history (10 entries, DataStore-persisted) - DAO: getQueryTypeDistribution + QueryTypeStat projection
1 parent 94608c3 commit c1ad2aa

6 files changed

Lines changed: 128 additions & 8 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# HostShield
22

33
## Overview
4-
Modern, AMOLED-dark hosts-based ad blocker app for Android. Inspired by AdAway. v4.5.0.
4+
Modern, AMOLED-dark hosts-based ad blocker app for Android. Inspired by AdAway. v4.6.0.
55

66
## Tech Stack
77
- Kotlin, Jetpack Compose, Material 3
@@ -70,6 +70,7 @@ cd app
7070
- Secrets configured: `KEYSTORE_BASE64`, `KEY_ALIAS`, `KEY_PASSWORD`, `STORE_PASSWORD`
7171

7272
## Version History
73+
- v4.6.0: DNS latency sparkline on Home (live response time mini-graph), source summary stats on Sources screen (total domains, size, unhealthy count), search history persistence (DataStore, 10 recent, chip display), search history chips on Home
7374
- v4.5.0: Query type distribution chart in Stats (A/AAAA/CNAME/MX bar chart), per-app DNS log drill-down (AppLogsScreen with domains + timeline tabs), permanent block/allow buttons in log detail sheet, log cleanup worker improved (6h interval, battery-not-low constraint)
7475
- v4.4.0: Connection log interface labels (rmnet0=Mobile, wlan0=WiFi, etc), DNS cache management in Settings (clear cache button + live stats), expanded notification (Pause 5m / Pause 30m / Stop), top querying apps mini-card on Home dashboard
7576
- v4.3.2: UI fixes — FlowRow wrapping for category chips (was smushed in single Row), larger text fields (search bar, custom DNS), FlowRow for DoH provider selector and feature pills, removed fixed heights that clipped text

app/app/build.gradle.kts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// HostShield v4.5.0
1+
// HostShield v4.6.0
22
plugins {
33
id("com.android.application")
44
id("org.jetbrains.kotlin.android")
@@ -15,8 +15,8 @@ android {
1515
applicationId = "com.hostshield"
1616
minSdk = 26
1717
targetSdk = 35
18-
versionCode = 47
19-
versionName = "4.5.0"
18+
versionCode = 48
19+
versionName = "4.6.0"
2020

2121
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
2222

app/app/src/main/java/com/hostshield/data/preferences/AppPreferences.kt

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,4 +241,25 @@ class AppPreferences @Inject constructor(
241241
return if (raw.isBlank()) emptyList()
242242
else raw.split(",").map { it.trim() }.filter { it.startsWith("http") }
243243
}
244+
245+
// ── Search History ────────────────────────────────────────
246+
private object SearchKeys {
247+
val SEARCH_HISTORY = stringPreferencesKey("search_history")
248+
}
249+
250+
val searchHistory: Flow<List<String>> = ds.data.map {
251+
(it[SearchKeys.SEARCH_HISTORY] ?: "").split("\n").filter { s -> s.isNotBlank() }
252+
}
253+
254+
suspend fun addSearchQuery(query: String) {
255+
val trimmed = query.trim().lowercase()
256+
if (trimmed.length < 2) return
257+
ds.edit {
258+
val current = (it[SearchKeys.SEARCH_HISTORY] ?: "").split("\n").filter { s -> s.isNotBlank() }
259+
val updated = (listOf(trimmed) + current.filter { s -> s != trimmed }).take(10)
260+
it[SearchKeys.SEARCH_HISTORY] = updated.joinToString("\n")
261+
}
262+
}
263+
264+
suspend fun clearSearchHistory() = ds.edit { it[SearchKeys.SEARCH_HISTORY] = "" }
244265
}

app/app/src/main/java/com/hostshield/ui/screens/home/HomeScreen.kt

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,26 @@ fun HomeScreen(
115115
cursorColor = Teal, focusedTextColor = TextPrimary, unfocusedTextColor = TextPrimary
116116
)
117117
)
118+
// Search history chips when field is focused but empty
119+
val searchHistory by viewModel.searchHistory.collectAsStateWithLifecycle()
120+
if (searchQuery.isBlank() && searchHistory.isNotEmpty()) {
121+
androidx.compose.foundation.layout.FlowRow(
122+
modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 4.dp),
123+
horizontalArrangement = Arrangement.spacedBy(6.dp),
124+
verticalArrangement = Arrangement.spacedBy(4.dp)
125+
) {
126+
searchHistory.take(6).forEach { term ->
127+
Surface(
128+
onClick = { searchQuery = term; searchExpanded = true; viewModel.saveSearch(term) },
129+
shape = RoundedCornerShape(16.dp),
130+
color = Surface2
131+
) {
132+
Text(term, modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
133+
color = TextDim, fontSize = 11.sp)
134+
}
135+
}
136+
}
137+
}
118138
AnimatedVisibility(visible = searchExpanded && searchQuery.length >= 2) {
119139
Column(modifier = Modifier.padding(horizontal = 20.dp, vertical = 4.dp)) {
120140
Surface(
@@ -406,7 +426,7 @@ fun HomeScreen(
406426
}
407427
}
408428

409-
// Live query rate
429+
// Live query rate + latency sparkline
410430
if (state.isEnabled && (state.queriesPerMinute > 0 || state.blocksPerMinute > 0)) {
411431
Spacer(Modifier.height(8.dp))
412432
Row(
@@ -419,6 +439,36 @@ fun HomeScreen(
419439
Spacer(Modifier.width(16.dp))
420440
Text("${state.blocksPerMinute}", color = Red, fontSize = 14.sp, fontWeight = FontWeight.Bold)
421441
Text(" blk/min", color = TextDim, fontSize = 10.sp)
442+
if (state.avgLatencyMs > 0) {
443+
Spacer(Modifier.width(16.dp))
444+
Text("${state.avgLatencyMs}", color = Peach, fontSize = 14.sp, fontWeight = FontWeight.Bold)
445+
Text(" ms", color = TextDim, fontSize = 10.sp)
446+
}
447+
}
448+
// Latency sparkline
449+
if (state.latencySparkline.size >= 3) {
450+
Spacer(Modifier.height(6.dp))
451+
Canvas(
452+
modifier = Modifier.fillMaxWidth().padding(horizontal = 40.dp).height(24.dp)
453+
) {
454+
val points = state.latencySparkline
455+
val maxVal = points.max().toFloat().coerceAtLeast(1f)
456+
val stepX = size.width / (points.size - 1).coerceAtLeast(1)
457+
val path = androidx.compose.ui.graphics.Path()
458+
points.forEachIndexed { i, v ->
459+
val x = i * stepX
460+
val y = size.height - (v / maxVal * size.height * 0.9f)
461+
if (i == 0) path.moveTo(x, y) else path.lineTo(x, y)
462+
}
463+
drawPath(
464+
path = path,
465+
color = Peach.copy(alpha = 0.6f),
466+
style = androidx.compose.ui.graphics.drawscope.Stroke(
467+
width = 2f,
468+
cap = androidx.compose.ui.graphics.StrokeCap.Round
469+
)
470+
)
471+
}
422472
}
423473
}
424474

app/app/src/main/java/com/hostshield/ui/screens/home/HomeViewModel.kt

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,11 @@ data class HomeUiState(
8585
/** Dropped queries from buffer overflow. */
8686
val droppedQueries: Int = 0,
8787
/** Top querying apps (up to 3). */
88-
val topApps: List<Triple<String, String, Int>> = emptyList() // (package, label, count)
88+
val topApps: List<Triple<String, String, Int>> = emptyList(), // (package, label, count)
89+
/** Recent DNS latencies for sparkline (last 20 values). */
90+
val latencySparkline: List<Int> = emptyList(),
91+
/** Average latency from sparkline. */
92+
val avgLatencyMs: Int = 0
8993
)
9094

9195
@HiltViewModel
@@ -201,12 +205,19 @@ class HomeViewModel @Inject constructor(
201205
val cacheStats = DnsVpnService.currentCacheStats
202206
val dropped = DnsVpnService.currentDroppedQueries
203207

208+
// Build latency sparkline from recent entries with response times
209+
val latencies = recent.filter { it.responseTimeMs > 0 && !it.blocked }
210+
.take(20).map { it.responseTimeMs }
211+
val avgLatency = if (latencies.isNotEmpty()) latencies.average().toInt() else 0
212+
204213
_uiState.update { it.copy(
205214
queriesPerMinute = recentQueries,
206215
blocksPerMinute = recentBlocks,
207216
queryAnomalyWarning = anomalyWarning,
208217
cacheHitRate = cacheStats?.hitRate ?: 0f,
209-
droppedQueries = dropped
218+
droppedQueries = dropped,
219+
latencySparkline = latencies.reversed(), // oldest first for left-to-right
220+
avgLatencyMs = avgLatency
210221
) }
211222
}
212223
}
@@ -735,6 +746,10 @@ class HomeViewModel @Inject constructor(
735746

736747
// -- Helpers -------------------------------------------------------
737748

749+
// Search history
750+
val searchHistory = prefs.searchHistory.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
751+
fun saveSearch(query: String) { viewModelScope.launch { prefs.addSearchQuery(query) } }
752+
738753
fun dismissError() { _uiState.update { it.copy(errorMessage = null) } }
739754
fun dismissSnackbar() { _uiState.update { it.copy(snackbarMessage = null) } }
740755
fun dismissPrivateDnsWarning() { _uiState.update { it.copy(privateDnsWarning = null) } }

app/app/src/main/java/com/hostshield/ui/screens/sources/SourcesScreen.kt

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,40 @@ fun SourcesScreen(
135135
Spacer(Modifier.height(4.dp))
136136
Text(msg, color = TextDim, fontSize = 11.sp)
137137
}
138-
Spacer(Modifier.height(12.dp))
138+
Spacer(Modifier.height(8.dp))
139+
140+
// Summary stats
141+
val totalDomains = sources.filter { it.enabled }.sumOf { it.entryCount }
142+
val totalSize = sources.filter { it.enabled }.sumOf { it.sizeBytes }
143+
val unhealthy = sources.count { it.health == SourceHealth.ERROR || it.health == SourceHealth.DEAD }
144+
Row(
145+
modifier = Modifier.fillMaxWidth(),
146+
horizontalArrangement = Arrangement.spacedBy(8.dp)
147+
) {
148+
Surface(shape = RoundedCornerShape(8.dp), color = Surface2, modifier = Modifier.weight(1f)) {
149+
Column(modifier = Modifier.padding(10.dp), horizontalAlignment = Alignment.CenterHorizontally) {
150+
Text(NumberFormat.getNumberInstance().format(totalDomains), color = Teal, fontWeight = FontWeight.Bold, fontSize = 14.sp)
151+
Text("Domains", color = TextDim, fontSize = 9.sp)
152+
}
153+
}
154+
Surface(shape = RoundedCornerShape(8.dp), color = Surface2, modifier = Modifier.weight(1f)) {
155+
Column(modifier = Modifier.padding(10.dp), horizontalAlignment = Alignment.CenterHorizontally) {
156+
val sizeLabel = if (totalSize > 1_000_000) "${"%.1f".format(totalSize / 1_000_000f)} MB"
157+
else if (totalSize > 1000) "${totalSize / 1000} KB" else "$totalSize B"
158+
Text(sizeLabel, color = Blue, fontWeight = FontWeight.Bold, fontSize = 14.sp)
159+
Text("Total Size", color = TextDim, fontSize = 9.sp)
160+
}
161+
}
162+
if (unhealthy > 0) {
163+
Surface(shape = RoundedCornerShape(8.dp), color = Red.copy(alpha = 0.08f), modifier = Modifier.weight(1f)) {
164+
Column(modifier = Modifier.padding(10.dp), horizontalAlignment = Alignment.CenterHorizontally) {
165+
Text("$unhealthy", color = Red, fontWeight = FontWeight.Bold, fontSize = 14.sp)
166+
Text("Unhealthy", color = Red.copy(alpha = 0.7f), fontSize = 9.sp)
167+
}
168+
}
169+
}
170+
}
171+
Spacer(Modifier.height(8.dp))
139172
}
140173

141174
val grouped = sources.groupBy { it.category }

0 commit comments

Comments
 (0)