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 items = new ArrayList<>(); + + public interface OnEntryClickListener { + void onEntryClick(TimelineEntry entry); + } + + public TimelineAdapter(Context context, OnEntryClickListener listener) { + this.context = context; + this.pm = context.getPackageManager(); + this.listener = listener; + } + + public void setEntries(List entries) { + items.clear(); + + if (entries.isEmpty()) { + notifyDataSetChanged(); + return; + } + + long now = System.currentTimeMillis(); + long oneHourAgo = now - 60 * 60 * 1000L; + long startOfToday = getStartOfDay(0); + long startOfYesterday = getStartOfDay(1); + long startOfWeek = now - 7L * 24 * 60 * 60 * 1000; + + String currentSection = null; + for (TimelineEntry entry : entries) { + String section; + if (entry.mostRecentTime >= oneHourAgo) { + section = context.getString(R.string.timeline_section_last_hour); + } else if (entry.mostRecentTime >= startOfToday) { + section = context.getString(R.string.timeline_section_today); + } else if (entry.mostRecentTime >= startOfYesterday) { + section = context.getString(R.string.timeline_section_yesterday); + } else { + section = context.getString(R.string.timeline_section_this_week); + } + + if (!section.equals(currentSection)) { + items.add(section); + currentSection = section; + } + items.add(entry); + } + notifyDataSetChanged(); + } + + private long getStartOfDay(int daysAgo) { + Calendar cal = Calendar.getInstance(); + cal.add(Calendar.DAY_OF_YEAR, -daysAgo); + cal.set(Calendar.HOUR_OF_DAY, 0); + cal.set(Calendar.MINUTE, 0); + cal.set(Calendar.SECOND, 0); + cal.set(Calendar.MILLISECOND, 0); + return cal.getTimeInMillis(); + } + + @Override + public int getItemViewType(int position) { + return items.get(position) instanceof String ? TYPE_SECTION : TYPE_ENTRY; + } + + @Override + public int getItemCount() { + return items.size(); + } + + @NonNull + @Override + public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + LayoutInflater inflater = LayoutInflater.from(parent.getContext()); + if (viewType == TYPE_SECTION) { + View view = inflater.inflate(R.layout.item_timeline_section, parent, false); + return new SectionHolder(view); + } else { + View view = inflater.inflate(R.layout.item_timeline_entry, parent, false); + return new EntryHolder(view); + } + } + + @Override + public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) { + if (holder instanceof SectionHolder) { + ((SectionHolder) holder).tvSection.setText((String) items.get(position)); + } else { + bindEntry((EntryHolder) holder, (TimelineEntry) items.get(position)); + } + } + + private void bindEntry(EntryHolder holder, TimelineEntry entry) { + // App icon + try { + ApplicationInfo ai = pm.getApplicationInfo(entry.packageName, 0); + Drawable icon = pm.getApplicationIcon(ai); + holder.ivAppIcon.setImageDrawable(icon); + } catch (PackageManager.NameNotFoundException e) { + holder.ivAppIcon.setImageResource(android.R.drawable.sym_def_app_icon); + } + + // App name + holder.tvAppName.setText(entry.appName); + + // Relative time + CharSequence relativeTime = DateUtils.getRelativeTimeSpanString( + entry.mostRecentTime, System.currentTimeMillis(), + DateUtils.MINUTE_IN_MILLIS, DateUtils.FORMAT_ABBREV_RELATIVE); + holder.tvTime.setText(relativeTime); + + // Tracker list + holder.llTrackers.removeAllViews(); + LayoutInflater inflater = LayoutInflater.from(context); + + int shown = 0; + for (TrackerContact tc : entry.trackers) { + if (shown >= MAX_TRACKERS_SHOWN) { + int remaining = entry.trackers.size() - MAX_TRACKERS_SHOWN; + TextView overflow = new TextView(context); + overflow.setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall); + overflow.setTextColor(context.getColor(android.R.color.darker_gray)); + overflow.setText(context.getString(R.string.timeline_more_trackers, remaining)); + holder.llTrackers.addView(overflow); + break; + } + + TextView tv = new TextView(context); + tv.setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall); + + String prefix = tc.blocked ? "\u26D4 " : "\u2705 "; + String label = tc.companyName; + if (tc.category != null) + label += " \u00B7 " + tc.category; + tv.setText(prefix + label); + holder.llTrackers.addView(tv); + shown++; + } + + // Click listener + holder.itemView.setOnClickListener(v -> { + if (listener != null) listener.onEntryClick(entry); + }); + } + + static class SectionHolder extends RecyclerView.ViewHolder { + final TextView tvSection; + + SectionHolder(View itemView) { + super(itemView); + tvSection = itemView.findViewById(R.id.tvSection); + } + } + + static class EntryHolder extends RecyclerView.ViewHolder { + final ImageView ivAppIcon; + final TextView tvAppName; + final TextView tvTime; + final LinearLayout llTrackers; + + EntryHolder(View itemView) { + super(itemView); + ivAppIcon = itemView.findViewById(R.id.ivAppIcon); + tvAppName = itemView.findViewById(R.id.tvAppName); + tvTime = itemView.findViewById(R.id.tvTime); + llTrackers = itemView.findViewById(R.id.llTrackers); + } + } +} diff --git a/app/src/main/java/net/kollnig/missioncontrol/data/TimelineEntry.java b/app/src/main/java/net/kollnig/missioncontrol/data/TimelineEntry.java new file mode 100644 index 000000000..6d4f3e849 --- /dev/null +++ b/app/src/main/java/net/kollnig/missioncontrol/data/TimelineEntry.java @@ -0,0 +1,42 @@ +package net.kollnig.missioncontrol.data; + +import java.util.List; + +/** + * A timeline entry grouping all recent tracker contacts for one app. + * Each entry contains the tracker companies contacted, with their blocked/allowed state. + */ +public class TimelineEntry { + public final int uid; + public final String appName; + public final String packageName; + public final long mostRecentTime; + public final List trackers; + + public TimelineEntry(int uid, String appName, String packageName, + long mostRecentTime, List trackers) { + this.uid = uid; + this.appName = appName; + this.packageName = packageName; + this.mostRecentTime = mostRecentTime; + this.trackers = trackers; + } + + public int getBlockedCount() { + int count = 0; + for (TrackerContact tc : trackers) + if (tc.blocked) count++; + return count; + } + + public int getAllowedCount() { + int count = 0; + for (TrackerContact tc : trackers) + if (!tc.blocked) count++; + return count; + } + + public boolean hasMixed() { + return getBlockedCount() > 0 && getAllowedCount() > 0; + } +} diff --git a/app/src/main/java/net/kollnig/missioncontrol/data/TrackerContact.java b/app/src/main/java/net/kollnig/missioncontrol/data/TrackerContact.java new file mode 100644 index 000000000..7dadec63e --- /dev/null +++ b/app/src/main/java/net/kollnig/missioncontrol/data/TrackerContact.java @@ -0,0 +1,19 @@ +package net.kollnig.missioncontrol.data; + +/** + * A single tracker company contact observed in network traffic. + * Used by the timeline to show per-company blocked/allowed state. + */ +public class TrackerContact { + public final String companyName; + public final String category; + public final boolean blocked; + public final long lastTime; + + public TrackerContact(String companyName, String category, boolean blocked, long lastTime) { + this.companyName = companyName; + this.category = category; + this.blocked = blocked; + this.lastTime = lastTime; + } +} diff --git a/app/src/main/java/net/kollnig/missioncontrol/details/ActionsFragment.java b/app/src/main/java/net/kollnig/missioncontrol/details/ActionsFragment.java index bd11eca6d..949cad3fa 100644 --- a/app/src/main/java/net/kollnig/missioncontrol/details/ActionsFragment.java +++ b/app/src/main/java/net/kollnig/missioncontrol/details/ActionsFragment.java @@ -30,6 +30,8 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; import androidx.fragment.app.Fragment; import com.google.android.material.snackbar.Snackbar; @@ -136,7 +138,7 @@ public void onClick(View v) { Context c = getContext(); if (c == null) return; - AlertDialog.Builder builder = new AlertDialog.Builder(c) + AlertDialog.Builder builder = new MaterialAlertDialogBuilder(c) .setTitle(R.string.external_servers) .setMessage(R.string.confirm_google_info) .setPositiveButton(R.string.yes, (dialog, id2) -> { diff --git a/app/src/main/res/layout/activity_onboarding.xml b/app/src/main/res/layout/activity_onboarding.xml index 95556582f..dc4a4312d 100644 --- a/app/src/main/res/layout/activity_onboarding.xml +++ b/app/src/main/res/layout/activity_onboarding.xml @@ -25,7 +25,7 @@