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
13 changes: 6 additions & 7 deletions app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand All @@ -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() {
Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<java.util.List<WorkInfo>> getWorkInfoByPackageLiveData(String packageName) {
return workManager.getWorkInfosByTagLiveData(packageName);
return workManager.getWorkInfosForUniqueWorkLiveData(getWorkName(packageName));
}

@Nullable
Expand All @@ -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;
Expand All @@ -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.
*/
Expand All @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -48,17 +72,23 @@ 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);

// Cache the result
TrackerAnalysisManager.getInstance(context)
.cacheResult(packageName, result, pkg.versionCode);

updateInstallNotification(context, packageName, result);

return Result.success(new Data.Builder()
.putString(KEY_RESULT, result)
.build());
Expand All @@ -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();
}
}

Expand All @@ -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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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));
}
}
Loading