diff --git a/TODO.md b/TODO.md
index 146e152d4..3163aa49f 100644
--- a/TODO.md
+++ b/TODO.md
@@ -105,25 +105,3 @@ If revisited later, start by deciding whether the desired goal is:
- just to reduce cross-app cache bleed, or
- to redesign tracker attribution so "per-app blocking" is backed by per-app evidence.
-
-## Remaining Material 3 migration items
-
-The core M3 migration is complete (themes, switches, settings with MaterialToolbar, FABs, tabs). The following are cosmetic items that still reference AppCompat/MaterialComponents but work correctly:
-
-### TextAppearance references (~16 occurrences)
-- `traffic.xml` — 8 uses of `Base.TextAppearance.AppCompat.Small` → should be `TextAppearance.Material3.BodySmall`
-- `item_onboarding.xml` / `item_onboarding_blocking_mode.xml` — uses of `TextAppearance.AppCompat.Large`, `.Body1`, `.Caption` → should be `TextAppearance.Material3.HeadlineSmall`, `.BodyLarge`, `.LabelSmall`
-
-### Button styles (4 buttons)
-- `activity_onboarding.xml:28` — `Widget.AppCompat.Button.Borderless` → `Widget.Material3.Button.TextButton`
-- `troubleshooting.xml:56,90,156` — `Widget.AppCompat.Button.Borderless.Colored` → `Widget.Material3.Button.TextButton`
-
-### Layout-level theme references
-- `traffic.xml:5` — `Theme.AppCompat.Light.DarkActionBar` on a RelativeLayout
-- `list_item_trackers_header.xml:158-159` — `Theme.MaterialComponents.DayNight` and `Widget.MaterialComponents.Button.OutlinedButton` → `Theme.Material3.DayNight` and `Widget.Material3.Button.OutlinedButton`
-
-### AlertDialog → MaterialAlertDialogBuilder (12 occurrences)
-Uses of `androidx.appcompat.app.AlertDialog.Builder` across ActivityMain, ActivityLog, ActivitySettings, ActivityForwarding, ActivityBlocklists, ActionsFragment, and Util. Migrating to `com.google.android.material.dialog.MaterialAlertDialogBuilder` would give M3-styled dialogs (rounded corners, M3 color tokens).
-
-### Action bar styles in styles.xml
-`ActionBar.Red`, `ActionBarTheme.Red`, `Toolbar.Red`, `ActionBarTitleText` still reference AppCompat parents (`Widget.AppCompat.ActionBar.Solid`, `TextAppearance.AppCompat.Widget.ActionBar.Title`). These are used by the main screen's legacy action bar and work correctly but aren't pure M3.
diff --git a/app/build.gradle b/app/build.gradle
index e88829c6e..9a285d3bd 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -137,6 +137,7 @@ dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
// https://developer.android.com/jetpack/androidx/releases/
+ implementation 'androidx.activity:activity:1.9.3'
implementation 'androidx.appcompat:appcompat:1.7.1'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.2.0'
implementation 'androidx.recyclerview:recyclerview:1.4.0'
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index b312699b8..5f0860cda 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -167,12 +167,22 @@
android:value="eu.faircode.netguard.ActivitySettings" />
+
+
+
+
+ android:theme="@style/AppTheme.NoActionBar">
diff --git a/app/src/main/java/eu/faircode/netguard/ActivityForwarding.java b/app/src/main/java/eu/faircode/netguard/ActivityForwarding.java
index 0ebfee88f..37bf8da40 100644
--- a/app/src/main/java/eu/faircode/netguard/ActivityForwarding.java
+++ b/app/src/main/java/eu/faircode/netguard/ActivityForwarding.java
@@ -39,6 +39,8 @@
import android.widget.Toast;
import androidx.appcompat.app.AlertDialog;
+
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import androidx.appcompat.app.AppCompatActivity;
import net.kollnig.missioncontrol.R;
@@ -183,7 +185,7 @@ protected void onPostExecute(List rules) {
};
task.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
- dialog = new AlertDialog.Builder(this)
+ dialog = new MaterialAlertDialogBuilder(this)
.setView(view)
.setCancelable(true)
.setPositiveButton(android.R.string.yes, new DialogInterface.OnClickListener() {
diff --git a/app/src/main/java/eu/faircode/netguard/ActivityLog.java b/app/src/main/java/eu/faircode/netguard/ActivityLog.java
index dfa7d9c5c..7607b16b0 100644
--- a/app/src/main/java/eu/faircode/netguard/ActivityLog.java
+++ b/app/src/main/java/eu/faircode/netguard/ActivityLog.java
@@ -41,6 +41,8 @@
import android.widget.Toast;
import androidx.appcompat.app.AlertDialog;
+
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.widget.SearchView;
import com.google.android.material.materialswitch.MaterialSwitch;
@@ -405,7 +407,7 @@ public boolean onOptionsItemSelected(MenuItem item) {
}
// warn that enabling will send data to ipinfo.io
- AlertDialog.Builder builder = new AlertDialog.Builder(this)
+ AlertDialog.Builder builder = new MaterialAlertDialogBuilder(this)
.setTitle(R.string.confirm_ipinfo_title)
.setMessage(R.string.confirm_ipinfo)
.setPositiveButton(R.string.yes, (dialog, id2) -> {
diff --git a/app/src/main/java/eu/faircode/netguard/ActivityMain.java b/app/src/main/java/eu/faircode/netguard/ActivityMain.java
index a04704a0f..5a197777e 100644
--- a/app/src/main/java/eu/faircode/netguard/ActivityMain.java
+++ b/app/src/main/java/eu/faircode/netguard/ActivityMain.java
@@ -60,6 +60,8 @@
import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
+
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.widget.SearchView;
import com.google.android.material.materialswitch.MaterialSwitch;
@@ -182,7 +184,7 @@ protected void onCreate(Bundle savedInstanceState) {
// Check for filtering
if (!Util.canFilter(this)) {
- AlertDialog.Builder builder = new AlertDialog.Builder(this)
+ AlertDialog.Builder builder = new MaterialAlertDialogBuilder(this)
.setTitle(R.string.device_not_supported_title)
.setMessage(R.string.device_not_supported_msg)
.setPositiveButton(R.string.ok, (dialog, id) -> {
@@ -302,7 +304,7 @@ public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
LayoutInflater inflater = LayoutInflater.from(ActivityMain.this);
final View view = inflater.inflate(R.layout.vpn, null, false);
- dialogVpn = new AlertDialog.Builder(ActivityMain.this)
+ dialogVpn = new MaterialAlertDialogBuilder(ActivityMain.this)
.setView(view)
.setCancelable(false)
.setPositiveButton(android.R.string.yes, new DialogInterface.OnClickListener() {
@@ -984,6 +986,9 @@ public boolean onOptionsItemSelected(MenuItem item) {
item.setChecked(true);
prefs.edit().putString("sort", "uid").apply();
return true;
+ } else if (itemId == R.id.menu_timeline) {
+ startActivity(new Intent(this, net.kollnig.missioncontrol.ActivityTimeline.class));
+ return true;
} else if (itemId == R.id.menu_log) {
if (Util.canFilter(this))
startActivity(new Intent(this, ActivityLog.class));
@@ -1197,7 +1202,7 @@ public void onClick(View v) {
}
});
- dialogTroubleshooting = new AlertDialog.Builder(this)
+ dialogTroubleshooting = new MaterialAlertDialogBuilder(this)
.setView(view)
.setCancelable(true)
.setPositiveButton(android.R.string.ok, null)
@@ -1247,7 +1252,7 @@ private void menu_legend() {
}
// Show dialog
- dialogLegend = new AlertDialog.Builder(this)
+ dialogLegend = new MaterialAlertDialogBuilder(this)
.setView(view)
.setCancelable(true)
.setOnDismissListener(new DialogInterface.OnDismissListener() {
@@ -1289,7 +1294,7 @@ public void onClick(View view) {
});
// Show dialog
- dialogAbout = new AlertDialog.Builder(this)
+ dialogAbout = new MaterialAlertDialogBuilder(this)
.setView(view)
.setCancelable(true)
.setOnDismissListener(new DialogInterface.OnDismissListener() {
diff --git a/app/src/main/java/eu/faircode/netguard/ActivitySettings.java b/app/src/main/java/eu/faircode/netguard/ActivitySettings.java
index f22968db9..31e99c5a6 100644
--- a/app/src/main/java/eu/faircode/netguard/ActivitySettings.java
+++ b/app/src/main/java/eu/faircode/netguard/ActivitySettings.java
@@ -52,6 +52,8 @@
import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
+
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.app.NavUtils;
import androidx.core.util.PatternsCompat;
@@ -624,7 +626,7 @@ else if ("filter".equals(name)) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP && prefs.getBoolean(name, true)) {
LayoutInflater inflater = LayoutInflater.from(ActivitySettings.this);
View view = inflater.inflate(R.layout.filter, null, false);
- dialogFilter = new AlertDialog.Builder(ActivitySettings.this)
+ dialogFilter = new MaterialAlertDialogBuilder(ActivitySettings.this)
.setView(view)
.setCancelable(false)
.setPositiveButton(android.R.string.yes, new DialogInterface.OnClickListener() {
diff --git a/app/src/main/java/eu/faircode/netguard/ApplicationEx.java b/app/src/main/java/eu/faircode/netguard/ApplicationEx.java
index 4134d18c3..d1f000fda 100644
--- a/app/src/main/java/eu/faircode/netguard/ApplicationEx.java
+++ b/app/src/main/java/eu/faircode/netguard/ApplicationEx.java
@@ -31,25 +31,19 @@
import android.content.Context;
import android.content.SharedPreferences;
import android.graphics.Color;
-import android.graphics.drawable.ColorDrawable;
import android.os.Build;
import android.os.Bundle;
import android.os.StrictMode;
import android.util.Log;
-import android.view.View;
+import androidx.activity.ComponentActivity;
+import androidx.activity.EdgeToEdge;
+import androidx.activity.SystemBarStyle;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
-import androidx.core.graphics.Insets;
-import androidx.core.view.OnApplyWindowInsetsListener;
-import androidx.core.view.ViewCompat;
-import androidx.core.view.WindowCompat;
-import androidx.core.view.WindowInsetsCompat;
import net.kollnig.missioncontrol.BuildConfig;
-import net.kollnig.missioncontrol.Common;
-import net.kollnig.missioncontrol.DetailsActivity;
import net.kollnig.missioncontrol.R;
import net.kollnig.missioncontrol.data.BlockingMode;
@@ -129,79 +123,39 @@ public void onCreate() {
registerActivityLifecycleCallbacks(new ActivityLifecycleCallbacks() {
@Override
public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle savedInstanceState) {
- if (activity.getClass() == DetailsActivity.class)
- return;
-
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
- android.widget.FrameLayout content = activity.findViewById(android.R.id.content);
-
- // On Android 15+, setStatusBarColor is ignored
- // Set window background to primary color - this shows behind the status bar
+ if (activity instanceof ComponentActivity) {
int statusBarColor = ContextCompat.getColor(activity, R.color.colorPrimaryDark);
- activity.getWindow().setBackgroundDrawable(new ColorDrawable(statusBarColor));
-
- boolean isNight = Common.isNight(activity);
-
- // Set status bar icons to light (white) since our background is dark
- View decor = activity.getWindow().getDecorView();
- WindowCompat.getInsetsController(activity.getWindow(), decor).setAppearanceLightStatusBars(false);
- WindowCompat.getInsetsController(activity.getWindow(), decor)
- .setAppearanceLightNavigationBars(!isNight);
-
- ViewCompat.setOnApplyWindowInsetsListener(content, new OnApplyWindowInsetsListener() {
- @NonNull
- @Override
- public WindowInsetsCompat onApplyWindowInsets(@NonNull View v,
- @NonNull WindowInsetsCompat insets) {
- Insets bars = insets.getInsets(WindowInsetsCompat.Type.systemBars()
- | WindowInsetsCompat.Type.displayCutout() | WindowInsetsCompat.Type.ime());
-
- // Apply padding to android.R.id.content for system bars
- // We do NOT apply bottom padding here, so the content background (White/Black)
- // extends to the bottom
- v.setPadding(bars.left, bars.top, bars.right, 0);
-
- // Set background on the actual layout (first child), not on the content frame
- // This way the padding area shows the window background (primary color)
- if (content.getChildCount() > 0) {
- View child = content.getChildAt(0);
- child.setBackgroundColor(Common.isNight(activity) ? Color.BLACK : Color.WHITE);
- }
-
- return insets;
- }
- });
+ // SystemBarStyle.dark() is critical: without it, EdgeToEdge defaults to
+ // transparent, and M3's light theme produces white-on-white status bar icons.
+ EdgeToEdge.enable(
+ (ComponentActivity) activity,
+ SystemBarStyle.dark(statusBarColor),
+ SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT));
}
}
@Override
public void onActivityStarted(@NonNull Activity activity) {
-
}
@Override
public void onActivityResumed(@NonNull Activity activity) {
-
}
@Override
public void onActivityPaused(@NonNull Activity activity) {
-
}
@Override
public void onActivityStopped(@NonNull Activity activity) {
-
}
@Override
public void onActivitySaveInstanceState(@NonNull Activity activity, @NonNull Bundle outState) {
-
}
@Override
public void onActivityDestroyed(@NonNull Activity activity) {
-
}
});
}
diff --git a/app/src/main/java/eu/faircode/netguard/DatabaseHelper.java b/app/src/main/java/eu/faircode/netguard/DatabaseHelper.java
index b99ccb71e..fd2a2a797 100644
--- a/app/src/main/java/eu/faircode/netguard/DatabaseHelper.java
+++ b/app/src/main/java/eu/faircode/netguard/DatabaseHelper.java
@@ -863,6 +863,26 @@ public Cursor getInsightsData7Days() {
}
}
+ public Cursor getRecentTrackerActivity() {
+ lock.readLock().lock();
+ try {
+ SQLiteDatabase db = this.getReadableDatabase();
+ long sevenDaysAgo = System.currentTimeMillis() - (7L * 24 * 60 * 60 * 1000);
+
+ String query = "SELECT uid, daddr, allowed, MAX(time) as last_time, " +
+ "COUNT(*) as attempts, uncertain " +
+ "FROM access " +
+ "WHERE time >= ? " +
+ "GROUP BY uid, daddr, allowed " +
+ "ORDER BY last_time DESC " +
+ "LIMIT 500";
+
+ return db.rawQuery(query, new String[] { Long.toString(sevenDaysAgo) });
+ } finally {
+ lock.readLock().unlock();
+ }
+ }
+
// DNS
public boolean insertDns(ResourceRecord rr) {
diff --git a/app/src/main/java/eu/faircode/netguard/Util.java b/app/src/main/java/eu/faircode/netguard/Util.java
index 4a89443ff..52690fe59 100644
--- a/app/src/main/java/eu/faircode/netguard/Util.java
+++ b/app/src/main/java/eu/faircode/netguard/Util.java
@@ -50,7 +50,7 @@
import android.view.View;
import android.widget.TextView;
-import androidx.appcompat.app.AlertDialog;
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import androidx.appcompat.app.AppCompatDelegate;
import androidx.core.app.ActivityCompat;
import androidx.core.net.ConnectivityManagerCompat;
@@ -560,7 +560,7 @@ public static void areYouSure(Context context, int explanation, final DoubtListe
View view = inflater.inflate(R.layout.sure, null, false);
TextView tvExplanation = view.findViewById(R.id.tvExplanation);
tvExplanation.setText(explanation);
- new AlertDialog.Builder(context)
+ new MaterialAlertDialogBuilder(context)
.setView(view)
.setCancelable(true)
.setPositiveButton(android.R.string.yes, new DialogInterface.OnClickListener() {
diff --git a/app/src/main/java/net/kollnig/missioncontrol/ActivityBlocklists.java b/app/src/main/java/net/kollnig/missioncontrol/ActivityBlocklists.java
index 078277988..6d2697b66 100644
--- a/app/src/main/java/net/kollnig/missioncontrol/ActivityBlocklists.java
+++ b/app/src/main/java/net/kollnig/missioncontrol/ActivityBlocklists.java
@@ -14,6 +14,8 @@
import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
+
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import androidx.appcompat.app.AppCompatActivity;
import com.google.android.material.materialswitch.MaterialSwitch;
import androidx.recyclerview.widget.LinearLayoutManager;
@@ -68,7 +70,7 @@ public boolean onOptionsItemSelected(MenuItem item) {
}
private void showAddDialog(Blocklist item) {
- AlertDialog.Builder builder = new AlertDialog.Builder(this);
+ AlertDialog.Builder builder = new MaterialAlertDialogBuilder(this);
builder.setTitle(item == null ? R.string.title_add_blocklist : R.string.title_blocklists);
final EditText input = new EditText(this);
@@ -162,7 +164,7 @@ public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
holder.itemView.setOnClickListener(v -> showAddDialog(item));
holder.btnDelete.setOnClickListener(v -> {
- new AlertDialog.Builder(context)
+ new MaterialAlertDialogBuilder(context)
.setTitle(R.string.title_delete_blocklist)
.setMessage(R.string.msg_delete_blocklist_confirm)
.setPositiveButton(android.R.string.yes, (dialog, which) -> {
diff --git a/app/src/main/java/net/kollnig/missioncontrol/ActivityTimeline.java b/app/src/main/java/net/kollnig/missioncontrol/ActivityTimeline.java
new file mode 100644
index 000000000..91c4a4dd9
--- /dev/null
+++ b/app/src/main/java/net/kollnig/missioncontrol/ActivityTimeline.java
@@ -0,0 +1,192 @@
+package net.kollnig.missioncontrol;
+
+import android.content.Intent;
+import android.content.pm.ApplicationInfo;
+import android.content.pm.PackageManager;
+import android.database.Cursor;
+import android.os.AsyncTask;
+import android.os.Bundle;
+import android.view.View;
+import android.widget.TextView;
+
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.appcompat.widget.Toolbar;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+
+import net.kollnig.missioncontrol.data.TimelineEntry;
+import net.kollnig.missioncontrol.data.Tracker;
+import net.kollnig.missioncontrol.data.TrackerContact;
+import net.kollnig.missioncontrol.data.TrackerList;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import eu.faircode.netguard.DatabaseHelper;
+
+public class ActivityTimeline extends AppCompatActivity implements TimelineAdapter.OnEntryClickListener {
+
+ private TimelineAdapter adapter;
+ private TextView tvEmpty;
+ private RecyclerView rvTimeline;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_timeline);
+
+ Toolbar toolbar = findViewById(R.id.toolbar);
+ setSupportActionBar(toolbar);
+ if (getSupportActionBar() != null)
+ getSupportActionBar().setDisplayHomeAsUpEnabled(true);
+
+ tvEmpty = findViewById(R.id.tvEmpty);
+ rvTimeline = findViewById(R.id.rvTimeline);
+ rvTimeline.setLayoutManager(new LinearLayoutManager(this));
+
+ adapter = new TimelineAdapter(this, this);
+ rvTimeline.setAdapter(adapter);
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ loadTimeline();
+ }
+
+ @Override
+ public boolean onSupportNavigateUp() {
+ finish();
+ return true;
+ }
+
+ @Override
+ public void onEntryClick(TimelineEntry entry) {
+ Intent intent = new Intent(this, DetailsActivity.class);
+ intent.putExtra(DetailsActivity.INTENT_EXTRA_APP_NAME, entry.appName);
+ intent.putExtra(DetailsActivity.INTENT_EXTRA_APP_PACKAGENAME, entry.packageName);
+ intent.putExtra(DetailsActivity.INTENT_EXTRA_APP_UID, entry.uid);
+ startActivity(intent);
+ }
+
+ private void loadTimeline() {
+ new AsyncTask>() {
+ @Override
+ protected List doInBackground(Void... voids) {
+ return buildTimeline();
+ }
+
+ @Override
+ protected void onPostExecute(List entries) {
+ adapter.setEntries(entries);
+ tvEmpty.setVisibility(entries.isEmpty() ? View.VISIBLE : View.GONE);
+ rvTimeline.setVisibility(entries.isEmpty() ? View.GONE : View.VISIBLE);
+ }
+ }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
+ }
+
+ private List buildTimeline() {
+ DatabaseHelper dh = DatabaseHelper.getInstance(this);
+ PackageManager pm = getPackageManager();
+
+ // uid -> (companyName -> TrackerContact with latest time and blocked state)
+ // We use a compound key of "companyName|blocked" to keep separate entries
+ // for the same company when it has both blocked and allowed connections.
+ Map> uidTrackers = new LinkedHashMap<>();
+ Map uidLatestTime = new LinkedHashMap<>();
+ Map uidAppInfo = new LinkedHashMap<>();
+
+ try (Cursor cursor = dh.getRecentTrackerActivity()) {
+ if (cursor == null)
+ return Collections.emptyList();
+
+ int colUid = cursor.getColumnIndexOrThrow("uid");
+ int colDaddr = cursor.getColumnIndexOrThrow("daddr");
+ int colAllowed = cursor.getColumnIndexOrThrow("allowed");
+ int colLastTime = cursor.getColumnIndexOrThrow("last_time");
+
+ while (cursor.moveToNext()) {
+ int uid = cursor.getInt(colUid);
+ String daddr = cursor.getString(colDaddr);
+ int allowed = cursor.getInt(colAllowed);
+ long lastTime = cursor.getLong(colLastTime);
+
+ // Resolve to tracker — skip non-tracker destinations
+ Tracker tracker = TrackerList.findTracker(daddr);
+ if (tracker == null)
+ continue;
+
+ String companyName = tracker.getName();
+ if (companyName == null)
+ continue;
+
+ boolean blocked = allowed == 0;
+ String category = tracker.getCategory();
+
+ // Track per-uid
+ Map companyMap = uidTrackers.get(uid);
+ if (companyMap == null) {
+ companyMap = new LinkedHashMap<>();
+ uidTrackers.put(uid, companyMap);
+ }
+
+ // Key by company+blocked to keep blocked/allowed separate for same company
+ String key = companyName + "|" + blocked;
+ TrackerContact existing = companyMap.get(key);
+ if (existing == null || lastTime > existing.lastTime) {
+ companyMap.put(key, new TrackerContact(companyName, category, blocked, lastTime));
+ }
+
+ // Track latest time per uid
+ Long currentLatest = uidLatestTime.get(uid);
+ if (currentLatest == null || lastTime > currentLatest) {
+ uidLatestTime.put(uid, lastTime);
+ }
+
+ // Resolve app info once per uid
+ if (!uidAppInfo.containsKey(uid)) {
+ String appName = Integer.toString(uid);
+ String packageName = null;
+ String[] packages = pm.getPackagesForUid(uid);
+ if (packages != null && packages.length > 0) {
+ packageName = packages[0];
+ try {
+ ApplicationInfo ai = pm.getApplicationInfo(packageName, 0);
+ appName = pm.getApplicationLabel(ai).toString();
+ } catch (PackageManager.NameNotFoundException ignored) {
+ }
+ }
+ uidAppInfo.put(uid, new String[] { appName, packageName });
+ }
+ }
+ }
+
+ // Build entries
+ List entries = new ArrayList<>();
+ for (Map.Entry> e : uidTrackers.entrySet()) {
+ int uid = e.getKey();
+ String[] appInfo = uidAppInfo.get(uid);
+ if (appInfo == null || appInfo[1] == null)
+ continue;
+
+ List trackers = new ArrayList<>(e.getValue().values());
+ // Sort: blocked first, then by most recent
+ trackers.sort((a, b) -> {
+ if (a.blocked != b.blocked)
+ return a.blocked ? -1 : 1;
+ return Long.compare(b.lastTime, a.lastTime);
+ });
+
+ Long latestTime = uidLatestTime.get(uid);
+ entries.add(new TimelineEntry(uid, appInfo[0], appInfo[1],
+ latestTime != null ? latestTime : 0, trackers));
+ }
+
+ // Sort by most recent first
+ entries.sort((a, b) -> Long.compare(b.mostRecentTime, a.mostRecentTime));
+ return entries;
+ }
+}
diff --git a/app/src/main/java/net/kollnig/missioncontrol/DetailsActivity.java b/app/src/main/java/net/kollnig/missioncontrol/DetailsActivity.java
index 06e242f78..39ec95cca 100644
--- a/app/src/main/java/net/kollnig/missioncontrol/DetailsActivity.java
+++ b/app/src/main/java/net/kollnig/missioncontrol/DetailsActivity.java
@@ -26,7 +26,6 @@
import android.content.Intent;
import android.content.SharedPreferences;
import android.database.Cursor;
-import android.graphics.drawable.ColorDrawable;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Bundle;
@@ -40,12 +39,9 @@
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.widget.Toolbar;
import androidx.core.app.NavUtils;
-import androidx.core.content.ContextCompat;
import androidx.core.graphics.Insets;
import androidx.core.view.ViewCompat;
-import androidx.core.view.WindowCompat;
import androidx.core.view.WindowInsetsCompat;
-import androidx.core.view.WindowInsetsControllerCompat;
import androidx.viewpager2.widget.ViewPager2;
import com.google.android.material.appbar.AppBarLayout;
@@ -122,22 +118,9 @@ protected void onActivityResult(int requestCode, int resultCode, final Intent da
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
- // Enable edge-to-edge content
- WindowCompat.setDecorFitsSystemWindows(getWindow(), false);
+ // Edge-to-edge is enabled globally by ApplicationEx via EdgeToEdge.enable()
setContentView(R.layout.activity_details);
- // Status bar appearance
- WindowInsetsControllerCompat insetsController = new WindowInsetsControllerCompat(getWindow(),
- getWindow().getDecorView());
- insetsController.setAppearanceLightStatusBars(false);
- if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.VANILLA_ICE_CREAM) {
- getWindow().setStatusBarColor(ContextCompat.getColor(this, R.color.colorPrimaryDark));
- }
-
- // Set window background to primary dark color to show behind the transparent
- // status bar
- getWindow().setBackgroundDrawable(new ColorDrawable(ContextCompat.getColor(this, R.color.colorPrimaryDark)));
-
running = true;
// Receive about details
diff --git a/app/src/main/java/net/kollnig/missioncontrol/TimelineAdapter.java b/app/src/main/java/net/kollnig/missioncontrol/TimelineAdapter.java
new file mode 100644
index 000000000..3913e297b
--- /dev/null
+++ b/app/src/main/java/net/kollnig/missioncontrol/TimelineAdapter.java
@@ -0,0 +1,200 @@
+package net.kollnig.missioncontrol;
+
+import android.content.Context;
+import android.content.pm.ApplicationInfo;
+import android.content.pm.PackageManager;
+import android.graphics.drawable.Drawable;
+import android.text.format.DateUtils;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+
+import net.kollnig.missioncontrol.data.TimelineEntry;
+import net.kollnig.missioncontrol.data.TrackerContact;
+
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.List;
+
+public class TimelineAdapter extends RecyclerView.Adapter {
+
+ private static final int TYPE_SECTION = 0;
+ private static final int TYPE_ENTRY = 1;
+ private static final int MAX_TRACKERS_SHOWN = 3;
+
+ private final Context context;
+ private final PackageManager pm;
+ private final OnEntryClickListener listener;
+ private final List