Skip to content

Commit f2c438e

Browse files
committed
Add tracker activity timeline as separate activity
New ActivityTimeline reachable from the main activity menu. Shows a recency-sorted timeline of tracker contacts grouped by app, with per-company blocked/allowed indicators and time-bucket section headers. - TimelineEntry/TrackerContact: data models for grouped tracker activity - TimelineAdapter: RecyclerView adapter with section headers and entries - ActivityTimeline: queries access table, resolves tracker companies via TrackerList.findTracker(), groups by app, sorts by most recent - DB: getRecentTrackerActivity() groups by (uid, daddr, allowed) over 7 days - Menu: "Tracker activity" item between lockdown and traffic log - Tapping an entry navigates to DetailsActivity for that app https://claude.ai/code/session_01AKoCWnAeeHrRa4KjngEiTM
1 parent ff6803b commit f2c438e

12 files changed

Lines changed: 596 additions & 0 deletions

File tree

app/src/main/AndroidManifest.xml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,16 @@
167167
android:value="eu.faircode.netguard.ActivitySettings" />
168168
</activity>
169169

170+
<activity
171+
android:name="net.kollnig.missioncontrol.ActivityTimeline"
172+
android:label="@string/title_tracker_activity"
173+
android:parentActivityName="eu.faircode.netguard.ActivityMain"
174+
android:theme="@style/AppThemeRed.NoActionBar">
175+
<meta-data
176+
android:name="android.support.PARENT_ACTIVITY"
177+
android:value="eu.faircode.netguard.ActivityMain" />
178+
</activity>
179+
170180
<activity
171181
android:name="net.kollnig.missioncontrol.DetailsActivity"
172182
android:exported="true"

app/src/main/java/eu/faircode/netguard/ActivityMain.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -986,6 +986,9 @@ public boolean onOptionsItemSelected(MenuItem item) {
986986
item.setChecked(true);
987987
prefs.edit().putString("sort", "uid").apply();
988988
return true;
989+
} else if (itemId == R.id.menu_timeline) {
990+
startActivity(new Intent(this, net.kollnig.missioncontrol.ActivityTimeline.class));
991+
return true;
989992
} else if (itemId == R.id.menu_log) {
990993
if (Util.canFilter(this))
991994
startActivity(new Intent(this, ActivityLog.class));

app/src/main/java/eu/faircode/netguard/DatabaseHelper.java

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -863,6 +863,26 @@ public Cursor getInsightsData7Days() {
863863
}
864864
}
865865

866+
public Cursor getRecentTrackerActivity() {
867+
lock.readLock().lock();
868+
try {
869+
SQLiteDatabase db = this.getReadableDatabase();
870+
long sevenDaysAgo = System.currentTimeMillis() - (7L * 24 * 60 * 60 * 1000);
871+
872+
String query = "SELECT uid, daddr, allowed, MAX(time) as last_time, " +
873+
"COUNT(*) as attempts, uncertain " +
874+
"FROM access " +
875+
"WHERE time >= ? " +
876+
"GROUP BY uid, daddr, allowed " +
877+
"ORDER BY last_time DESC " +
878+
"LIMIT 500";
879+
880+
return db.rawQuery(query, new String[] { Long.toString(sevenDaysAgo) });
881+
} finally {
882+
lock.readLock().unlock();
883+
}
884+
}
885+
866886
// DNS
867887

868888
public boolean insertDns(ResourceRecord rr) {
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
package net.kollnig.missioncontrol;
2+
3+
import android.content.Intent;
4+
import android.content.pm.ApplicationInfo;
5+
import android.content.pm.PackageManager;
6+
import android.database.Cursor;
7+
import android.os.AsyncTask;
8+
import android.os.Bundle;
9+
import android.view.View;
10+
import android.widget.TextView;
11+
12+
import androidx.appcompat.app.AppCompatActivity;
13+
import androidx.appcompat.widget.Toolbar;
14+
import androidx.recyclerview.widget.LinearLayoutManager;
15+
import androidx.recyclerview.widget.RecyclerView;
16+
17+
import net.kollnig.missioncontrol.data.TimelineEntry;
18+
import net.kollnig.missioncontrol.data.Tracker;
19+
import net.kollnig.missioncontrol.data.TrackerContact;
20+
import net.kollnig.missioncontrol.data.TrackerList;
21+
22+
import java.util.ArrayList;
23+
import java.util.Collections;
24+
import java.util.LinkedHashMap;
25+
import java.util.List;
26+
import java.util.Map;
27+
28+
import eu.faircode.netguard.DatabaseHelper;
29+
30+
public class ActivityTimeline extends AppCompatActivity implements TimelineAdapter.OnEntryClickListener {
31+
32+
private TimelineAdapter adapter;
33+
private TextView tvEmpty;
34+
private RecyclerView rvTimeline;
35+
36+
@Override
37+
protected void onCreate(Bundle savedInstanceState) {
38+
super.onCreate(savedInstanceState);
39+
setContentView(R.layout.activity_timeline);
40+
41+
Toolbar toolbar = findViewById(R.id.toolbar);
42+
setSupportActionBar(toolbar);
43+
if (getSupportActionBar() != null)
44+
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
45+
46+
tvEmpty = findViewById(R.id.tvEmpty);
47+
rvTimeline = findViewById(R.id.rvTimeline);
48+
rvTimeline.setLayoutManager(new LinearLayoutManager(this));
49+
50+
adapter = new TimelineAdapter(this, this);
51+
rvTimeline.setAdapter(adapter);
52+
}
53+
54+
@Override
55+
protected void onResume() {
56+
super.onResume();
57+
loadTimeline();
58+
}
59+
60+
@Override
61+
public boolean onSupportNavigateUp() {
62+
finish();
63+
return true;
64+
}
65+
66+
@Override
67+
public void onEntryClick(TimelineEntry entry) {
68+
Intent intent = new Intent(this, DetailsActivity.class);
69+
intent.putExtra(DetailsActivity.INTENT_EXTRA_APP_NAME, entry.appName);
70+
intent.putExtra(DetailsActivity.INTENT_EXTRA_APP_PACKAGENAME, entry.packageName);
71+
intent.putExtra(DetailsActivity.INTENT_EXTRA_APP_UID, entry.uid);
72+
startActivity(intent);
73+
}
74+
75+
private void loadTimeline() {
76+
new AsyncTask<Void, Void, List<TimelineEntry>>() {
77+
@Override
78+
protected List<TimelineEntry> doInBackground(Void... voids) {
79+
return buildTimeline();
80+
}
81+
82+
@Override
83+
protected void onPostExecute(List<TimelineEntry> entries) {
84+
adapter.setEntries(entries);
85+
tvEmpty.setVisibility(entries.isEmpty() ? View.VISIBLE : View.GONE);
86+
rvTimeline.setVisibility(entries.isEmpty() ? View.GONE : View.VISIBLE);
87+
}
88+
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
89+
}
90+
91+
private List<TimelineEntry> buildTimeline() {
92+
DatabaseHelper dh = DatabaseHelper.getInstance(this);
93+
PackageManager pm = getPackageManager();
94+
95+
// uid -> (companyName -> TrackerContact with latest time and blocked state)
96+
// We use a compound key of "companyName|blocked" to keep separate entries
97+
// for the same company when it has both blocked and allowed connections.
98+
Map<Integer, Map<String, TrackerContact>> uidTrackers = new LinkedHashMap<>();
99+
Map<Integer, Long> uidLatestTime = new LinkedHashMap<>();
100+
Map<Integer, String[]> uidAppInfo = new LinkedHashMap<>();
101+
102+
try (Cursor cursor = dh.getRecentTrackerActivity()) {
103+
if (cursor == null)
104+
return Collections.emptyList();
105+
106+
int colUid = cursor.getColumnIndexOrThrow("uid");
107+
int colDaddr = cursor.getColumnIndexOrThrow("daddr");
108+
int colAllowed = cursor.getColumnIndexOrThrow("allowed");
109+
int colLastTime = cursor.getColumnIndexOrThrow("last_time");
110+
111+
while (cursor.moveToNext()) {
112+
int uid = cursor.getInt(colUid);
113+
String daddr = cursor.getString(colDaddr);
114+
int allowed = cursor.getInt(colAllowed);
115+
long lastTime = cursor.getLong(colLastTime);
116+
117+
// Resolve to tracker — skip non-tracker destinations
118+
Tracker tracker = TrackerList.findTracker(daddr);
119+
if (tracker == null)
120+
continue;
121+
122+
String companyName = tracker.getName();
123+
if (companyName == null)
124+
continue;
125+
126+
boolean blocked = allowed == 0;
127+
String category = tracker.getCategory();
128+
129+
// Track per-uid
130+
Map<String, TrackerContact> companyMap = uidTrackers.get(uid);
131+
if (companyMap == null) {
132+
companyMap = new LinkedHashMap<>();
133+
uidTrackers.put(uid, companyMap);
134+
}
135+
136+
// Key by company+blocked to keep blocked/allowed separate for same company
137+
String key = companyName + "|" + blocked;
138+
TrackerContact existing = companyMap.get(key);
139+
if (existing == null || lastTime > existing.lastTime) {
140+
companyMap.put(key, new TrackerContact(companyName, category, blocked, lastTime));
141+
}
142+
143+
// Track latest time per uid
144+
Long currentLatest = uidLatestTime.get(uid);
145+
if (currentLatest == null || lastTime > currentLatest) {
146+
uidLatestTime.put(uid, lastTime);
147+
}
148+
149+
// Resolve app info once per uid
150+
if (!uidAppInfo.containsKey(uid)) {
151+
String appName = Integer.toString(uid);
152+
String packageName = null;
153+
String[] packages = pm.getPackagesForUid(uid);
154+
if (packages != null && packages.length > 0) {
155+
packageName = packages[0];
156+
try {
157+
ApplicationInfo ai = pm.getApplicationInfo(packageName, 0);
158+
appName = pm.getApplicationLabel(ai).toString();
159+
} catch (PackageManager.NameNotFoundException ignored) {
160+
}
161+
}
162+
uidAppInfo.put(uid, new String[] { appName, packageName });
163+
}
164+
}
165+
}
166+
167+
// Build entries
168+
List<TimelineEntry> entries = new ArrayList<>();
169+
for (Map.Entry<Integer, Map<String, TrackerContact>> e : uidTrackers.entrySet()) {
170+
int uid = e.getKey();
171+
String[] appInfo = uidAppInfo.get(uid);
172+
if (appInfo == null || appInfo[1] == null)
173+
continue;
174+
175+
List<TrackerContact> trackers = new ArrayList<>(e.getValue().values());
176+
// Sort: blocked first, then by most recent
177+
trackers.sort((a, b) -> {
178+
if (a.blocked != b.blocked)
179+
return a.blocked ? -1 : 1;
180+
return Long.compare(b.lastTime, a.lastTime);
181+
});
182+
183+
Long latestTime = uidLatestTime.get(uid);
184+
entries.add(new TimelineEntry(uid, appInfo[0], appInfo[1],
185+
latestTime != null ? latestTime : 0, trackers));
186+
}
187+
188+
// Sort by most recent first
189+
entries.sort((a, b) -> Long.compare(b.mostRecentTime, a.mostRecentTime));
190+
return entries;
191+
}
192+
}

0 commit comments

Comments
 (0)