Skip to content

Commit 94608c3

Browse files
committed
v4.5.0: Query type chart, per-app drill-down, detail sheet actions
- Stats: Query type distribution chart (A/AAAA/CNAME/MX/etc) with color-coded horizontal bars showing percentage and count over 7 days - New screen: AppLogsScreen — per-app DNS log with domains tab (aggregated counts) and timeline tab (chronological entries with latency). Navigable from Home's Top Querying Apps card via tap - Log detail sheet: permanent Block Domain / Allow Domain buttons added to Quick Actions section (complement existing temporary allow) - LogCleanupWorker: 6h interval (was 12h), battery-not-low constraint - DAO: getQueryTypeDistribution query + QueryTypeStat projection class - Navigation: APP_LOGS route with pkg argument
1 parent 5f631ac commit 94608c3

11 files changed

Lines changed: 340 additions & 13 deletions

File tree

CLAUDE.md

Lines changed: 4 additions & 3 deletions
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.4.0.
4+
Modern, AMOLED-dark hosts-based ad blocker app for Android. Inspired by AdAway. v4.5.0.
55

66
## Tech Stack
77
- Kotlin, Jetpack Compose, Material 3
@@ -47,8 +47,8 @@ Modern, AMOLED-dark hosts-based ad blocker app for Android. Inspired by AdAway.
4747
- `app/app/src/main/java/com/hostshield/di/DatabaseModule.kt` - Hilt DI (DB + singleton OkHttpClient)
4848
- `app/app/src/main/assets/curated_blocklists.json` - 70+ categorized blocklist definitions
4949

50-
## Screens (23+)
51-
Home, Sources, Rules, Stats, Settings, Logs, Apps, AppPrivacy, Firewall (DNS/Network/Context tabs), ConnectionLog, DnsTools, NetworkStats, OverlapAnalysis, DnsLeakTest, RuleTest, HostsEditor, HostsDiff, AppExclusions, Onboarding (with Private DNS warning), BlocklistGallery, AutomationAudit
50+
## Screens (24+)
51+
Home, Sources, Rules, Stats, Settings, Logs, Apps, AppPrivacy, AppLogs, Firewall (DNS/Network/Context tabs), ConnectionLog, DnsTools, NetworkStats, OverlapAnalysis, DnsLeakTest, RuleTest, HostsEditor, HostsDiff, AppExclusions, Onboarding (with Private DNS warning), BlocklistGallery, AutomationAudit
5252

5353
## Build
5454
```bash
@@ -70,6 +70,7 @@ cd app
7070
- Secrets configured: `KEYSTORE_BASE64`, `KEY_ALIAS`, `KEY_PASSWORD`, `STORE_PASSWORD`
7171

7272
## Version History
73+
- 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)
7374
- 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
7475
- 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
7576
- v4.3.1: Bug audit — fix AutomationReceiver rate limiting (static companion state), GeoIpLookup atomic CAS window reset, SourceHealthWorker ensures alert channel exists, pauseResumeJob @Volatile, baselineRates synchronized list

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.4.0
1+
// HostShield v4.5.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 = 46
19-
versionName = "4.4.0"
18+
versionCode = 47
19+
versionName = "4.5.0"
2020

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

app/app/src/main/java/com/hostshield/MainActivity.kt

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -300,7 +300,8 @@ private fun HostShieldMainApp(activity: MainActivity) {
300300
onNavigateToApps = { navController.navigate(SubScreen.APPS) },
301301
onNavigateToFirewall = { navController.navigate(SubScreen.FIREWALL) },
302302
onNavigateToConnectionLog = { navController.navigate(SubScreen.CONNECTION_LOG) },
303-
onRequestVpnPermission = { onResult -> activity.requestVpnPermission(onResult) }
303+
onRequestVpnPermission = { onResult -> activity.requestVpnPermission(onResult) },
304+
onNavigateToAppLogs = { pkg -> navController.navigate("${SubScreen.APP_LOGS}?pkg=$pkg") }
304305
)
305306
}
306307
composable(Screen.Sources.route) {
@@ -395,6 +396,16 @@ private fun HostShieldMainApp(activity: MainActivity) {
395396
onBack = { navController.popBackStack() }
396397
)
397398
}
399+
composable(
400+
"${SubScreen.APP_LOGS}?pkg={pkg}",
401+
arguments = listOf(androidx.navigation.navArgument("pkg") { defaultValue = "" })
402+
) { entry ->
403+
val pkg = entry.arguments?.getString("pkg") ?: ""
404+
com.hostshield.ui.screens.logs.AppLogsScreen(
405+
packageName = pkg,
406+
onBack = { navController.popBackStack() }
407+
)
408+
}
398409
}
399410
}
400411
}

app/app/src/main/java/com/hostshield/data/database/Daos.kt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,14 @@ interface DnsLogDao {
231231
""")
232232
suspend fun getOlderBlockedDomains(olderStart: Long, olderEnd: Long, limit: Int = 30): List<TopHostname>
233233

234+
/** Query type distribution (A, AAAA, CNAME, MX, etc). */
235+
@Query("""
236+
SELECT query_type as queryType, COUNT(*) as cnt
237+
FROM dns_logs WHERE timestamp > :since
238+
GROUP BY query_type ORDER BY cnt DESC LIMIT :limit
239+
""")
240+
fun getQueryTypeDistribution(since: Long, limit: Int = 10): Flow<List<QueryTypeStat>>
241+
234242
/** Average DNS response time per hour (for latency chart). */
235243
@Query("""
236244
SELECT CAST((timestamp / 3600000) % 24 AS INTEGER) as hour,
@@ -424,6 +432,11 @@ data class FirewallTopApp(
424432
val cnt: Int
425433
)
426434

435+
data class QueryTypeStat(
436+
val queryType: String,
437+
val cnt: Int
438+
)
439+
427440
data class HourlyLatency(
428441
val hour: Int,
429442
val avgMs: Float,

app/app/src/main/java/com/hostshield/data/repository/HostShieldRepository.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ class HostShieldRepository @Inject constructor(
6969

7070
fun getDailyBreakdown(since: Long): Flow<List<com.hostshield.data.database.DailyBreakdown>> = logDao.getDailyBreakdown(since)
7171
fun getHourlyLatency(since: Long): Flow<List<com.hostshield.data.database.HourlyLatency>> = logDao.getHourlyLatency(since)
72+
fun getQueryTypeDistribution(since: Long): Flow<List<com.hostshield.data.database.QueryTypeStat>> = logDao.getQueryTypeDistribution(since)
7273
suspend fun logDnsQuery(entry: DnsLogEntry) = logDao.insert(entry)
7374
suspend fun clearOldLogs(olderThanMs: Long) = logDao.deleteOlderThan(System.currentTimeMillis() - olderThanMs)
7475
suspend fun clearAllLogs() = logDao.deleteAll()

app/app/src/main/java/com/hostshield/service/LogCleanupWorker.kt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,10 @@ class LogCleanupWorker @AssistedInject constructor(
3232

3333
fun schedule(context: Context) {
3434
val request = PeriodicWorkRequestBuilder<LogCleanupWorker>(
35-
12, TimeUnit.HOURS
36-
).build()
35+
6, TimeUnit.HOURS
36+
)
37+
.setConstraints(Constraints.Builder().setRequiresBatteryNotLow(true).build())
38+
.build()
3739

3840
WorkManager.getInstance(context)
3941
.enqueueUniquePeriodicWork(

app/app/src/main/java/com/hostshield/ui/navigation/Navigation.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,4 +45,5 @@ object SubScreen {
4545
const val APP_PRIVACY = "app_privacy"
4646
const val BLOCKLIST_GALLERY = "blocklist_gallery"
4747
const val AUTOMATION_AUDIT = "automation_audit"
48+
const val APP_LOGS = "app_logs" // arg: ?pkg=com.example.app
4849
}

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

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,8 @@ fun HomeScreen(
4949
onNavigateToApps: () -> Unit = {},
5050
onNavigateToFirewall: () -> Unit = {},
5151
onNavigateToConnectionLog: () -> Unit = {},
52-
onRequestVpnPermission: ((Boolean) -> Unit) -> Unit = {}
52+
onRequestVpnPermission: ((Boolean) -> Unit) -> Unit = {},
53+
onNavigateToAppLogs: (String) -> Unit = {}
5354
) {
5455
val state by viewModel.uiState.collectAsStateWithLifecycle()
5556
val liveLogs by viewModel.liveLogs.collectAsStateWithLifecycle()
@@ -648,9 +649,10 @@ fun HomeScreen(
648649
Column(modifier = Modifier.padding(14.dp)) {
649650
Text("Top Querying Apps", color = TextDim, fontSize = 10.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp)
650651
Spacer(Modifier.height(8.dp))
651-
state.topApps.forEachIndexed { idx, (_, label, count) ->
652+
state.topApps.forEachIndexed { idx, (pkg, label, count) ->
652653
Row(
653-
modifier = Modifier.fillMaxWidth().padding(vertical = 3.dp),
654+
modifier = Modifier.fillMaxWidth().padding(vertical = 3.dp)
655+
.clickable { if (pkg.isNotBlank()) onNavigateToAppLogs(pkg) },
654656
verticalAlignment = Alignment.CenterVertically
655657
) {
656658
val medalColor = when (idx) { 0 -> Teal; 1 -> Blue; else -> TextDim }
@@ -659,6 +661,7 @@ fun HomeScreen(
659661
Text(label.ifBlank { "Unknown" }, color = TextPrimary, fontSize = 12.sp,
660662
maxLines = 1, modifier = Modifier.weight(1f),
661663
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis)
664+
Icon(Icons.Filled.ChevronRight, null, tint = TextDim.copy(alpha = 0.4f), modifier = Modifier.size(14.dp))
662665
Text("$count", color = TextDim, fontSize = 11.sp, fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace)
663666
}
664667
}
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
package com.hostshield.ui.screens.logs
2+
3+
import androidx.compose.foundation.background
4+
import androidx.compose.foundation.layout.*
5+
import androidx.compose.foundation.lazy.LazyColumn
6+
import androidx.compose.foundation.lazy.items
7+
import androidx.compose.foundation.shape.CircleShape
8+
import androidx.compose.foundation.shape.RoundedCornerShape
9+
import androidx.compose.material.icons.Icons
10+
import androidx.compose.material.icons.automirrored.filled.ArrowBack
11+
import androidx.compose.material.icons.filled.*
12+
import androidx.compose.material3.*
13+
import androidx.compose.runtime.*
14+
import androidx.compose.ui.Alignment
15+
import androidx.compose.ui.Modifier
16+
import androidx.compose.ui.draw.clip
17+
import androidx.compose.ui.graphics.Color
18+
import androidx.compose.ui.text.font.FontFamily
19+
import androidx.compose.ui.text.font.FontWeight
20+
import androidx.compose.ui.text.style.TextOverflow
21+
import androidx.compose.ui.unit.dp
22+
import androidx.compose.ui.unit.sp
23+
import androidx.hilt.navigation.compose.hiltViewModel
24+
import androidx.lifecycle.SavedStateHandle
25+
import androidx.lifecycle.ViewModel
26+
import androidx.lifecycle.compose.collectAsStateWithLifecycle
27+
import androidx.lifecycle.viewModelScope
28+
import com.hostshield.data.database.AppDomainStat
29+
import com.hostshield.data.database.DnsLogDao
30+
import com.hostshield.data.model.DnsLogEntry
31+
import com.hostshield.ui.screens.home.GlassCard
32+
import com.hostshield.ui.theme.*
33+
import dagger.hilt.android.lifecycle.HiltViewModel
34+
import kotlinx.coroutines.flow.*
35+
import javax.inject.Inject
36+
import java.text.SimpleDateFormat
37+
import java.util.*
38+
39+
@HiltViewModel
40+
class AppLogsViewModel @Inject constructor(
41+
savedStateHandle: SavedStateHandle,
42+
private val dnsLogDao: DnsLogDao
43+
) : ViewModel() {
44+
val packageName: String = savedStateHandle["pkg"] ?: ""
45+
46+
val recentLogs: StateFlow<List<DnsLogEntry>> = dnsLogDao.getLogsForApp(packageName, 200)
47+
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
48+
49+
val domainStats: StateFlow<List<AppDomainStat>> = dnsLogDao.getDomainsForApp(packageName, 50)
50+
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
51+
}
52+
53+
@Composable
54+
fun AppLogsScreen(
55+
packageName: String,
56+
viewModel: AppLogsViewModel = hiltViewModel(),
57+
onBack: () -> Unit = {}
58+
) {
59+
val logs by viewModel.recentLogs.collectAsStateWithLifecycle()
60+
val domains by viewModel.domainStats.collectAsStateWithLifecycle()
61+
var showDomains by remember { mutableStateOf(true) }
62+
val timeFmt = remember { SimpleDateFormat("HH:mm:ss", Locale.getDefault()) }
63+
64+
Column(modifier = Modifier.fillMaxSize().background(Color.Black)) {
65+
// Header
66+
Row(
67+
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 8.dp),
68+
verticalAlignment = Alignment.CenterVertically
69+
) {
70+
IconButton(onClick = onBack) {
71+
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back", tint = TextPrimary)
72+
}
73+
Column(modifier = Modifier.weight(1f)) {
74+
Text("App DNS Log", color = TextPrimary, fontWeight = FontWeight.SemiBold, fontSize = 18.sp)
75+
Text(packageName, color = TextDim, fontSize = 11.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
76+
}
77+
Text("${logs.size} queries", color = TextDim, fontSize = 11.sp, modifier = Modifier.padding(end = 12.dp))
78+
}
79+
80+
// Stats summary
81+
val blocked = logs.count { it.blocked }
82+
val allowed = logs.size - blocked
83+
Surface(
84+
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
85+
shape = RoundedCornerShape(10.dp),
86+
color = Surface2
87+
) {
88+
Row(
89+
modifier = Modifier.padding(12.dp),
90+
horizontalArrangement = Arrangement.SpaceEvenly
91+
) {
92+
Column(horizontalAlignment = Alignment.CenterHorizontally) {
93+
Text("$allowed", color = Green, fontSize = 18.sp, fontWeight = FontWeight.Bold)
94+
Text("Allowed", color = TextDim, fontSize = 9.sp)
95+
}
96+
Column(horizontalAlignment = Alignment.CenterHorizontally) {
97+
Text("$blocked", color = Red, fontSize = 18.sp, fontWeight = FontWeight.Bold)
98+
Text("Blocked", color = TextDim, fontSize = 9.sp)
99+
}
100+
Column(horizontalAlignment = Alignment.CenterHorizontally) {
101+
Text("${domains.size}", color = Blue, fontSize = 18.sp, fontWeight = FontWeight.Bold)
102+
Text("Domains", color = TextDim, fontSize = 9.sp)
103+
}
104+
}
105+
}
106+
107+
Spacer(Modifier.height(8.dp))
108+
109+
// Tab toggle
110+
Row(
111+
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
112+
horizontalArrangement = Arrangement.spacedBy(8.dp)
113+
) {
114+
Surface(
115+
onClick = { showDomains = true },
116+
shape = RoundedCornerShape(10.dp),
117+
color = if (showDomains) Teal.copy(alpha = 0.15f) else Surface2
118+
) {
119+
Text("Domains", modifier = Modifier.padding(horizontal = 14.dp, vertical = 7.dp),
120+
color = if (showDomains) Teal else TextDim, fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
121+
}
122+
Surface(
123+
onClick = { showDomains = false },
124+
shape = RoundedCornerShape(10.dp),
125+
color = if (!showDomains) Blue.copy(alpha = 0.15f) else Surface2
126+
) {
127+
Text("Timeline", modifier = Modifier.padding(horizontal = 14.dp, vertical = 7.dp),
128+
color = if (!showDomains) Blue else TextDim, fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
129+
}
130+
}
131+
132+
Spacer(Modifier.height(8.dp))
133+
134+
if (showDomains) {
135+
// Domain breakdown
136+
if (domains.isEmpty()) {
137+
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
138+
Text("No DNS queries from this app", color = TextDim, fontSize = 14.sp)
139+
}
140+
} else {
141+
LazyColumn(contentPadding = PaddingValues(horizontal = 16.dp, vertical = 4.dp)) {
142+
items(domains, key = { it.hostname }) { stat ->
143+
Row(
144+
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
145+
verticalAlignment = Alignment.CenterVertically
146+
) {
147+
Box(
148+
modifier = Modifier.size(6.dp).clip(CircleShape)
149+
.background(if (stat.blocked) Red else Green)
150+
)
151+
Spacer(Modifier.width(10.dp))
152+
Text(
153+
stat.hostname,
154+
color = TextPrimary, fontSize = 12.sp, fontFamily = FontFamily.Monospace,
155+
maxLines = 1, overflow = TextOverflow.Ellipsis,
156+
modifier = Modifier.weight(1f)
157+
)
158+
Text("${stat.cnt}x", color = TextDim, fontSize = 10.sp, fontFamily = FontFamily.Monospace)
159+
}
160+
HorizontalDivider(color = Surface2.copy(alpha = 0.3f))
161+
}
162+
}
163+
}
164+
} else {
165+
// Timeline view
166+
if (logs.isEmpty()) {
167+
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
168+
Text("No recent queries", color = TextDim, fontSize = 14.sp)
169+
}
170+
} else {
171+
LazyColumn(contentPadding = PaddingValues(horizontal = 16.dp, vertical = 4.dp)) {
172+
items(logs, key = { it.id }) { entry ->
173+
Row(
174+
modifier = Modifier.fillMaxWidth().padding(vertical = 3.dp),
175+
verticalAlignment = Alignment.CenterVertically
176+
) {
177+
Box(
178+
modifier = Modifier.size(6.dp).clip(CircleShape)
179+
.background(if (entry.blocked) Red else Green)
180+
)
181+
Spacer(Modifier.width(10.dp))
182+
Text(
183+
entry.hostname,
184+
color = TextPrimary, fontSize = 11.sp, fontFamily = FontFamily.Monospace,
185+
maxLines = 1, overflow = TextOverflow.Ellipsis,
186+
modifier = Modifier.weight(1f)
187+
)
188+
if (entry.responseTimeMs > 0) {
189+
Text("${entry.responseTimeMs}ms", color = TextDim.copy(alpha = 0.5f), fontSize = 9.sp)
190+
Spacer(Modifier.width(6.dp))
191+
}
192+
Text(
193+
timeFmt.format(Date(entry.timestamp)),
194+
color = TextDim.copy(alpha = 0.6f), fontSize = 9.sp, fontFamily = FontFamily.Monospace
195+
)
196+
}
197+
}
198+
}
199+
}
200+
}
201+
}
202+
}

0 commit comments

Comments
 (0)