Skip to content

Commit 9cb1cb3

Browse files
committed
feat(privacy): implement persistent stats tracking
Add persistent storage for blocker statistics, update the UI to display all-time metrics, and improve WebView scroll handling to prevent layout jitter.
1 parent 7585f14 commit 9cb1cb3

8 files changed

Lines changed: 306 additions & 30 deletions

File tree

app/src/main/java/com/example/browser/BrowserViewModel.kt

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,14 @@ class BrowserViewModel(application: Application) : AndroidViewModel(application)
7676
)
7777
val webViewActionEvent: SharedFlow<WebViewAction> = _webViewActionEvent.asSharedFlow()
7878

79-
// Reactive Total Blocked Items Flow
79+
// Reactive Persistent Privacy Blocked Items Flows
8080
val totalBlockedCount: StateFlow<Int> = ContentBlocker.totalBlockedCount
81+
val totalTrackersCount: StateFlow<Int> = ContentBlocker.totalTrackersCount
82+
val totalAdsCount: StateFlow<Int> = ContentBlocker.totalAdsCount
83+
84+
fun resetPrivacyStats() {
85+
ContentBlocker.resetStats()
86+
}
8187

8288
// Sheets & Dialogs
8389
private val _activeSheet = MutableStateFlow(ActiveSheet.NONE)
@@ -416,6 +422,9 @@ class BrowserViewModel(application: Application) : AndroidViewModel(application)
416422
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
417423

418424
init {
425+
// Initialize ContentBlocker with persistent preferences so blocked statistics survive app restarts
426+
ContentBlocker.initialize(preferences)
427+
419428
val defaultTabId = UUID.randomUUID().toString()
420429
_activeTabId.value = defaultTabId
421430
_activeTabState.value = ActiveTabState(

app/src/main/java/com/example/data/BrowserPreferences.kt

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,41 @@ class BrowserPreferences(context: Context) {
2626
private const val KEY_WEATHER_ON_NEW_TAB = "pref_weather_on_new_tab"
2727
private const val KEY_WEATHER_FAHRENHEIT = "pref_weather_fahrenheit"
2828
private const val KEY_TOOLBAR_POSITION = "pref_toolbar_position"
29+
private const val KEY_TOTAL_BLOCKED_COUNT = "pref_total_blocked_count"
30+
private const val KEY_TOTAL_TRACKERS_BLOCKED = "pref_total_trackers_blocked"
31+
private const val KEY_TOTAL_ADS_BLOCKED = "pref_total_ads_blocked"
32+
}
33+
34+
fun getTotalBlockedCount(): Long {
35+
return prefs.getLong(KEY_TOTAL_BLOCKED_COUNT, 0L)
36+
}
37+
38+
fun setTotalBlockedCount(count: Long) {
39+
prefs.edit().putLong(KEY_TOTAL_BLOCKED_COUNT, count).apply()
40+
}
41+
42+
fun getTotalTrackersBlocked(): Long {
43+
return prefs.getLong(KEY_TOTAL_TRACKERS_BLOCKED, 0L)
44+
}
45+
46+
fun setTotalTrackersBlocked(count: Long) {
47+
prefs.edit().putLong(KEY_TOTAL_TRACKERS_BLOCKED, count).apply()
48+
}
49+
50+
fun getTotalAdsBlocked(): Long {
51+
return prefs.getLong(KEY_TOTAL_ADS_BLOCKED, 0L)
52+
}
53+
54+
fun setTotalAdsBlocked(count: Long) {
55+
prefs.edit().putLong(KEY_TOTAL_ADS_BLOCKED, count).apply()
56+
}
57+
58+
fun resetPrivacyStats() {
59+
prefs.edit()
60+
.putLong(KEY_TOTAL_BLOCKED_COUNT, 0L)
61+
.putLong(KEY_TOTAL_TRACKERS_BLOCKED, 0L)
62+
.putLong(KEY_TOTAL_ADS_BLOCKED, 0L)
63+
.apply()
2964
}
3065

3166
fun getToolbarPosition(): com.example.browser.ToolbarPosition {

app/src/main/java/com/example/privacy/ContentBlocker.kt

Lines changed: 105 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,85 @@ package com.example.privacy
22

33
import android.net.Uri
44
import android.webkit.WebResourceResponse
5+
import com.example.data.BrowserPreferences
6+
import kotlinx.coroutines.CoroutineScope
7+
import kotlinx.coroutines.Dispatchers
8+
import kotlinx.coroutines.SupervisorJob
9+
import kotlinx.coroutines.flow.MutableStateFlow
10+
import kotlinx.coroutines.flow.StateFlow
11+
import kotlinx.coroutines.flow.updateAndGet
12+
import kotlinx.coroutines.launch
513
import java.io.ByteArrayInputStream
614
import java.util.concurrent.ConcurrentHashMap
715
import java.util.concurrent.atomic.AtomicInteger
816

917
/**
1018
* Lightweight, high-performance rule-based ad and tracker blocker.
1119
* Intercepts tracking pixels, telemetry, analytics beacons, ad network calls, and intrusive scripts.
20+
* Persistently records cumulative stats so the Privacy Dashboard retains history across app restarts.
1221
*/
1322
object ContentBlocker {
1423

24+
private var browserPreferences: BrowserPreferences? = null
25+
private val ioScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
26+
27+
// Overall persistent blocked items counters (Reactive StateFlows)
28+
private val _totalBlockedCount = MutableStateFlow(0)
29+
val totalBlockedCount: StateFlow<Int> = _totalBlockedCount
30+
31+
private val _totalTrackersCount = MutableStateFlow(0)
32+
val totalTrackersCount: StateFlow<Int> = _totalTrackersCount
33+
34+
private val _totalAdsCount = MutableStateFlow(0)
35+
val totalAdsCount: StateFlow<Int> = _totalAdsCount
36+
37+
fun initialize(preferences: BrowserPreferences) {
38+
browserPreferences = preferences
39+
val savedTotal = preferences.getTotalBlockedCount().toInt()
40+
val savedTrackers = preferences.getTotalTrackersBlocked().toInt()
41+
val savedAds = preferences.getTotalAdsBlocked().toInt()
42+
43+
val currentTotal = _totalBlockedCount.value
44+
val currentTrackers = _totalTrackersCount.value
45+
val currentAds = _totalAdsCount.value
46+
47+
val newTotal = maxOf(savedTotal, savedTotal + currentTotal)
48+
val newTrackers = maxOf(savedTrackers, savedTrackers + currentTrackers)
49+
val newAds = maxOf(savedAds, savedAds + currentAds)
50+
51+
_totalBlockedCount.value = newTotal
52+
_totalTrackersCount.value = newTrackers
53+
_totalAdsCount.value = newAds
54+
55+
if (currentTotal > 0) {
56+
ioScope.launch {
57+
preferences.setTotalBlockedCount(newTotal.toLong())
58+
preferences.setTotalTrackersBlocked(newTrackers.toLong())
59+
preferences.setTotalAdsBlocked(newAds.toLong())
60+
}
61+
}
62+
}
63+
64+
fun resetStats() {
65+
_totalBlockedCount.value = 0
66+
_totalTrackersCount.value = 0
67+
_totalAdsCount.value = 0
68+
browserPreferences?.resetPrivacyStats()
69+
}
70+
71+
private fun recordBlockedItem(isTracker: Boolean) {
72+
val total = _totalBlockedCount.updateAndGet { it + 1 }
73+
val trackers = if (isTracker) _totalTrackersCount.updateAndGet { it + 1 } else _totalTrackersCount.value
74+
val ads = if (!isTracker) _totalAdsCount.updateAndGet { it + 1 } else _totalAdsCount.value
75+
76+
val prefs = browserPreferences ?: return
77+
ioScope.launch {
78+
prefs.setTotalBlockedCount(total.toLong())
79+
prefs.setTotalTrackersBlocked(trackers.toLong())
80+
prefs.setTotalAdsBlocked(ads.toLong())
81+
}
82+
}
83+
1584
// Default blocklist of notorious tracking and advertising host patterns
1685
private val blockedHostSuffixes = hashSetOf(
1786
// Ad networks & exchanges
@@ -83,12 +152,39 @@ object ContentBlocker {
83152
"/gtag/js"
84153
)
85154

155+
private val trackerHostSuffixes = hashSetOf(
156+
"google-analytics.com",
157+
"googletagmanager.com",
158+
"hotjar.com",
159+
"clarity.ms",
160+
"mouseflow.com",
161+
"mixpanel.com",
162+
"segment.io",
163+
"amplitude.com",
164+
"appsflyer.com",
165+
"adjust.com",
166+
"branch.io",
167+
"chartbeat.com",
168+
"crazyegg.com",
169+
"newrelic.com",
170+
"nr-data.net",
171+
"optimizely.com",
172+
"fullstory.com",
173+
"heapanalytics.com",
174+
"statcounter.com",
175+
"yandex.ru/metrika",
176+
"mc.yandex.ru"
177+
)
178+
179+
private val trackerPathKeywords = arrayOf(
180+
"/pixel.gif",
181+
"/tr?id=",
182+
"/analytics.js",
183+
"/gtag/js"
184+
)
185+
86186
// Blocked count per tab ID
87187
private val tabBlockCounts = ConcurrentHashMap<String, AtomicInteger>()
88-
89-
// Overall session blocked items counter (Reactive StateFlow)
90-
private val _totalBlockedCount = kotlinx.coroutines.flow.MutableStateFlow(0)
91-
val totalBlockedCount: kotlinx.coroutines.flow.StateFlow<Int> = _totalBlockedCount
92188

93189
fun shouldBlock(uri: Uri, isGlobalBlockerEnabled: Boolean, isSiteWhitelisted: Boolean): Boolean {
94190
if (!isGlobalBlockerEnabled || isSiteWhitelisted) return false
@@ -102,15 +198,16 @@ object ContentBlocker {
102198

103199
// Check specialized YouTube ad endpoints
104200
if (YouTubeAdBlocker.isYouTubeAdRequest(uri)) {
105-
_totalBlockedCount.value += 1
201+
recordBlockedItem(isTracker = false)
106202
return true
107203
}
108204
val pathAndQuery = (uri.path ?: "") + (uri.query?.let { "?$it" } ?: "")
109205

110206
// Check host suffix match (e.g. ad.doubleclick.net endsWith doubleclick.net)
111207
for (blockedHost in blockedHostSuffixes) {
112208
if (host == blockedHost || host.endsWith(".$blockedHost")) {
113-
_totalBlockedCount.value += 1
209+
val isTracker = trackerHostSuffixes.contains(blockedHost)
210+
recordBlockedItem(isTracker = isTracker)
114211
return true
115212
}
116213
}
@@ -119,7 +216,8 @@ object ContentBlocker {
119216
val lowerPath = pathAndQuery.lowercase()
120217
for (kw in blockedPathKeywords) {
121218
if (lowerPath.contains(kw)) {
122-
_totalBlockedCount.value += 1
219+
val isTracker = trackerPathKeywords.any { kw.contains(it) }
220+
recordBlockedItem(isTracker = isTracker)
123221
return true
124222
}
125223
}

app/src/main/java/com/example/ui/BrowserScreen.kt

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -283,16 +283,10 @@ fun BrowserScreen(
283283
map.values.toList()
284284
}
285285

286-
// Decouple WebView container padding from dynamic bottom bar hide/show animation
287-
// to completely eliminate Chromium viewport relayout jitter when reversing scroll direction
288-
// Status bar protection area is always preserved at the top of the viewport
286+
// Keep status bar and navigation bar insets cleanly separated from web content
287+
// so web page elements (like YouTube's bottom navigation menu) never collide with browser toolbars
289288
val effectiveTopPadding = innerPadding.calculateTopPadding()
290-
291-
val effectiveBottomPadding = if (isHome) {
292-
innerPadding.calculateBottomPadding()
293-
} else {
294-
0.dp
295-
}
289+
val effectiveBottomPadding = innerPadding.calculateBottomPadding()
296290

297291
Box(
298292
modifier = Modifier

app/src/main/java/com/example/ui/components/PrivacyShieldDialog.kt

Lines changed: 105 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,14 @@ fun PrivacyShieldDialog(
3636
val domain = UrlUtils.extractDomain(activeTab?.url ?: "")
3737
val tabBlockedCount = activeTab?.blockedCount ?: 0
3838
val totalBlocked by ContentBlocker.totalBlockedCount.collectAsState()
39+
val totalTrackers by ContentBlocker.totalTrackersCount.collectAsState()
40+
val totalAds by ContentBlocker.totalAdsCount.collectAsState()
41+
42+
val estimatedBytesSaved = totalBlocked * 48_000L
43+
val dataSavedString = when {
44+
estimatedBytesSaved < 1024 * 1024 -> "${estimatedBytesSaved / 1024} KB"
45+
else -> String.format(java.util.Locale.US, "%.1f MB", estimatedBytesSaved / (1024f * 1024f))
46+
}
3947

4048
val isShieldActiveOnCurrentSite = isGlobalBlockerEnabled && !isSiteWhitelisted
4149

@@ -139,7 +147,7 @@ fun PrivacyShieldDialog(
139147
Spacer(modifier = Modifier.height(16.dp))
140148
}
141149

142-
// Stats Cards
150+
// Stats Cards - Primary
143151
Row(
144152
modifier = Modifier.fillMaxWidth(),
145153
horizontalArrangement = Arrangement.spacedBy(10.dp)
@@ -177,14 +185,82 @@ fun PrivacyShieldDialog(
177185
color = Color(0xFF10B981)
178186
)
179187
Text(
180-
text = "Total Blocked",
188+
text = "Total Blocked (All-time)",
181189
fontSize = 11.sp,
182190
color = MaterialTheme.colorScheme.onSurfaceVariant
183191
)
184192
}
185193
}
186194
}
187195

196+
Spacer(modifier = Modifier.height(10.dp))
197+
198+
// Breakdown Stats Cards - Trackers, Ads, Data Saved
199+
Row(
200+
modifier = Modifier.fillMaxWidth(),
201+
horizontalArrangement = Arrangement.spacedBy(8.dp)
202+
) {
203+
Surface(
204+
shape = RoundedCornerShape(12.dp),
205+
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
206+
modifier = Modifier.weight(1f)
207+
) {
208+
Column(modifier = Modifier.padding(10.dp)) {
209+
Text(
210+
text = "$totalTrackers",
211+
fontSize = 17.sp,
212+
fontWeight = FontWeight.Bold,
213+
color = MaterialTheme.colorScheme.onSurface
214+
)
215+
Text(
216+
text = "Trackers",
217+
fontSize = 10.5.sp,
218+
color = MaterialTheme.colorScheme.onSurfaceVariant
219+
)
220+
}
221+
}
222+
223+
Surface(
224+
shape = RoundedCornerShape(12.dp),
225+
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
226+
modifier = Modifier.weight(1f)
227+
) {
228+
Column(modifier = Modifier.padding(10.dp)) {
229+
Text(
230+
text = "$totalAds",
231+
fontSize = 17.sp,
232+
fontWeight = FontWeight.Bold,
233+
color = MaterialTheme.colorScheme.onSurface
234+
)
235+
Text(
236+
text = "Ads & Promos",
237+
fontSize = 10.5.sp,
238+
color = MaterialTheme.colorScheme.onSurfaceVariant
239+
)
240+
}
241+
}
242+
243+
Surface(
244+
shape = RoundedCornerShape(12.dp),
245+
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
246+
modifier = Modifier.weight(1f)
247+
) {
248+
Column(modifier = Modifier.padding(10.dp)) {
249+
Text(
250+
text = dataSavedString,
251+
fontSize = 17.sp,
252+
fontWeight = FontWeight.Bold,
253+
color = Color(0xFF0284C7)
254+
)
255+
Text(
256+
text = "Data Saved",
257+
fontSize = 10.5.sp,
258+
color = MaterialTheme.colorScheme.onSurfaceVariant
259+
)
260+
}
261+
}
262+
}
263+
188264
Spacer(modifier = Modifier.height(16.dp))
189265

190266
// Global Blocker Switch
@@ -220,7 +296,33 @@ fun PrivacyShieldDialog(
220296
}
221297
}
222298

223-
Spacer(modifier = Modifier.height(28.dp))
299+
Spacer(modifier = Modifier.height(12.dp))
300+
301+
// Reset Statistics action
302+
Row(
303+
modifier = Modifier.fillMaxWidth(),
304+
horizontalArrangement = Arrangement.Center
305+
) {
306+
TextButton(
307+
onClick = { ContentBlocker.resetStats() },
308+
modifier = Modifier.testTag("reset_privacy_stats_button")
309+
) {
310+
Icon(
311+
imageVector = Icons.Default.Refresh,
312+
contentDescription = null,
313+
tint = MaterialTheme.colorScheme.onSurfaceVariant,
314+
modifier = Modifier.size(15.dp)
315+
)
316+
Spacer(modifier = Modifier.width(6.dp))
317+
Text(
318+
text = "Reset All-Time Stats",
319+
fontSize = 12.sp,
320+
color = MaterialTheme.colorScheme.onSurfaceVariant
321+
)
322+
}
323+
}
324+
325+
Spacer(modifier = Modifier.height(20.dp))
224326
}
225327
}
226328
}

0 commit comments

Comments
 (0)