diff --git a/app/src/androidTest/java/org/fdroid/fdroid/CleanCacheServiceTest.java b/app/src/androidTest/java/org/fdroid/fdroid/CleanCacheWorkerTest.java similarity index 81% rename from app/src/androidTest/java/org/fdroid/fdroid/CleanCacheServiceTest.java rename to app/src/androidTest/java/org/fdroid/fdroid/CleanCacheWorkerTest.java index 7178bd34a..e3706dc03 100644 --- a/app/src/androidTest/java/org/fdroid/fdroid/CleanCacheServiceTest.java +++ b/app/src/androidTest/java/org/fdroid/fdroid/CleanCacheWorkerTest.java @@ -1,11 +1,13 @@ package org.fdroid.fdroid; import android.app.Instrumentation; -import androidx.test.platform.app.InstrumentationRegistry; + import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; import org.apache.commons.io.FileUtils; import org.fdroid.fdroid.compat.FileCompatTest; +import org.fdroid.fdroid.work.CleanCacheWorker; import org.junit.Test; import org.junit.runner.RunWith; @@ -16,9 +18,8 @@ import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; @RunWith(AndroidJUnit4.class) -public class CleanCacheServiceTest { - - public static final String TAG = "CleanCacheServiceTest"; +public class CleanCacheWorkerTest { + public static final String TAG = "CleanCacheWorkerTest"; @Test public void testClearOldFiles() throws IOException, InterruptedException { @@ -48,18 +49,18 @@ public class CleanCacheServiceTest { assertTrue(second.createNewFile()); assertTrue(second.exists()); - CleanCacheService.clearOldFiles(dir, 3000); // check all in dir + CleanCacheWorker.clearOldFiles(dir, 3000); // check all in dir assertFalse(first.exists()); assertTrue(second.exists()); Thread.sleep(7000); - CleanCacheService.clearOldFiles(second, 3000); // check just second file + CleanCacheWorker.clearOldFiles(second, 3000); // check just second file assertFalse(first.exists()); assertFalse(second.exists()); // make sure it doesn't freak out on a non-existent file File nonexistent = new File(tempDir, "nonexistent"); - CleanCacheService.clearOldFiles(nonexistent, 1); - CleanCacheService.clearOldFiles(null, 1); + CleanCacheWorker.clearOldFiles(nonexistent, 1); + CleanCacheWorker.clearOldFiles(null, 1); } } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d8278645e..80592fbb3 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -241,14 +241,6 @@ android:name=".installer.InstallerService" android:permission="android.permission.BIND_JOB_SERVICE" android:exported="false"/> - - * Note that it is not perfect, because some devices seem to not provide a list of running app * processes when asked. In such situations, F-Droid may regress to the behaviour where some diff --git a/app/src/main/java/org/fdroid/fdroid/receiver/DeviceStorageReceiver.java b/app/src/main/java/org/fdroid/fdroid/receiver/DeviceStorageReceiver.java index b4f1fbfe1..6e0d10c18 100644 --- a/app/src/main/java/org/fdroid/fdroid/receiver/DeviceStorageReceiver.java +++ b/app/src/main/java/org/fdroid/fdroid/receiver/DeviceStorageReceiver.java @@ -3,9 +3,10 @@ package org.fdroid.fdroid.receiver; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; -import org.fdroid.fdroid.CleanCacheService; + import org.fdroid.fdroid.DeleteCacheService; import org.fdroid.fdroid.Utils; +import org.fdroid.fdroid.work.WorkUtils; public class DeviceStorageReceiver extends BroadcastReceiver { @Override @@ -18,7 +19,7 @@ public class DeviceStorageReceiver extends BroadcastReceiver { int percentageFree = Utils.getPercent(Utils.getImageCacheDirAvailableMemory(context), Utils.getImageCacheDirTotalMemory(context)); if (percentageFree > 2) { - CleanCacheService.start(context); + WorkUtils.scheduleCleanCache(context); } else { DeleteCacheService.deleteAll(context); } diff --git a/app/src/main/java/org/fdroid/fdroid/views/PreferencesFragment.java b/app/src/main/java/org/fdroid/fdroid/views/PreferencesFragment.java index a6e2ba4bf..e81734779 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/PreferencesFragment.java +++ b/app/src/main/java/org/fdroid/fdroid/views/PreferencesFragment.java @@ -48,7 +48,6 @@ import androidx.preference.SwitchPreference; import androidx.recyclerview.widget.LinearSmoothScroller; import androidx.recyclerview.widget.RecyclerView; -import org.fdroid.fdroid.CleanCacheService; import org.fdroid.fdroid.FDroidApp; import org.fdroid.fdroid.Languages; import org.fdroid.fdroid.Preferences; @@ -58,6 +57,7 @@ import org.fdroid.fdroid.Utils; import org.fdroid.fdroid.data.RepoProvider; import org.fdroid.fdroid.installer.InstallHistoryService; import org.fdroid.fdroid.installer.PrivilegedInstaller; +import org.fdroid.fdroid.work.WorkUtils; import info.guardianproject.netcipher.proxy.OrbotHelper; @@ -304,7 +304,7 @@ public class PreferencesFragment extends PreferenceFragmentCompat entrySummary(key); if (changing && currentKeepCacheTime != Preferences.get().getKeepCacheTime()) { - CleanCacheService.schedule(getActivity()); + WorkUtils.scheduleCleanCache(requireContext()); } break; diff --git a/app/src/main/java/org/fdroid/fdroid/CleanCacheService.java b/app/src/main/java/org/fdroid/fdroid/work/CleanCacheWorker.java similarity index 50% rename from app/src/main/java/org/fdroid/fdroid/CleanCacheService.java rename to app/src/main/java/org/fdroid/fdroid/work/CleanCacheWorker.java index 618df2f10..a6407d84e 100644 --- a/app/src/main/java/org/fdroid/fdroid/CleanCacheService.java +++ b/app/src/main/java/org/fdroid/fdroid/work/CleanCacheWorker.java @@ -1,90 +1,45 @@ -package org.fdroid.fdroid; +package org.fdroid.fdroid.work; -import android.app.AlarmManager; -import android.app.PendingIntent; -import android.app.job.JobInfo; -import android.app.job.JobScheduler; -import android.content.ComponentName; import android.content.Context; -import android.content.Intent; import android.os.Build; import android.os.Process; -import android.os.SystemClock; +import android.system.ErrnoException; +import android.system.Os; +import android.system.StructStat; + import androidx.annotation.NonNull; -import androidx.core.app.JobIntentService; -import androidx.core.content.ContextCompat; +import androidx.annotation.RequiresApi; +import androidx.work.Worker; +import androidx.work.WorkerParameters; import org.apache.commons.io.FileUtils; +import org.fdroid.fdroid.Preferences; +import org.fdroid.fdroid.Utils; import org.fdroid.fdroid.installer.ApkCache; import java.io.File; import java.util.concurrent.TimeUnit; -/** - * Handles cleaning up caches files that are not going to be used, and do not - * block the operation of the app itself. For things that must happen before - * F-Droid starts normal operation, that should go into - * {@link FDroidApp#onCreate()}. - *

- * These files should only be deleted when they are at least an hour-ish old, - * in case they are actively in use while {@code CleanCacheService} is running. - * {@link #clearOldFiles(File, long)} checks the file age using access time from - * {@link android.system.StructStat#st_atime} on {@link android.os.Build.VERSION_CODES#LOLLIPOP} - * and newer. On older Android, last modified time from {@link File#lastModified()} - * is used. - */ -public class CleanCacheService extends JobIntentService { - public static final String TAG = "CleanCacheService"; +public class CleanCacheWorker extends Worker { + private static final String TAG = CleanCacheWorker.class.getSimpleName(); - private static final int JOB_ID = 0x982374; - - /** - * Schedule or cancel this service to update the app index, according to the - * current preferences. Should be called a) at boot, b) if the preference - * is changed, or c) on startup, in case we get upgraded. - */ - public static void schedule(Context context) { - long keepTime = Preferences.get().getKeepCacheTime(); - long interval = TimeUnit.DAYS.toMillis(1); - if (keepTime < interval) { - interval = keepTime; - } - - if (Build.VERSION.SDK_INT < 21) { - Intent intent = new Intent(context, CleanCacheService.class); - PendingIntent pending = PendingIntent.getService(context, 0, intent, 0); - - AlarmManager alarm = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); - alarm.cancel(pending); - alarm.setInexactRepeating(AlarmManager.ELAPSED_REALTIME, - SystemClock.elapsedRealtime() + 5000, interval, pending); - } else { - Utils.debugLog(TAG, "Using android-21 JobScheduler for updates"); - JobScheduler jobScheduler = ContextCompat.getSystemService(context, JobScheduler.class); - ComponentName componentName = new ComponentName(context, CleanCacheJobService.class); - JobInfo.Builder builder = new JobInfo.Builder(JOB_ID, componentName) - .setRequiresDeviceIdle(true) - .setRequiresCharging(true) - .setPeriodic(interval); - if (Build.VERSION.SDK_INT >= 26) { - builder.setRequiresBatteryNotLow(true); - } - jobScheduler.schedule(builder.build()); - - } - } - - public static void start(Context context) { - enqueueWork(context, CleanCacheService.class, JOB_ID, new Intent(context, CleanCacheService.class)); + public CleanCacheWorker(@NonNull Context context, @NonNull WorkerParameters workerParams) { + super(context, workerParams); } + @NonNull @Override - protected void onHandleWork(@NonNull Intent intent) { + public Result doWork() { Process.setThreadPriority(Process.THREAD_PRIORITY_LOWEST); - deleteExpiredApksFromCache(); - deleteStrayIndexFiles(); - deleteOldInstallerFiles(); - deleteOldIcons(); + try { + deleteExpiredApksFromCache(); + deleteStrayIndexFiles(); + deleteOldInstallerFiles(); + deleteOldIcons(); + return Result.success(); + } catch (Exception e) { + return Result.failure(); + } } /** @@ -93,7 +48,7 @@ public class CleanCacheService extends JobIntentService { * any APK in the cache that is older than that preference specifies. */ private void deleteExpiredApksFromCache() { - File cacheDir = ApkCache.getApkCacheDir(getBaseContext()); + File cacheDir = ApkCache.getApkCacheDir(getApplicationContext()); clearOldFiles(cacheDir, Preferences.get().getKeepCacheTime()); } @@ -102,13 +57,15 @@ public class CleanCacheService extends JobIntentService { * a safe place before installing. It doesn't clean up them reliably yet. */ private void deleteOldInstallerFiles() { - File filesDir = getFilesDir(); + File filesDir = getApplicationContext().getFilesDir(); if (filesDir == null) { + Utils.debugLog(TAG, "The files directory doesn't exist."); return; } final File[] files = filesDir.listFiles(); if (files == null) { + Utils.debugLog(TAG, "The files directory doesn't have any files."); return; } @@ -132,13 +89,15 @@ public class CleanCacheService extends JobIntentService { * {@link org.fdroid.fdroid.net.DownloaderFactory#create(Context, String)}, e.g. "dl-*" */ private void deleteStrayIndexFiles() { - File cacheDir = getCacheDir(); + File cacheDir = getApplicationContext().getCacheDir(); if (cacheDir == null) { + Utils.debugLog(TAG, "The cache directory doesn't exist."); return; } final File[] files = cacheDir.listFiles(); if (files == null) { + Utils.debugLog(TAG, "The cache directory doesn't have files."); return; } @@ -156,7 +115,7 @@ public class CleanCacheService extends JobIntentService { * Delete cached icons that have not been accessed in over a year. */ private void deleteOldIcons() { - clearOldFiles(Utils.getImageCacheDir(this), TimeUnit.DAYS.toMillis(365)); + clearOldFiles(Utils.getImageCacheDir(getApplicationContext()), TimeUnit.DAYS.toMillis(365)); } /** @@ -170,24 +129,58 @@ public class CleanCacheService extends JobIntentService { */ public static void clearOldFiles(File f, long millisAgo) { if (f == null) { + Utils.debugLog(TAG, "No files to be cleared."); return; } long olderThan = System.currentTimeMillis() - millisAgo; if (f.isDirectory()) { File[] files = f.listFiles(); if (files == null) { + Utils.debugLog(TAG, "No more files to be cleared."); return; } for (File file : files) { clearOldFiles(file, millisAgo); } - f.delete(); - } else if (Build.VERSION.SDK_INT < 21) { + deleteFileAndLog(f); + } else if (Build.VERSION.SDK_INT <= 21) { if (FileUtils.isFileOlder(f, olderThan)) { - f.delete(); + deleteFileAndLog(f); } } else { - CleanCacheService21.deleteIfOld(f, olderThan); + Impl21.deleteIfOld(f, olderThan); } } -} \ No newline at end of file + + private static void deleteFileAndLog(final File file) { + file.delete(); + Utils.debugLog(TAG, "Deleted file: " + file); + } + + @RequiresApi(api = 21) + private static class Impl21 { + /** + * Recursively delete files in {@code f} that were last used + * {@code millisAgo} milliseconds ago. On {@code android-21} and newer, this + * is based on the last access of the file, on older Android versions, it is + * based on the last time the file was modified, e.g. downloaded. + * + * @param file The file or directory to clean + * @param olderThan The number of milliseconds old that marks a file for deletion. + */ + public static void deleteIfOld(File file, long olderThan) { + if (file == null || !file.exists()) { + Utils.debugLog(TAG, "No files to be cleared."); + return; + } + try { + StructStat stat = Os.lstat(file.getAbsolutePath()); + if ((stat.st_atime * 1000L) < olderThan) { + deleteFileAndLog(file); + } + } catch (ErrnoException e) { + Utils.debugLog(TAG, "An exception occurred while deleting: ", e); + } + } + } +} diff --git a/app/src/main/java/org/fdroid/fdroid/work/WorkUtils.java b/app/src/main/java/org/fdroid/fdroid/work/WorkUtils.java new file mode 100644 index 000000000..1314231ff --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/work/WorkUtils.java @@ -0,0 +1,49 @@ +package org.fdroid.fdroid.work; + +import android.content.Context; +import android.os.Build; + +import androidx.annotation.NonNull; +import androidx.work.Constraints; +import androidx.work.ExistingPeriodicWorkPolicy; +import androidx.work.PeriodicWorkRequest; +import androidx.work.WorkManager; + +import org.fdroid.fdroid.Preferences; +import org.fdroid.fdroid.Utils; + +import java.util.concurrent.TimeUnit; + +public class WorkUtils { + private static final String TAG = WorkUtils.class.getSimpleName(); + + private WorkUtils() { } + + /** + * Schedule or cancel a work request to update the app index, according to the + * current preferences. Should be called a) at boot, b) if the preference + * is changed, or c) on startup, in case we get upgraded. + */ + public static void scheduleCleanCache(@NonNull final Context context) { + final WorkManager workManager = WorkManager.getInstance(context); + final long keepTime = Preferences.get().getKeepCacheTime(); + long interval = TimeUnit.DAYS.toMillis(1); + if (keepTime < interval) { + interval = keepTime; + } + + final Constraints.Builder constraintsBuilder = new Constraints.Builder() + .setRequiresCharging(true) + .setRequiresBatteryNotLow(true); + if (Build.VERSION.SDK_INT >= 23) { + constraintsBuilder.setRequiresDeviceIdle(true); + } + final PeriodicWorkRequest cleanCache = + new PeriodicWorkRequest.Builder(CleanCacheWorker.class, interval, TimeUnit.MILLISECONDS) + .setConstraints(constraintsBuilder.build()) + .build(); + workManager.enqueueUniquePeriodicWork("clean_cache", + ExistingPeriodicWorkPolicy.REPLACE, cleanCache); + Utils.debugLog(TAG, "Scheduled periodic work for cleaning the cache."); + } +}