Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 24 additions & 10 deletions app/src/main/java/net/kollnig/missioncontrol/TimelineAdapter.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 {
Expand Down
108 changes: 93 additions & 15 deletions app/src/main/java/net/kollnig/missioncontrol/TimelineEmptyAdapter.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,14 @@

import com.google.android.material.button.MaterialButton;

import eu.faircode.netguard.ActivitySettings;

public class TimelineEmptyAdapter extends RecyclerView.Adapter<TimelineEmptyAdapter.ViewHolder> {

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)
Expand All @@ -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;
Expand All @@ -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;
}
}

Expand Down
28 changes: 23 additions & 5 deletions app/src/main/java/net/kollnig/missioncontrol/TimelineFragment.java
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -152,6 +158,9 @@ protected void onPostExecute(List<TimelineEntry> 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);
Expand All @@ -174,14 +183,15 @@ private void loadInsights() {
}

private List<TimelineEntry> 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<Integer, Map<String, TrackerContact>> uidTrackers = new LinkedHashMap<>();
Map<Integer, Long> uidLatestTime = new LinkedHashMap<>();
Expand Down Expand Up @@ -231,11 +241,19 @@ private List<TimelineEntry> 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();
Expand All @@ -251,7 +269,7 @@ private List<TimelineEntry> buildTimeline() {
for (Map.Entry<Integer, Map<String, TrackerContact>> e : uidTrackers.entrySet()) {
int uid = e.getKey();
String[] appInfo = uidAppInfo.get(uid);
if (appInfo == null || appInfo[1] == null)
if (appInfo == null)
continue;

List<TrackerContact> trackers = new ArrayList<>(e.getValue().values());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,13 @@
<string name="timeline_empty_disabled_subtitle">Enable TrackerControl using the switch at the top right. Tracker activity from your apps will then appear here.</string>
<string name="timeline_empty_enabled_title">Watching for trackers…</string>
<string name="timeline_empty_enabled_subtitle">Most apps only contact trackers while you use them. Open an app, then come back — its tracker activity will appear here.</string>
<string name="timeline_empty_recording_off_title">Tracker recording is off</string>
<string name="timeline_empty_recording_off_subtitle">“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.</string>
<string name="timeline_empty_recording_unavailable_title">Tracker recording unavailable</string>
<string name="timeline_empty_recording_unavailable_subtitle">This version of TrackerControl does not expose tracker activity recording. Blocking still works, but this Timeline will stay empty.</string>
<string name="timeline_open_app">Open an app</string>
<string name="timeline_open_settings">Open settings</string>
<string name="unidentified_app_uid">Unidentified app (UID %1$d)</string>
<string name="tab_timeline">Timeline</string>
<string name="tab_apps">Apps</string>
<string name="tab_vpn">VPN</string>
Expand Down
Loading