Move capture list files check to background thread

This ensures that the UI thread is not delayed, preving ANRs.
The check is performed at max once every 10 seconds.
This commit is contained in:
emanuele-f 2026-05-21 22:57:32 +02:00
parent ddba6ae1a6
commit 6b9aa03f7e
3 changed files with 153 additions and 17 deletions

View File

@ -26,6 +26,7 @@ import android.net.Uri;
import android.os.Bundle;
import android.os.Environment;
import android.os.StatFs;
import android.os.SystemClock;
import android.provider.MediaStore;
import android.text.InputType;
import android.view.ContextMenu;
@ -70,6 +71,7 @@ import java.util.Set;
public class CaptureListFragment extends Fragment {
private static final String TAG = "CaptureListFragment";
private static long sLastScanCompletedAt = 0;
private CaptureList mCaptureList;
private CaptureListAdapter mAdapter;
@ -99,6 +101,11 @@ public class CaptureListFragment extends Fragment {
Context ctx = requireContext();
mCaptureList = new CaptureList(ctx);
// only run the scan if more than 10 seconds passed since the last one
if ((SystemClock.elapsedRealtime() - sLastScanCompletedAt) > 10_000)
scanForDeletedFiles();
mAdapter = new CaptureListAdapter(ctx);
mRecycler.setLayoutManager(new LinearLayoutManager(ctx));
mRecycler.setAdapter(mAdapter);
@ -146,6 +153,8 @@ public class CaptureListFragment extends Fragment {
public boolean onMenuItemSelected(@NonNull MenuItem menuItem) {
if (menuItem.getItemId() == R.id.refresh) {
refresh();
scanForDeletedFiles();
return true;
}
return false;
@ -160,8 +169,6 @@ public class CaptureListFragment extends Fragment {
}
};
requireActivity().getOnBackPressedDispatcher().addCallback(getViewLifecycleOwner(), mBackCallback);
refresh();
}
@Override
@ -170,8 +177,36 @@ public class CaptureListFragment extends Fragment {
refresh();
}
@Override
public void onDestroyView() {
if (mCaptureList != null)
mCaptureList.abortScan();
super.onDestroyView();
}
private void scanForDeletedFiles() {
Log.d(TAG, "Scanning file changes on disk...");
mCaptureList.scanForDeletedFiles(changed -> {
Log.d(TAG, "File scan completed, changed=" + changed);
Context context = getContext();
if (changed && (context != null)) {
refreshUi();
Utils.showToast(context, R.string.capture_list_files_changed);
}
sLastScanCompletedAt = SystemClock.elapsedRealtime();
});
}
private void refresh() {
mCaptureList.reload();
refreshUi();
}
private void refreshUi() {
List<CaptureList.Capture> captures = mCaptureList.getCaptures();
mAdapter.setItems(captures);

View File

@ -22,7 +22,10 @@ package com.emanuelef.remote_capture.model;
import android.content.Context;
import android.content.SharedPreferences;
import android.net.Uri;
import android.os.Handler;
import android.os.Looper;
import androidx.collection.ArraySet;
import androidx.preference.PreferenceManager;
import com.emanuelef.remote_capture.Log;
@ -33,19 +36,27 @@ import com.google.gson.GsonBuilder;
import com.google.gson.JsonParseException;
import com.google.gson.reflect.TypeToken;
import org.jetbrains.annotations.NotNull;
import java.io.File;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class CaptureList {
private static final String TAG = "CaptureList";
private final SharedPreferences mPrefs;
private final Context mContext;
private ArrayList<Capture> mCaptures;
private ExecutorService mScanExecutor;
private Set<String> mRenamedDuringScan;
public record TargetApp(int uid, String packageName, String name) {}
@ -86,30 +97,115 @@ public class CaptureList {
fromJson(serialized);
else
mCaptures = new ArrayList<>();
}
private record ScanDiff(Set<String> missing, Set<String> clearDecrypted) {}
public interface OnScanDoneListener {
void onScanDone(boolean changed);
}
/**
* Check the files actually on disk and update the model when done.
* Use abortScan() to cancel the scan if the listener is deallocated.
*
* @param listener onScanDone is invoked when done
* @return true if scan started, false otherwise
*/
public boolean scanForDeletedFiles(@NotNull OnScanDoneListener listener) {
if (mScanExecutor != null)
return false;
final Handler handler = new Handler(Looper.getMainLooper());
if (mCaptures.isEmpty()) {
handler.post(() -> listener.onScanDone(false));
return true;
}
ArrayList<String> uris = new ArrayList<>(mCaptures.size());
ArrayList<Boolean> decryptedFlags = new ArrayList<>(mCaptures.size());
for (Capture c: mCaptures) {
uris.add(c.uri);
decryptedFlags.add(c.decrypted);
}
mRenamedDuringScan = new ArraySet<>();
mScanExecutor = Executors.newSingleThreadExecutor();
mScanExecutor.submit(() -> {
ScanDiff diff = scan(uris, decryptedFlags);
handler.post(() -> onScanResult(diff, listener));
});
return true;
}
public void abortScan() {
if (mScanExecutor != null) {
mScanExecutor.shutdownNow();
mScanExecutor = null;
mRenamedDuringScan = null;
}
}
private ScanDiff scan(List<String> uris, List<Boolean> decryptedFlags) {
Set<String> missing = new HashSet<>();
Set<String> clearDecrypted = new HashSet<>();
for (int i = 0; i < uris.size(); i++) {
String uri = uris.get(i);
try {
String path = Utils.uriToFilePath(mContext, Uri.parse(uri));
if ((path == null) || !new File(path).exists()) {
missing.add(uri);
continue;
}
if (decryptedFlags.get(i) && !path.endsWith(".pcapng") &&
(Utils.findSiblingKeylog(path) == null))
{
clearDecrypted.add(uri);
}
} catch (Exception e) {
Log.w(TAG, "scan: check failed for " + uri + ": " + e.getMessage());
}
}
return new ScanDiff(missing, clearDecrypted);
}
private void onScanResult(ScanDiff diff, @NotNull OnScanDoneListener listener) {
if (mScanExecutor == null)
// scan was aborted
return;
boolean changed = false;
Iterator<Capture> it = mCaptures.iterator();
while (it.hasNext()) {
Capture c = it.next();
try {
String path = Utils.uriToFilePath(mContext, Uri.parse(c.uri));
if ((path == null) || !new File(path).exists()) {
it.remove();
Log.i(TAG, "removing deleted capture: " + c.uri);
changed = true;
continue;
}
if (c.decrypted && !path.endsWith(".pcapng") && (Utils.findSiblingKeylog(path) == null)) {
Log.i(TAG, "clearing decrypted flag for " + c.uri + " (sibling keylog missing)");
c.decrypted = false;
changed = true;
}
} catch (Exception e) {
Log.w(TAG, "reload: check failed for " + c.uri + ": " + e.getMessage());
if (mRenamedDuringScan.contains(c.uri))
// ignore renamed captures, as they may have changed name on disk
continue;
if (diff.missing.contains(c.uri)) {
Log.i(TAG, "removing deleted capture: " + c.uri);
it.remove();
changed = true;
} else if (c.decrypted && diff.clearDecrypted.contains(c.uri)) {
Log.i(TAG, "clearing decrypted flag for " + c.uri + " (sibling keylog missing)");
c.decrypted = false;
changed = true;
}
}
mScanExecutor = null;
mRenamedDuringScan = null;
if (changed)
save();
listener.onScanDone(changed);
}
public void save() {
@ -154,6 +250,10 @@ public class CaptureList {
public void rename(Capture capture, String newName) {
capture.name = newName;
if (mRenamedDuringScan != null)
mRenamedDuringScan.add(capture.uri);
save();
}

View File

@ -611,4 +611,5 @@
<string name="n_hours_minutes">%1$d h, %2$d m</string>
<string name="connections_log_size">Max logged connections</string>
<string name="connections_log_size_entry">%1$s (~%2$d MB of RAM used)</string>
<string name="capture_list_files_changed">Capture files changed on disk, list updated</string>
</resources>