From 1b628fc99e77b5e4f6f003ca2f6af0694d2044ef Mon Sep 17 00:00:00 2001 From: Konrad Kollnig <5175206+kasnder@users.noreply.github.com> Date: Mon, 4 May 2026 14:41:52 +0200 Subject: [PATCH] Restore install tracker analysis notifications --- .../eu/faircode/netguard/ServiceSinkhole.java | 13 ++- .../analysis/TrackerAnalysisManager.java | 89 ++++++++++++++++--- .../analysis/TrackerAnalysisWorker.java | 83 +++++++++++++++++ .../details/TrackersListAdapter.java | 3 + .../analysis/TrackerAnalysisManagerTest.java | 38 ++++++++ 5 files changed, 207 insertions(+), 19 deletions(-) create mode 100644 app/src/test/java/net/kollnig/missioncontrol/analysis/TrackerAnalysisManagerTest.java diff --git a/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java b/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java index 0ed51ad48..6a74a64c8 100644 --- a/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java +++ b/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java @@ -91,7 +91,6 @@ import net.kollnig.missioncontrol.data.TrackerBlocklist; import net.kollnig.missioncontrol.data.TrackerList; -import org.apache.commons.lang3.StringUtils; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; @@ -2683,7 +2682,7 @@ public void notifyNewApplication(int uid, BroadcastReceiver br) { // Check tracker libraries in app if (br != null) - checkTrackers(packageName, uid, br, builder); + checkTrackers(packageName, uid, name, br, builder); } } catch (PackageManager.NameNotFoundException ex) { @@ -2694,7 +2693,8 @@ public void notifyNewApplication(int uid, BroadcastReceiver br) { } } - private void checkTrackers(String packageName, int uid, BroadcastReceiver br, NotificationCompat.Builder builder) { + private void checkTrackers(String packageName, int uid, String appName, BroadcastReceiver br, + NotificationCompat.Builder builder) { BroadcastReceiver.PendingResult result = br.goAsync(); new Thread() { public void run() { @@ -2706,13 +2706,12 @@ public void run() { String cachedResult = manager.getCachedResult(packageName); if (cachedResult != null && !manager.isCacheStale(packageName)) { // Use cached result - int trackerCount = StringUtils.countMatches(cachedResult, "•"); + int trackerCount = TrackerAnalysisManager.countTrackers(cachedResult); builder.setContentText(getString(R.string.msg_installed_tracker_libraries_found, trackerCount)); NotificationManagerCompat.from(c).notify(uid, builder.build()); } else { - // Schedule analysis for later - notification will show generic message - manager.startAnalysis(packageName); - // Don't update notification here, user can check app details for results + // Schedule analysis for later; the worker updates this notification when done. + manager.startAnalysis(packageName, uid, appName); } } catch (Exception e) { e.printStackTrace(); diff --git a/app/src/main/java/net/kollnig/missioncontrol/analysis/TrackerAnalysisManager.java b/app/src/main/java/net/kollnig/missioncontrol/analysis/TrackerAnalysisManager.java index 8ee4ac4fc..164c9db77 100644 --- a/app/src/main/java/net/kollnig/missioncontrol/analysis/TrackerAnalysisManager.java +++ b/app/src/main/java/net/kollnig/missioncontrol/analysis/TrackerAnalysisManager.java @@ -36,8 +36,8 @@ */ public class TrackerAnalysisManager { private static final String PREFS_NAME = "library_analysis"; - // Single work name ensures only one analysis runs at a time (prevents OOM) - private static final String WORK_NAME = "tracker_analysis"; + private static final String WORK_NAME_PREFIX = "tracker_analysis_"; + private static final String ATTEMPTED_VERSION_PREFIX = "attempted_versioncode_"; private static TrackerAnalysisManager instance; private final Context mContext; @@ -68,34 +68,46 @@ public static synchronized TrackerAnalysisManager getInstance(Context context) { /** * Starts an analysis for the given package using WorkManager. - * Only one analysis runs at a time to prevent OOM; others are queued. + * Duplicate requests for the same package are ignored while one is pending. * Observe progress via {@link #getWorkInfoByPackageLiveData(String)}. * * @param packageName The package to analyze */ public void startAnalysis(String packageName) { - Data inputData = new Data.Builder() + startAnalysis(packageName, -1, null); + } + + /** + * Starts an analysis and optionally updates an install notification with the + * result when the worker finishes. + */ + public void startAnalysis(String packageName, int notificationUid, @Nullable String appName) { + markAnalysisAttempted(packageName); + + Data.Builder dataBuilder = new Data.Builder() .putString(TrackerAnalysisWorker.KEY_PACKAGE_NAME, packageName) - .build(); + .putInt(TrackerAnalysisWorker.KEY_NOTIFICATION_UID, notificationUid); + if (appName != null) + dataBuilder.putString(TrackerAnalysisWorker.KEY_APP_NAME, appName); + + Data inputData = dataBuilder.build(); OneTimeWorkRequest workRequest = new OneTimeWorkRequest.Builder(TrackerAnalysisWorker.class) .setInputData(inputData) .addTag(packageName) .build(); - // Use global work name + APPEND to serialize all analyses (prevents OOM from - // concurrent scans) workManager.enqueueUniqueWork( - WORK_NAME, - ExistingWorkPolicy.APPEND_OR_REPLACE, + getWorkName(packageName), + ExistingWorkPolicy.KEEP, workRequest); } /** - * Observe work status for a given package (by tag). + * Observe work status for the package's unique analysis work. */ public LiveData> getWorkInfoByPackageLiveData(String packageName) { - return workManager.getWorkInfosByTagLiveData(packageName); + return workManager.getWorkInfosForUniqueWorkLiveData(getWorkName(packageName)); } @Nullable @@ -105,7 +117,7 @@ public String getCachedResult(String packageName) { public boolean isCacheStale(String packageName) { try { - PackageInfo pkg = mContext.getPackageManager().getPackageInfo(packageName, 0); + PackageInfo pkg = getPackageInfo(packageName); SharedPreferences prefs = getPrefs(); int cachedVersionCode = prefs.getInt("versioncode_" + packageName, Integer.MIN_VALUE); return pkg.versionCode > cachedVersionCode; @@ -114,6 +126,11 @@ public boolean isCacheStale(String packageName) { } } + public boolean shouldStartAnalysis(String packageName) { + return shouldStartAnalysis(getCachedResult(packageName), isCacheStale(packageName), + hasAttemptedCurrentVersion(packageName)); + } + /** * Saves analysis result to cache. Called by the Worker. */ @@ -124,7 +141,55 @@ public void cacheResult(String packageName, String result, int versionCode) { .apply(); } + public static int countTrackers(String result) { + if (result == null) + return 0; + + int count = 0; + int index = result.indexOf("•"); + while (index >= 0) { + count++; + index = result.indexOf("•", index + 1); + } + + return count; + } + + static String getWorkName(String packageName) { + return WORK_NAME_PREFIX + packageName; + } + + static boolean shouldStartAnalysis(@Nullable String cachedResult, boolean cacheStale, + boolean attemptedCurrentVersion) { + return !attemptedCurrentVersion && (cachedResult == null || cacheStale); + } + private SharedPreferences getPrefs() { return mContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); } + + private void markAnalysisAttempted(String packageName) { + try { + PackageInfo pkg = getPackageInfo(packageName); + getPrefs().edit() + .putInt(ATTEMPTED_VERSION_PREFIX + packageName, pkg.versionCode) + .apply(); + } catch (PackageManager.NameNotFoundException ignored) { + } + } + + private boolean hasAttemptedCurrentVersion(String packageName) { + try { + PackageInfo pkg = getPackageInfo(packageName); + int attemptedVersionCode = getPrefs().getInt(ATTEMPTED_VERSION_PREFIX + packageName, + Integer.MIN_VALUE); + return attemptedVersionCode >= pkg.versionCode; + } catch (PackageManager.NameNotFoundException e) { + return false; + } + } + + private PackageInfo getPackageInfo(String packageName) throws PackageManager.NameNotFoundException { + return mContext.getPackageManager().getPackageInfo(packageName, 0); + } } diff --git a/app/src/main/java/net/kollnig/missioncontrol/analysis/TrackerAnalysisWorker.java b/app/src/main/java/net/kollnig/missioncontrol/analysis/TrackerAnalysisWorker.java index 530826442..75b613123 100644 --- a/app/src/main/java/net/kollnig/missioncontrol/analysis/TrackerAnalysisWorker.java +++ b/app/src/main/java/net/kollnig/missioncontrol/analysis/TrackerAnalysisWorker.java @@ -17,19 +17,43 @@ package net.kollnig.missioncontrol.analysis; +import static net.kollnig.missioncontrol.DetailsActivity.INTENT_EXTRA_APP_NAME; +import static net.kollnig.missioncontrol.DetailsActivity.INTENT_EXTRA_APP_PACKAGENAME; +import static net.kollnig.missioncontrol.DetailsActivity.INTENT_EXTRA_APP_UID; + +import android.app.Notification; +import android.app.PendingIntent; import android.content.Context; +import android.content.Intent; import android.content.pm.PackageInfo; import android.content.pm.PackageManager; +import android.net.Uri; +import android.os.Build; +import android.provider.Settings; +import android.util.Log; import androidx.annotation.NonNull; +import androidx.core.app.NotificationCompat; +import androidx.core.app.NotificationManagerCompat; import androidx.work.Data; import androidx.work.Worker; import androidx.work.WorkerParameters; +import eu.faircode.netguard.PendingIntentCompat; +import eu.faircode.netguard.Util; +import net.kollnig.missioncontrol.DetailsActivity; +import net.kollnig.missioncontrol.R; + import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.Semaphore; public class TrackerAnalysisWorker extends Worker { + private static final String TAG = TrackerAnalysisWorker.class.getSimpleName(); + private static final Semaphore ANALYSIS_SEMAPHORE = new Semaphore(1); + public static final String KEY_PACKAGE_NAME = "package_name"; + public static final String KEY_NOTIFICATION_UID = "notification_uid"; + public static final String KEY_APP_NAME = "app_name"; public static final String KEY_RESULT = "result"; public static final String KEY_ERROR = "error"; public static final String KEY_PROGRESS = "progress"; @@ -48,10 +72,14 @@ public Result doWork() { .build()); } + boolean acquired = false; try { Context context = getApplicationContext(); PackageInfo pkg = context.getPackageManager().getPackageInfo(packageName, 0); + ANALYSIS_SEMAPHORE.acquire(); + acquired = true; + // Perform analysis with progress reporting String result = doAnalysis(context, packageName); @@ -59,6 +87,8 @@ public Result doWork() { TrackerAnalysisManager.getInstance(context) .cacheResult(packageName, result, pkg.versionCode); + updateInstallNotification(context, packageName, result); + return Result.success(new Data.Builder() .putString(KEY_RESULT, result) .build()); @@ -75,6 +105,9 @@ public Result doWork() { return Result.failure(new Data.Builder() .putString(KEY_ERROR, e.getMessage() != null ? e.getMessage() : "Unknown error") .build()); + } finally { + if (acquired) + ANALYSIS_SEMAPHORE.release(); } } @@ -91,4 +124,54 @@ private String doAnalysis(Context context, String packageName) throws AnalysisEx }); return analyser.analyseApp(packageName); } + + private void updateInstallNotification(Context context, String packageName, String result) { + int uid = getInputData().getInt(KEY_NOTIFICATION_UID, -1); + if (uid < 0 || !Util.canNotify(context)) + return; + + String appName = getInputData().getString(KEY_APP_NAME); + if (appName == null) + appName = packageName; + + try { + NotificationManagerCompat.from(context).notify(uid, + buildInstallNotification(context, packageName, uid, appName, result)); + } catch (SecurityException ex) { + Log.w(TAG, "SecurityException updating install notification for uid " + uid + ": " + ex.getMessage()); + } + } + + static Notification buildInstallNotification(Context context, String packageName, int uid, String appName, + String result) { + int trackerCount = TrackerAnalysisManager.countTrackers(result); + + Intent main = new Intent(context, DetailsActivity.class); + main.putExtra(INTENT_EXTRA_APP_NAME, appName); + main.putExtra(INTENT_EXTRA_APP_PACKAGENAME, packageName); + main.putExtra(INTENT_EXTRA_APP_UID, uid); + PendingIntent pi = PendingIntentCompat.getActivity(context, uid, main, + PendingIntent.FLAG_UPDATE_CURRENT); + + NotificationCompat.Builder builder = new NotificationCompat.Builder(context, "notify"); + builder.setSmallIcon(R.drawable.ic_rocket_white) + .setContentIntent(pi) + .addAction(0, context.getString(R.string.title_activity_detail), pi) + .setColor(context.getResources().getColor(R.color.colorTrackerControl)) + .setAutoCancel(true); + builder.setContentTitle(context.getString(R.string.msg_installed, appName)) + .setContentText(context.getString(R.string.msg_installed_tracker_libraries_found, trackerCount)); + + Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS); + intent.setData(Uri.parse("package:" + packageName)); + PendingIntent piUninstall = PendingIntentCompat.getActivity(context, uid + 10000, intent, + PendingIntent.FLAG_UPDATE_CURRENT); + builder.addAction(0, context.getString(R.string.uninstall), piUninstall); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) + builder.setCategory(NotificationCompat.CATEGORY_STATUS) + .setVisibility(NotificationCompat.VISIBILITY_SECRET); + + return builder.build(); + } } diff --git a/app/src/main/java/net/kollnig/missioncontrol/details/TrackersListAdapter.java b/app/src/main/java/net/kollnig/missioncontrol/details/TrackersListAdapter.java index 3ace5f24b..8225b185f 100644 --- a/app/src/main/java/net/kollnig/missioncontrol/details/TrackersListAdapter.java +++ b/app/src/main/java/net/kollnig/missioncontrol/details/TrackersListAdapter.java @@ -170,6 +170,9 @@ private void setupTrackerAnalysisButton(View view) { manager.startAnalysis(mAppId); // Fragment's observer will pick up the work state changes }); + + if (manager.shouldStartAnalysis(mAppId)) + manager.startAnalysis(mAppId); } /** diff --git a/app/src/test/java/net/kollnig/missioncontrol/analysis/TrackerAnalysisManagerTest.java b/app/src/test/java/net/kollnig/missioncontrol/analysis/TrackerAnalysisManagerTest.java new file mode 100644 index 000000000..bc04e9475 --- /dev/null +++ b/app/src/test/java/net/kollnig/missioncontrol/analysis/TrackerAnalysisManagerTest.java @@ -0,0 +1,38 @@ +package net.kollnig.missioncontrol.analysis; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public class TrackerAnalysisManagerTest { + @Test + public void countTrackersCountsBulletPrefixedAnalysisRows() { + assertEquals(0, TrackerAnalysisManager.countTrackers(null)); + assertEquals(0, TrackerAnalysisManager.countTrackers("None")); + assertEquals(1, TrackerAnalysisManager.countTrackers("\n• Google")); + assertEquals(3, TrackerAnalysisManager.countTrackers("\n• Google\n• Meta\n• Branch")); + } + + @Test + public void workNameIsUniquePerPackage() { + assertEquals("tracker_analysis_org.example.one", + TrackerAnalysisManager.getWorkName("org.example.one")); + assertEquals("tracker_analysis_org.example.two", + TrackerAnalysisManager.getWorkName("org.example.two")); + } + + @Test + public void analysisStartsWhenCacheIsMissingOrStale() { + assertTrue(TrackerAnalysisManager.shouldStartAnalysis(null, false, false)); + assertTrue(TrackerAnalysisManager.shouldStartAnalysis("\n• Google", true, false)); + assertFalse(TrackerAnalysisManager.shouldStartAnalysis("\n• Google", false, false)); + } + + @Test + public void analysisDoesNotAutoRepeatForAlreadyAttemptedVersion() { + assertFalse(TrackerAnalysisManager.shouldStartAnalysis(null, false, true)); + assertFalse(TrackerAnalysisManager.shouldStartAnalysis("\n• Google", true, true)); + } +}