diff --git a/app/src/main/java/net/kollnig/missioncontrol/TimelineAdapter.java b/app/src/main/java/net/kollnig/missioncontrol/TimelineAdapter.java index 2970677a..511e0fb5 100644 --- a/app/src/main/java/net/kollnig/missioncontrol/TimelineAdapter.java +++ b/app/src/main/java/net/kollnig/missioncontrol/TimelineAdapter.java @@ -121,14 +121,21 @@ public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int positi } private void bindEntry(EntryHolder holder, TimelineEntry entry) { - // App icon - try { - ApplicationInfo ai = pm.getApplicationInfo(entry.packageName, 0); - Drawable icon = pm.getApplicationIcon(ai); + // App icon. The package is unknown for UIDs that cannot be resolved + // (other profiles, cloned or uninstalled apps), whose activity is still + // recorded and listed. + Drawable icon = null; + if (entry.packageName != null) { + try { + ApplicationInfo ai = pm.getApplicationInfo(entry.packageName, 0); + icon = pm.getApplicationIcon(ai); + } catch (PackageManager.NameNotFoundException ignored) { + } + } + if (icon != null) holder.ivAppIcon.setImageDrawable(icon); - } catch (PackageManager.NameNotFoundException e) { + else holder.ivAppIcon.setImageResource(android.R.drawable.sym_def_app_icon); - } // App name holder.tvAppName.setText(entry.appName); @@ -191,10 +198,17 @@ private void bindEntry(EntryHolder holder, TimelineEntry entry) { shown++; } - // Click listener - holder.itemView.setOnClickListener(v -> { - if (listener != null) listener.onEntryClick(entry); - }); + // Click listener. Details are per-package, so entries without a + // resolvable package are shown but not clickable. + if (entry.packageName == null) { + holder.itemView.setOnClickListener(null); + holder.itemView.setClickable(false); + } else { + holder.itemView.setClickable(true); + holder.itemView.setOnClickListener(v -> { + if (listener != null) listener.onEntryClick(entry); + }); + } } static class SectionHolder extends RecyclerView.ViewHolder { diff --git a/app/src/main/java/net/kollnig/missioncontrol/TimelineEmptyAdapter.java b/app/src/main/java/net/kollnig/missioncontrol/TimelineEmptyAdapter.java index e804cc70..21484af7 100644 --- a/app/src/main/java/net/kollnig/missioncontrol/TimelineEmptyAdapter.java +++ b/app/src/main/java/net/kollnig/missioncontrol/TimelineEmptyAdapter.java @@ -11,10 +11,14 @@ import com.google.android.material.button.MaterialButton; +import eu.faircode.netguard.ActivitySettings; + public class TimelineEmptyAdapter extends RecyclerView.Adapter { private boolean visible = false; private boolean trackerControlEnabled = false; + private boolean trackerRecordingEnabled = true; + private boolean trackerRecordingAvailable = true; public void setVisible(boolean visible) { if (this.visible == visible) @@ -34,6 +38,32 @@ public void setTrackerControlEnabled(boolean enabled) { notifyItemChanged(0); } + /** + * Whether tracker access is being recorded ("Search new trackers", the + * log_app setting). With it off, trackers are still blocked but never + * written to the access table, so the Timeline stays empty forever — + * saying "Watching for trackers…" would be untrue. + */ + public void setTrackerRecordingEnabled(boolean enabled) { + if (this.trackerRecordingEnabled == enabled) + return; + this.trackerRecordingEnabled = enabled; + if (visible) + notifyItemChanged(0); + } + + /** + * Whether this build exposes the setting that controls tracker recording. + * The Play build deliberately removes "Search new trackers" from Settings. + */ + public void setTrackerRecordingAvailable(boolean available) { + if (this.trackerRecordingAvailable == available) + return; + this.trackerRecordingAvailable = available; + if (visible) + notifyItemChanged(0); + } + @Override public int getItemCount() { return visible ? 1 : 0; @@ -47,23 +77,71 @@ public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { return new ViewHolder(view); } + /** + * Which explanation the empty Timeline should show. Recording off is a + * distinct state from "nothing seen yet": it never resolves on its own. + */ + enum EmptyState { + TRACKER_CONTROL_OFF, + RECORDING_OFF, + RECORDING_UNAVAILABLE, + WATCHING + } + + static EmptyState stateFor(boolean trackerControlEnabled, boolean trackerRecordingEnabled) { + return stateFor(trackerControlEnabled, trackerRecordingEnabled, true); + } + + static EmptyState stateFor(boolean trackerControlEnabled, boolean trackerRecordingEnabled, + boolean trackerRecordingAvailable) { + if (!trackerControlEnabled) + return EmptyState.TRACKER_CONTROL_OFF; + if (!trackerRecordingAvailable) + return EmptyState.RECORDING_UNAVAILABLE; + if (!trackerRecordingEnabled) + return EmptyState.RECORDING_OFF; + return EmptyState.WATCHING; + } + @Override public void onBindViewHolder(@NonNull ViewHolder holder, int position) { - if (trackerControlEnabled) { - holder.tvTitle.setText(R.string.timeline_empty_enabled_title); - holder.tvSubtitle.setText(R.string.timeline_empty_enabled_subtitle); - holder.btnOpenApp.setVisibility(View.VISIBLE); - holder.btnOpenApp.setOnClickListener(v -> { - Intent home = new Intent(Intent.ACTION_MAIN); - home.addCategory(Intent.CATEGORY_HOME); - home.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - v.getContext().startActivity(home); - }); - } else { - holder.tvTitle.setText(R.string.timeline_empty_disabled_title); - holder.tvSubtitle.setText(R.string.timeline_empty_disabled_subtitle); - holder.btnOpenApp.setVisibility(View.GONE); - holder.btnOpenApp.setOnClickListener(null); + switch (stateFor(trackerControlEnabled, trackerRecordingEnabled, + trackerRecordingAvailable)) { + case TRACKER_CONTROL_OFF: + holder.tvTitle.setText(R.string.timeline_empty_disabled_title); + holder.tvSubtitle.setText(R.string.timeline_empty_disabled_subtitle); + holder.btnOpenApp.setVisibility(View.GONE); + holder.btnOpenApp.setOnClickListener(null); + break; + + case RECORDING_OFF: + holder.tvTitle.setText(R.string.timeline_empty_recording_off_title); + holder.tvSubtitle.setText(R.string.timeline_empty_recording_off_subtitle); + holder.btnOpenApp.setVisibility(View.VISIBLE); + holder.btnOpenApp.setText(R.string.timeline_open_settings); + holder.btnOpenApp.setOnClickListener(v -> v.getContext().startActivity( + new Intent(v.getContext(), ActivitySettings.class))); + break; + + case RECORDING_UNAVAILABLE: + holder.tvTitle.setText(R.string.timeline_empty_recording_unavailable_title); + holder.tvSubtitle.setText(R.string.timeline_empty_recording_unavailable_subtitle); + holder.btnOpenApp.setVisibility(View.GONE); + holder.btnOpenApp.setOnClickListener(null); + break; + + default: + holder.tvTitle.setText(R.string.timeline_empty_enabled_title); + holder.tvSubtitle.setText(R.string.timeline_empty_enabled_subtitle); + holder.btnOpenApp.setVisibility(View.VISIBLE); + holder.btnOpenApp.setText(R.string.timeline_open_app); + holder.btnOpenApp.setOnClickListener(v -> { + Intent home = new Intent(Intent.ACTION_MAIN); + home.addCategory(Intent.CATEGORY_HOME); + home.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + v.getContext().startActivity(home); + }); + break; } } diff --git a/app/src/main/java/net/kollnig/missioncontrol/TimelineFragment.java b/app/src/main/java/net/kollnig/missioncontrol/TimelineFragment.java index cb6e6377..648260ab 100644 --- a/app/src/main/java/net/kollnig/missioncontrol/TimelineFragment.java +++ b/app/src/main/java/net/kollnig/missioncontrol/TimelineFragment.java @@ -1,5 +1,6 @@ package net.kollnig.missioncontrol; +import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.content.pm.ApplicationInfo; @@ -124,6 +125,11 @@ public void onDestroyView() { @Override public void onEntryClick(TimelineEntry entry) { + // Details are looked up per package; entries for UIDs without a + // resolvable package are listed but have nothing to open. + if (entry.packageName == null) + return; + Intent intent = new Intent(requireContext(), DetailsActivity.class); intent.putExtra(DetailsActivity.INTENT_EXTRA_APP_NAME, entry.appName); intent.putExtra(DetailsActivity.INTENT_EXTRA_APP_PACKAGENAME, entry.packageName); @@ -152,6 +158,9 @@ protected void onPostExecute(List entries) { SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(requireContext()); emptyAdapter.setTrackerControlEnabled(prefs.getBoolean("enabled", false)); + emptyAdapter.setTrackerRecordingEnabled(prefs.getBoolean("log_app", true)); + emptyAdapter.setTrackerRecordingAvailable( + !Util.isPlayStoreInstall(requireContext())); emptyAdapter.setVisible(entries.isEmpty()); } }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); @@ -174,14 +183,15 @@ private void loadInsights() { } private List buildTimeline() { - DatabaseHelper dh = DatabaseHelper.getInstance(requireContext()); - PackageManager pm = requireContext().getPackageManager(); + Context context = requireContext(); + DatabaseHelper dh = DatabaseHelper.getInstance(context); + PackageManager pm = context.getPackageManager(); // findTracker() reads from a static map populated lazily by // TrackerList.getInstance(). Without this call, opening the // app on the Timeline tab races with insights initialization // and every entry is silently dropped — appearing as an empty // "Watching for trackers…" screen even when there is data. - TrackerList.getInstance(requireContext()); + TrackerList.getInstance(context); Map> uidTrackers = new LinkedHashMap<>(); Map uidLatestTime = new LinkedHashMap<>(); @@ -231,11 +241,19 @@ private List buildTimeline() { } if (!uidAppInfo.containsKey(uid)) { - String appName = Integer.toString(uid); + // A UID that cannot be resolved to a package (another + // profile — work profile, Secure Folder, private space, + // cloned app — or an app uninstalled since the contact) + // still gets its tracker access recorded: shouldTrackApp() + // deliberately defaults unknown UIDs to tracked. Show that + // activity under a UID label instead of dropping it, which + // would leave the recorded data permanently invisible. + String appName = context.getString(R.string.unidentified_app_uid, uid); String packageName = null; String[] packages = Util.getPackagesForUid(pm, uid); if (packages != null && packages.length > 0) { packageName = packages[0]; + appName = packageName; try { ApplicationInfo ai = pm.getApplicationInfo(packageName, 0); appName = pm.getApplicationLabel(ai).toString(); @@ -251,7 +269,7 @@ private List buildTimeline() { for (Map.Entry> e : uidTrackers.entrySet()) { int uid = e.getKey(); String[] appInfo = uidAppInfo.get(uid); - if (appInfo == null || appInfo[1] == null) + if (appInfo == null) continue; List trackers = new ArrayList<>(e.getValue().values()); diff --git a/app/src/main/java/net/kollnig/missioncontrol/data/InsightsDataProvider.kt b/app/src/main/java/net/kollnig/missioncontrol/data/InsightsDataProvider.kt index 66553735..ae35bda8 100644 --- a/app/src/main/java/net/kollnig/missioncontrol/data/InsightsDataProvider.kt +++ b/app/src/main/java/net/kollnig/missioncontrol/data/InsightsDataProvider.kt @@ -25,6 +25,7 @@ import androidx.preference.PreferenceManager import eu.faircode.netguard.DatabaseHelper import eu.faircode.netguard.Util import net.kollnig.missioncontrol.Common +import net.kollnig.missioncontrol.R import java.util.Locale /** @@ -89,25 +90,31 @@ class InsightsDataProvider(context: Context) { if (!seenContacts.add(contactKey)) continue - // Get package name (cached) - val packageName = uidPackageCache.getOrPut(uid) { - getPackageNameForUid(uid) - } - - // Skip if we can't identify the package - if (packageName == null) continue - - // Check if system app - skip if show_system is false - val isSystem = uidSystemCache.getOrPut(uid) { - isSystemApp(uid) + // Get package name (cached; null is a valid cached result, + // so probe with containsKey to avoid re-resolving every row) + val packageName = if (uidPackageCache.containsKey(uid)) + uidPackageCache[uid] + else + getPackageNameForUid(uid).also { uidPackageCache[uid] = it } + + // A UID with no resolvable package (other profile, cloned or + // uninstalled app) is still recorded by ServiceSinkhole, + // which defaults unknown UIDs to tracked. Count it here too + // instead of dropping it; the per-package preference checks + // below simply have nothing to look up. + if (packageName != null) { + // Check if system app - skip if show_system is false + val isSystem = uidSystemCache.getOrPut(uid) { + isSystemApp(uid) + } + if (isSystem && !showSystem) continue + + // Check if excluded from VPN + if (!applyPrefs.getBoolean(packageName, true)) continue + + // Check if tracker protection is disabled for this app + if (!BlockingMode.isTrackerProtectionEnabled(context, trackerProtectPrefs, packageName)) continue } - if (isSystem && !showSystem) continue - - // Check if excluded from VPN - if (!applyPrefs.getBoolean(packageName, true)) continue - - // Check if tracker protection is disabled for this app - if (!BlockingMode.isTrackerProtectionEnabled(context, trackerProtectPrefs, packageName)) continue // Find tracker company for this hostname val tracker = TrackerList.findTracker(daddr) ?: continue @@ -166,8 +173,16 @@ class InsightsDataProvider(context: Context) { .sortedByDescending { it.value } .take(5) - data.topTrackingApps = sortedApps.mapNotNull { entry -> - Common.getAppName(packageManager, entry.key)?.let { Pair(it, entry.value) } + data.topTrackingApps = sortedApps.map { entry -> + val uid = entry.key + // Unresolvable UIDs are now counted, so name them by UID rather + // than letting them all collapse into a bare "Unknown" row. + val name = if (uid != 0 && uidPackageCache[uid] == null) + context.getString(R.string.unidentified_app_uid, uid) + else + Common.getAppName(packageManager, uid) + ?: context.getString(R.string.unidentified_app_uid, uid) + Pair(name, entry.value) }.toMutableList() // Build top tracker companies list (by total hosts contacted) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 44bbd34e..ddd218e5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -39,7 +39,13 @@ Enable TrackerControl using the switch at the top right. Tracker activity from your apps will then appear here. Watching for trackers… Most apps only contact trackers while you use them. Open an app, then come back — its tracker activity will appear here. + Tracker recording is off + “Search new trackers” is turned off in Advanced settings. Trackers are still blocked, but they are no longer recorded, so this Timeline stays empty. Turn it back on to see tracker activity again. + Tracker recording unavailable + This version of TrackerControl does not expose tracker activity recording. Blocking still works, but this Timeline will stay empty. Open an app + Open settings + Unidentified app (UID %1$d) Timeline Apps VPN diff --git a/app/src/test/java/net/kollnig/missioncontrol/TimelineEmptyStateTest.java b/app/src/test/java/net/kollnig/missioncontrol/TimelineEmptyStateTest.java new file mode 100644 index 00000000..5055b451 --- /dev/null +++ b/app/src/test/java/net/kollnig/missioncontrol/TimelineEmptyStateTest.java @@ -0,0 +1,51 @@ +/* + * TrackerControl is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * TrackerControl is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * Copyright © 2026 + */ + +package net.kollnig.missioncontrol; + +import static org.junit.Assert.assertEquals; + +import net.kollnig.missioncontrol.TimelineEmptyAdapter.EmptyState; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +@RunWith(RobolectricTestRunner.class) +public class TimelineEmptyStateTest { + + @Test + public void trackerControlOffTakesPrecedence() { + assertEquals(EmptyState.TRACKER_CONTROL_OFF, TimelineEmptyAdapter.stateFor(false, true)); + assertEquals(EmptyState.TRACKER_CONTROL_OFF, TimelineEmptyAdapter.stateFor(false, false)); + } + + @Test + public void recordingOffIsReportedSeparatelyFromWatching() { + // "Search new trackers" (log_app) off: trackers are still blocked but + // never recorded, so the Timeline can never fill up on its own. + assertEquals(EmptyState.RECORDING_OFF, TimelineEmptyAdapter.stateFor(true, false)); + } + + @Test + public void unavailableRecordingIsReportedWithoutASettingsCallToAction() { + assertEquals(EmptyState.RECORDING_UNAVAILABLE, + TimelineEmptyAdapter.stateFor(true, false, false)); + } + + @Test + public void watchingWhenEnabledAndRecording() { + assertEquals(EmptyState.WATCHING, TimelineEmptyAdapter.stateFor(true, true)); + } +}