Skip to content

Commit b081008

Browse files
committed
v4.3.0: Notification pause/resume, CNAME cloak badge, source health alerts
- VPN notification: "Pause 5m" action pauses blocking, shows "Resume" when paused - ACTION_PAUSE intent with pause_minutes extra (0 = resume immediately) - isPaused flag bypasses isDomainBlocked — all queries pass through while paused - Log detail sheet: red "CNAME CLOAK" badge when blocked entry has CNAME chain - Upstream server labels prettified (DoH:CLOUDFLARE -> DoH: Cloudflare) - SourceHealthWorker: push notification when sources transition to DEAD status - Alert notification channel (ALERT_CHANNEL_ID) for non-VPN alerts
1 parent 99df53b commit b081008

5 files changed

Lines changed: 150 additions & 16 deletions

File tree

CLAUDE.md

Lines changed: 6 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.2.0.
4+
Modern, AMOLED-dark hosts-based ad blocker app for Android. Inspired by AdAway. v4.3.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.3.0: Notification pause/resume action (5-min pause from notification), CNAME CLOAK badge in log detail sheet, pretty upstream server labels (DoH:Cloudflare), source health DEAD notifications (push alert), alerts notification channel, pause state bypasses blocking
7374
- v4.2.0: Fix DNS log data starvation (CNAME chains, resolved IPs, latency, upstream server now written to DB), CNAME-blocked domains now logged, fd error tracking + auto-restart on TUN error, IPv6 DoH support (honours useDoH flag), IPv6 DNS cache lookup, DohBypassUpdater uses shared OkHttpClient, app context threaded through all forward methods
7475
- v4.1.0: Custom upstream DNS UI in Settings, firewall rule export/import (JSON), automation audit log viewer screen, query rate anomaly detection (3x baseline warning on Home), dropped queries banner, cache hit rate on Home
7576
- v4.0.0: Automation API rate limiting + audit logging, GeoIP rate limit backoff, shared OkHttpClient pooling, tracker scanner Room caching, VPN stability metrics (uptime/rebuilds/errors/drops), DNS cache stats in Stats screen, VPN Health card, log buffer overflow detection, DB v9
@@ -111,3 +112,7 @@ cd app
111112
- processIpv6Dns now has DoH + cache lookup (was plaintext-only before v4.2.0)
112113
- forwardDoH is dual-mode: `wrapV6=true` wraps response as IPv6 packet instead of IPv4
113114
- packetLoop auto-restarts VPN on unexpected exit (fd error) while `isRunning=true`
115+
- ACTION_PAUSE with `pause_minutes` extra; 0 = resume immediately
116+
- isPaused flag checked in isDomainBlocked — all queries allowed while paused
117+
- SourceHealthWorker tracks newly-DEAD sources and posts notification via ALERT_CHANNEL_ID
118+
- ALERT_CHANNEL_ID created alongside VPN channel in createNotificationChannel()

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.2.0
1+
// HostShield v4.3.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 = 42
19-
versionName = "4.2.0"
18+
versionCode = 43
19+
versionName = "4.3.0"
2020

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

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

Lines changed: 74 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,10 @@ class DnsVpnService : VpnService() {
8383
companion object {
8484
const val ACTION_START = "com.hostshield.VPN_START"
8585
const val ACTION_STOP = "com.hostshield.VPN_STOP"
86+
const val ACTION_PAUSE = "com.hostshield.VPN_PAUSE"
8687
const val ACTION_WATCHDOG = "com.hostshield.VPN_WATCHDOG"
8788
const val CHANNEL_ID = "hostshield_vpn"
89+
const val ALERT_CHANNEL_ID = "hostshield_alerts"
8890
const val NOTIFICATION_ID = 1
8991
private const val TAG = "HostShield"
9092
private const val WATCHDOG_INTERVAL_MS = 600_000L // 10 minutes
@@ -259,6 +261,10 @@ class DnsVpnService : VpnService() {
259261
private var vpnStartTime = 0L
260262
@Volatile private var stabilityFlushJob: Job? = null
261263

264+
// Pause state: when paused, all queries are allowed (no blocking)
265+
@Volatile private var isPaused = false
266+
private var pauseResumeJob: Job? = null
267+
262268
override fun onCreate() {
263269
super.onCreate()
264270
createNotificationChannel()
@@ -267,6 +273,19 @@ class DnsVpnService : VpnService() {
267273
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
268274
when (intent?.action) {
269275
ACTION_STOP -> { stopVpn(); return START_NOT_STICKY }
276+
ACTION_PAUSE -> {
277+
val mins = intent.getIntExtra("pause_minutes", 5)
278+
if (mins <= 0) {
279+
// Resume immediately
280+
isPaused = false
281+
pauseResumeJob?.cancel(); pauseResumeJob = null
282+
Log.i(TAG, "Blocking resumed (manual)")
283+
updateNotification(blockedCount)
284+
} else {
285+
pauseBlocking(mins)
286+
}
287+
return START_STICKY
288+
}
270289
ACTION_WATCHDOG -> {
271290
// OEM battery managers (Samsung, Xiaomi, Huawei) kill VPN services.
272291
// This alarm fires every 10 min to detect and recover.
@@ -1042,7 +1061,10 @@ class DnsVpnService : VpnService() {
10421061
* Handles exact match, www. prefix, wildcard allow/block.
10431062
* Replaces the old linear Set.contains() + wildcard scan.
10441063
*/
1045-
private fun isDomainBlocked(domain: String): Boolean = blocklist.isBlocked(domain)
1064+
private fun isDomainBlocked(domain: String): Boolean {
1065+
if (isPaused) return false
1066+
return blocklist.isBlocked(domain)
1067+
}
10461068

10471069
// ── Packet Parsing ───────────────────────────────────────
10481070

@@ -1920,10 +1942,29 @@ class DnsVpnService : VpnService() {
19201942

19211943
// ── Notifications ────────────────────────────────────────
19221944

1945+
/** Pause DNS blocking for a specified number of minutes. */
1946+
private fun pauseBlocking(minutes: Int) {
1947+
isPaused = true
1948+
pauseResumeJob?.cancel()
1949+
pauseResumeJob = serviceScope.launch {
1950+
Log.i(TAG, "Blocking paused for ${minutes} minutes")
1951+
updateNotification(blockedCount)
1952+
delay(minutes * 60_000L)
1953+
isPaused = false
1954+
pauseResumeJob = null
1955+
Log.i(TAG, "Blocking resumed after ${minutes}-minute pause")
1956+
updateNotification(blockedCount)
1957+
}
1958+
}
1959+
19231960
private fun createNotificationChannel() {
1961+
val nm = getSystemService(NotificationManager::class.java)
19241962
NotificationChannel(CHANNEL_ID, "HostShield VPN", NotificationManager.IMPORTANCE_LOW).apply {
19251963
description = "VPN blocking status"; setShowBadge(false)
1926-
}.let { getSystemService(NotificationManager::class.java).createNotificationChannel(it) }
1964+
}.let { nm.createNotificationChannel(it) }
1965+
NotificationChannel(ALERT_CHANNEL_ID, "HostShield Alerts", NotificationManager.IMPORTANCE_DEFAULT).apply {
1966+
description = "Source health and system alerts"
1967+
}.let { nm.createNotificationChannel(it) }
19271968
}
19281969

19291970
private fun buildNotification(blocked: Int): Notification {
@@ -1932,18 +1973,42 @@ class DnsVpnService : VpnService() {
19321973
val si = PendingIntent.getService(this, 1,
19331974
Intent(this, DnsVpnService::class.java).apply { action = ACTION_STOP },
19341975
PendingIntent.FLAG_IMMUTABLE)
1976+
val pauseIntent = PendingIntent.getService(this, 2,
1977+
Intent(this, DnsVpnService::class.java).apply {
1978+
action = ACTION_PAUSE; putExtra("pause_minutes", 5)
1979+
},
1980+
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
1981+
1982+
val title = if (isPaused) "HostShield Paused" else "HostShield Active"
19351983
val sub = buildString {
1936-
append(if (blocked > 0) "$blocked blocked" else "DNS filtering active")
1937-
if (useDoH) append(" | DoH")
1938-
if (dnsTrapEnabled) append(" | Trap")
1984+
if (isPaused) append("Blocking paused")
1985+
else {
1986+
append(if (blocked > 0) "$blocked blocked" else "DNS filtering active")
1987+
if (useDoH) append(" | DoH")
1988+
if (dnsTrapEnabled) append(" | Trap")
1989+
}
19391990
}
1940-
return NotificationCompat.Builder(this, CHANNEL_ID)
1941-
.setContentTitle("HostShield Active").setContentText(sub)
1991+
val builder = NotificationCompat.Builder(this, CHANNEL_ID)
1992+
.setContentTitle(title).setContentText(sub)
19421993
.setSmallIcon(android.R.drawable.ic_lock_lock).setOngoing(true)
19431994
.setContentIntent(ci)
1944-
.addAction(android.R.drawable.ic_menu_close_clear_cancel, "Stop", si)
19451995
.setCategory(NotificationCompat.CATEGORY_SERVICE)
1946-
.setPriority(NotificationCompat.PRIORITY_LOW).build()
1996+
.setPriority(NotificationCompat.PRIORITY_LOW)
1997+
1998+
if (isPaused) {
1999+
// Show resume action when paused
2000+
val resumeIntent = PendingIntent.getService(this, 3,
2001+
Intent(this, DnsVpnService::class.java).apply {
2002+
action = ACTION_PAUSE; putExtra("pause_minutes", 0)
2003+
},
2004+
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
2005+
builder.addAction(android.R.drawable.ic_media_play, "Resume", resumeIntent)
2006+
} else {
2007+
builder.addAction(android.R.drawable.ic_media_pause, "Pause 5m", pauseIntent)
2008+
}
2009+
builder.addAction(android.R.drawable.ic_menu_close_clear_cancel, "Stop", si)
2010+
2011+
return builder.build()
19472012
}
19482013

19492014
private fun updateNotification(blocked: Int) {

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

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package com.hostshield.service
22

3+
import android.app.NotificationManager
34
import android.content.Context
5+
import androidx.core.app.NotificationCompat
46
import androidx.hilt.work.HiltWorker
57
import androidx.work.*
68
import com.hostshield.data.database.HostSourceDao
@@ -10,7 +12,7 @@ import dagger.assisted.Assisted
1012
import dagger.assisted.AssistedInject
1113
import java.util.concurrent.TimeUnit
1214

13-
// HostShield v1.6.0 - Source Health Monitor
15+
// HostShield v4.3.0 - Source Health Monitor with notifications
1416

1517
@HiltWorker
1618
class SourceHealthWorker @AssistedInject constructor(
@@ -24,6 +26,7 @@ class SourceHealthWorker @AssistedInject constructor(
2426
const val WORK_NAME = "hostshield_health_check"
2527
private const val STALE_THRESHOLD_MS = 7L * 24 * 60 * 60 * 1000 // 7 days
2628
private const val DEAD_FAILURE_THRESHOLD = 5
29+
private const val NOTIFICATION_ID_HEALTH = 200
2730

2831
fun schedule(context: Context) {
2932
val request = PeriodicWorkRequestBuilder<SourceHealthWorker>(
@@ -59,8 +62,10 @@ class SourceHealthWorker @AssistedInject constructor(
5962
override suspend fun doWork(): Result {
6063
return try {
6164
val sources = sourceDao.getAllSourcesList()
65+
val newlyDead = mutableListOf<String>()
6266

6367
for (source in sources) {
68+
val wasDead = source.health == SourceHealth.DEAD
6469
val validationResult = downloader.validate(source.url)
6570

6671
validationResult.onSuccess { lineCount ->
@@ -89,12 +94,44 @@ class SourceHealthWorker @AssistedInject constructor(
8994
error = err.message ?: "Unknown error",
9095
failures = failures
9196
)
97+
98+
// Track sources that just transitioned to DEAD
99+
if (!wasDead && health == SourceHealth.DEAD) {
100+
newlyDead.add(source.label)
101+
}
92102
}
93103
}
94104

105+
// Notify user about newly dead sources
106+
if (newlyDead.isNotEmpty()) {
107+
notifyDeadSources(newlyDead)
108+
}
109+
95110
Result.success()
96111
} catch (e: Exception) {
97112
if (runAttemptCount < 2) Result.retry() else Result.failure()
98113
}
99114
}
115+
116+
private fun notifyDeadSources(labels: List<String>) {
117+
try {
118+
val nm = applicationContext.getSystemService(NotificationManager::class.java) ?: return
119+
val text = if (labels.size == 1) {
120+
"${labels[0]} is unreachable after $DEAD_FAILURE_THRESHOLD failures"
121+
} else {
122+
"${labels.size} sources unreachable: ${labels.joinToString(", ")}"
123+
}
124+
125+
val notification = NotificationCompat.Builder(applicationContext, DnsVpnService.ALERT_CHANNEL_ID)
126+
.setContentTitle("Source Health Alert")
127+
.setContentText(text)
128+
.setStyle(NotificationCompat.BigTextStyle().bigText(text))
129+
.setSmallIcon(android.R.drawable.ic_dialog_alert)
130+
.setAutoCancel(true)
131+
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
132+
.build()
133+
134+
nm.notify(NOTIFICATION_ID_HEALTH, notification)
135+
} catch (_: Exception) { }
136+
}
100137
}

app/app/src/main/java/com/hostshield/ui/screens/logs/LogsScreen.kt

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -733,11 +733,38 @@ private fun QueryDetailSheet(entry: DedupedLogEntry, onDismiss: () -> Unit, isPi
733733
DetailRow("Response Time", "${entry.responseTimeMs} ms")
734734
}
735735
if (entry.upstreamServer.isNotEmpty()) {
736-
DetailRow("Upstream Server", entry.upstreamServer)
736+
// Pretty-print upstream server label
737+
val serverLabel = when {
738+
entry.upstreamServer.startsWith("DoH:") -> {
739+
val provider = entry.upstreamServer.removePrefix("DoH:")
740+
"DoH: ${provider.lowercase().replaceFirstChar { it.uppercase() }}"
741+
}
742+
entry.upstreamServer.contains("(fallback)") -> entry.upstreamServer
743+
else -> entry.upstreamServer
744+
}
745+
DetailRow("Upstream Server", serverLabel)
737746
}
738747
if (entry.cnameChain.isNotEmpty()) {
739748
Spacer(Modifier.height(8.dp))
740-
Text("CNAME Chain", color = TextDim, fontSize = 11.sp, fontWeight = FontWeight.SemiBold)
749+
Row(verticalAlignment = Alignment.CenterVertically) {
750+
Text("CNAME Chain", color = TextDim, fontSize = 11.sp, fontWeight = FontWeight.SemiBold)
751+
if (entry.blocked && entry.cnameChain.isNotBlank()) {
752+
Spacer(Modifier.width(8.dp))
753+
Surface(
754+
shape = RoundedCornerShape(4.dp),
755+
color = Red.copy(alpha = 0.15f)
756+
) {
757+
Text(
758+
"CNAME CLOAK",
759+
color = Red,
760+
fontSize = 9.sp,
761+
fontWeight = FontWeight.Bold,
762+
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
763+
letterSpacing = 0.5.sp
764+
)
765+
}
766+
}
767+
}
741768
entry.cnameChain.split(",").filter { it.isNotBlank() }.forEach { cname ->
742769
Row(modifier = Modifier.padding(start = 8.dp, top = 2.dp)) {
743770
Text("\u2192 ", color = TextDim, fontSize = 12.sp)

0 commit comments

Comments
 (0)