diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 5ef24f07b..34aa8c42d 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -82,7 +82,7 @@ errorprone: - adb devices - adb shell input keyevent 82 & - if [ $AVD_SDK -lt 25 ] || ! emulator -accel-check; then - export FLAG=-Pandroid.testInstrumentationRunnerArguments.notAnnotation=android.support.test.filters.LargeTest; + export FLAG=-Pandroid.testInstrumentationRunnerArguments.notAnnotation=androidx.test.filters.LargeTest; fi - ./gradlew connectedFullDebugAndroidTest $FLAG || ./gradlew connectedFullDebugAndroidTest $FLAG diff --git a/app/build.gradle b/app/build.gradle index 26d2a0a17..7390a9974 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -145,6 +145,7 @@ dependencies { implementation 'androidx.vectordrawable:vectordrawable:1.1.0' implementation 'androidx.constraintlayout:constraintlayout:1.1.3' implementation 'androidx.palette:palette:1.0.0' + implementation 'androidx.work:work-runtime:2.4.0' implementation 'com.google.android.material:material:1.1.0' @@ -176,6 +177,7 @@ dependencies { testImplementation 'org.hamcrest:hamcrest:2.2' testImplementation 'org.bouncycastle:bcprov-jdk15on:1.65' + androidTestImplementation 'androidx.arch.core:core-testing:2.1.0' androidTestImplementation 'androidx.test:core:1.3.0' androidTestImplementation 'androidx.test:runner:1.3.0' androidTestImplementation 'androidx.test:rules:1.3.0' @@ -183,6 +185,7 @@ dependencies { androidTestImplementation 'androidx.test.ext:junit:1.1.2' androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0' androidTestImplementation 'androidx.test.uiautomator:uiautomator:2.2.0' + androidTestImplementation 'androidx.work:work-testing:2.4.0' } checkstyle { diff --git a/app/src/androidTest/java/org/fdroid/fdroid/CleanCacheServiceTest.java b/app/src/androidTest/java/org/fdroid/fdroid/CleanCacheServiceTest.java deleted file mode 100644 index 7178bd34a..000000000 --- a/app/src/androidTest/java/org/fdroid/fdroid/CleanCacheServiceTest.java +++ /dev/null @@ -1,65 +0,0 @@ -package org.fdroid.fdroid; - -import android.app.Instrumentation; -import androidx.test.platform.app.InstrumentationRegistry; -import androidx.test.ext.junit.runners.AndroidJUnit4; - -import org.apache.commons.io.FileUtils; -import org.fdroid.fdroid.compat.FileCompatTest; -import org.junit.Test; -import org.junit.runner.RunWith; - -import java.io.File; -import java.io.IOException; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -@RunWith(AndroidJUnit4.class) -public class CleanCacheServiceTest { - - public static final String TAG = "CleanCacheServiceTest"; - - @Test - public void testClearOldFiles() throws IOException, InterruptedException { - Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation(); - File tempDir = FileCompatTest.getWriteableDir(instrumentation); - assertTrue(tempDir.isDirectory()); - assertTrue(tempDir.canWrite()); - - File dir = new File(tempDir, "F-Droid-test.clearOldFiles"); - FileUtils.deleteQuietly(dir); - assertTrue(dir.mkdirs()); - assertTrue(dir.isDirectory()); - - File first = new File(dir, "first"); - first.deleteOnExit(); - - File second = new File(dir, "second"); - second.deleteOnExit(); - - assertFalse(first.exists()); - assertFalse(second.exists()); - - assertTrue(first.createNewFile()); - assertTrue(first.exists()); - - Thread.sleep(7000); - assertTrue(second.createNewFile()); - assertTrue(second.exists()); - - CleanCacheService.clearOldFiles(dir, 3000); // check all in dir - assertFalse(first.exists()); - assertTrue(second.exists()); - - Thread.sleep(7000); - CleanCacheService.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); - } -} diff --git a/app/src/androidTest/java/org/fdroid/fdroid/work/CleanCacheWorkerTest.java b/app/src/androidTest/java/org/fdroid/fdroid/work/CleanCacheWorkerTest.java new file mode 100644 index 000000000..b57cd1034 --- /dev/null +++ b/app/src/androidTest/java/org/fdroid/fdroid/work/CleanCacheWorkerTest.java @@ -0,0 +1,89 @@ +package org.fdroid.fdroid.work; + +import android.app.Instrumentation; +import androidx.arch.core.executor.testing.InstantTaskExecutorRule; +import androidx.test.filters.LargeTest; +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.work.OneTimeWorkRequest; +import androidx.work.WorkInfo; +import com.google.common.util.concurrent.ListenableFuture; +import org.apache.commons.io.FileUtils; +import org.fdroid.fdroid.compat.FileCompatTest; +import org.junit.Rule; +import org.junit.Test; + +import java.io.File; +import java.io.IOException; +import java.util.concurrent.ExecutionException; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * This test cannot run on Robolectric unfortunately since it does not support + *

+ * This is marked with {@link LargeTest} because it always fails on the emulator + * tests on GitLab CI. That excludes it from the test run there. + */ +@LargeTest +public class CleanCacheWorkerTest { + public static final String TAG = "CleanCacheWorkerEmulatorTest"; + + @Rule + public InstantTaskExecutorRule instantTaskExecutorRule = new InstantTaskExecutorRule(); + + @Rule + public WorkManagerTestRule workManagerTestRule = new WorkManagerTestRule(); + + @Test + public void testWorkRequest() throws ExecutionException, InterruptedException { + OneTimeWorkRequest request = new OneTimeWorkRequest.Builder(CleanCacheWorker.class).build(); + workManagerTestRule.workManager.enqueue(request).getResult(); + ListenableFuture workInfo = workManagerTestRule.workManager.getWorkInfoById(request.getId()); + assertEquals(WorkInfo.State.SUCCEEDED, workInfo.get().getState()); + } + + @Test + public void testClearOldFiles() throws IOException, InterruptedException { + Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation(); + File tempDir = FileCompatTest.getWriteableDir(instrumentation); + assertTrue(tempDir.isDirectory()); + assertTrue(tempDir.canWrite()); + + File dir = new File(tempDir, "F-Droid-test.clearOldFiles"); + FileUtils.deleteQuietly(dir); + assertTrue(dir.mkdirs()); + assertTrue(dir.isDirectory()); + + File first = new File(dir, "first"); + first.deleteOnExit(); + + File second = new File(dir, "second"); + second.deleteOnExit(); + + assertFalse(first.exists()); + assertFalse(second.exists()); + + assertTrue(first.createNewFile()); + assertTrue(first.exists()); + + Thread.sleep(7000); + assertTrue(second.createNewFile()); + assertTrue(second.exists()); + + CleanCacheWorker.clearOldFiles(dir, 3000); // check all in dir + assertFalse(first.exists()); + assertTrue(second.exists()); + + Thread.sleep(7000); + 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"); + CleanCacheWorker.clearOldFiles(nonexistent, 1); + CleanCacheWorker.clearOldFiles(null, 1); + } +} diff --git a/app/src/androidTest/java/org/fdroid/fdroid/work/WorkManagerTestRule.java b/app/src/androidTest/java/org/fdroid/fdroid/work/WorkManagerTestRule.java new file mode 100644 index 000000000..fb6f6dc8e --- /dev/null +++ b/app/src/androidTest/java/org/fdroid/fdroid/work/WorkManagerTestRule.java @@ -0,0 +1,33 @@ +package org.fdroid.fdroid.work; + +import android.app.Instrumentation; +import android.content.Context; +import android.util.Log; +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.work.Configuration; +import androidx.work.WorkManager; +import androidx.work.testing.SynchronousExecutor; +import androidx.work.testing.WorkManagerTestInitHelper; +import org.junit.rules.TestWatcher; +import org.junit.runner.Description; + +public class WorkManagerTestRule extends TestWatcher { + Context targetContext; + Context testContext; + Configuration configuration; + WorkManager workManager; + + @Override + protected void starting(Description description) { + final Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation(); + targetContext = instrumentation.getTargetContext(); + testContext = instrumentation.getContext(); + configuration = new Configuration.Builder() + .setMinimumLoggingLevel(Log.DEBUG) + .setExecutor(new SynchronousExecutor()) + .build(); + + WorkManagerTestInitHelper.initializeTestWorkManager(targetContext, configuration); + workManager = WorkManager.getInstance(targetContext); + } +} 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"/> - - - * 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"; - - 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)); - } - - @Override - protected void onHandleWork(@NonNull Intent intent) { - Process.setThreadPriority(Process.THREAD_PRIORITY_LOWEST); - deleteExpiredApksFromCache(); - deleteStrayIndexFiles(); - deleteOldInstallerFiles(); - deleteOldIcons(); - } - - /** - * All downloaded APKs will be cached for a certain amount of time, which is - * specified by the user in the "Keep Cache Time" preference. This removes - * any APK in the cache that is older than that preference specifies. - */ - private void deleteExpiredApksFromCache() { - File cacheDir = ApkCache.getApkCacheDir(getBaseContext()); - clearOldFiles(cacheDir, Preferences.get().getKeepCacheTime()); - } - - /** - * {@link org.fdroid.fdroid.installer.Installer} instances copy the APK into - * a safe place before installing. It doesn't clean up them reliably yet. - */ - private void deleteOldInstallerFiles() { - File filesDir = getFilesDir(); - if (filesDir == null) { - return; - } - - final File[] files = filesDir.listFiles(); - if (files == null) { - return; - } - - for (File f : files) { - if (f.getName().endsWith(".apk")) { - clearOldFiles(f, TimeUnit.HOURS.toMillis(1)); - } - } - } - - /** - * Delete index files which were downloaded, but not removed (e.g. due to F-Droid being - * force closed during processing of the file, before getting a chance to delete). This - * may include both "index-*-downloaded" and "index-*-extracted.xml" files. - *

- * Note that if the SD card is not ready, then the cache directory will probably not be - * available. In this situation no files will be deleted (and thus they may still exist - * after the SD card becomes available). - *

- * This also deletes temp files that are created by - * {@link org.fdroid.fdroid.net.DownloaderFactory#create(Context, String)}, e.g. "dl-*" - */ - private void deleteStrayIndexFiles() { - File cacheDir = getCacheDir(); - if (cacheDir == null) { - return; - } - - final File[] files = cacheDir.listFiles(); - if (files == null) { - return; - } - - for (File f : files) { - if (f.getName().startsWith("index-")) { - clearOldFiles(f, TimeUnit.HOURS.toMillis(1)); - } - if (f.getName().startsWith("dl-")) { - clearOldFiles(f, TimeUnit.HOURS.toMillis(1)); - } - } - } - - /** - * Delete cached icons that have not been accessed in over a year. - */ - private void deleteOldIcons() { - clearOldFiles(Utils.getImageCacheDir(this), TimeUnit.DAYS.toMillis(365)); - } - - /** - * 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 f The file or directory to clean - * @param millisAgo The number of milliseconds old that marks a file for deletion. - */ - public static void clearOldFiles(File f, long millisAgo) { - if (f == null) { - return; - } - long olderThan = System.currentTimeMillis() - millisAgo; - if (f.isDirectory()) { - File[] files = f.listFiles(); - if (files == null) { - return; - } - for (File file : files) { - clearOldFiles(file, millisAgo); - } - f.delete(); - } else if (Build.VERSION.SDK_INT < 21) { - if (FileUtils.isFileOlder(f, olderThan)) { - f.delete(); - } - } else { - CleanCacheService21.deleteIfOld(f, olderThan); - } - } -} \ No newline at end of file diff --git a/app/src/main/java/org/fdroid/fdroid/CleanCacheService21.java b/app/src/main/java/org/fdroid/fdroid/CleanCacheService21.java deleted file mode 100644 index c2247f115..000000000 --- a/app/src/main/java/org/fdroid/fdroid/CleanCacheService21.java +++ /dev/null @@ -1,30 +0,0 @@ -package org.fdroid.fdroid; - -import android.system.ErrnoException; -import android.system.Os; -import android.system.StructStat; - -import androidx.annotation.RequiresApi; - -import java.io.File; - -/** - * Helper class to prevent {@link VerifyError}s from occurring in {@link CleanCacheService#clearOldFiles(File, long)} - * due to the fact that {@link Os} was only introduced in API 21. - */ -@RequiresApi(21) -class CleanCacheService21 { - static void deleteIfOld(File file, long olderThan) { - if (file == null || !file.exists()) { - return; - } - try { - StructStat stat = Os.lstat(file.getAbsolutePath()); - if ((stat.st_atime * 1000L) < olderThan) { - file.delete(); - } - } catch (ErrnoException e) { - e.printStackTrace(); - } - } -} diff --git a/app/src/main/java/org/fdroid/fdroid/FDroidApp.java b/app/src/main/java/org/fdroid/fdroid/FDroidApp.java index 459ff948e..f79e45688 100644 --- a/app/src/main/java/org/fdroid/fdroid/FDroidApp.java +++ b/app/src/main/java/org/fdroid/fdroid/FDroidApp.java @@ -40,16 +40,17 @@ import android.net.Uri; import android.os.Build; import android.os.Environment; import android.os.StrictMode; -import androidx.annotation.Nullable; -import androidx.collection.LongSparseArray; -import androidx.core.content.ContextCompat; - import android.text.TextUtils; import android.util.Base64; import android.util.Log; import android.view.Display; import android.view.WindowManager; import android.widget.Toast; + +import androidx.annotation.Nullable; +import androidx.collection.LongSparseArray; +import androidx.core.content.ContextCompat; + import com.nostra13.universalimageloader.cache.disc.DiskCache; import com.nostra13.universalimageloader.cache.disc.impl.UnlimitedDiskCache; import com.nostra13.universalimageloader.cache.disc.impl.ext.LruDiskCache; @@ -57,8 +58,7 @@ import com.nostra13.universalimageloader.core.DefaultConfigurationFactory; import com.nostra13.universalimageloader.core.ImageLoader; import com.nostra13.universalimageloader.core.ImageLoaderConfiguration; import com.nostra13.universalimageloader.core.process.BitmapProcessor; -import info.guardianproject.netcipher.NetCipher; -import info.guardianproject.netcipher.proxy.OrbotHelper; + import org.acra.ACRA; import org.acra.ReportField; import org.acra.ReportingInteractionMode; @@ -73,20 +73,25 @@ import org.fdroid.fdroid.data.Repo; import org.fdroid.fdroid.installer.ApkFileProvider; import org.fdroid.fdroid.installer.InstallHistoryService; import org.fdroid.fdroid.nearby.SDCardScannerService; +import org.fdroid.fdroid.nearby.WifiStateChangeService; import org.fdroid.fdroid.net.ConnectivityMonitorService; import org.fdroid.fdroid.net.Downloader; import org.fdroid.fdroid.net.HttpDownloader; import org.fdroid.fdroid.net.ImageLoaderForUIL; -import org.fdroid.fdroid.nearby.WifiStateChangeService; import org.fdroid.fdroid.panic.HidingManager; +import org.fdroid.fdroid.work.CleanCacheWorker; -import javax.microedition.khronos.opengles.GL10; import java.io.IOException; import java.nio.ByteBuffer; import java.security.Security; import java.util.List; import java.util.UUID; +import javax.microedition.khronos.opengles.GL10; + +import info.guardianproject.netcipher.NetCipher; +import info.guardianproject.netcipher.proxy.OrbotHelper; + @ReportsCrashes(mailTo = BuildConfig.ACRA_REPORT_EMAIL, mode = ReportingInteractionMode.DIALOG, reportDialogClass = org.fdroid.fdroid.acra.CrashReportActivity.class, @@ -421,7 +426,7 @@ public class FDroidApp extends Application { } }); - CleanCacheService.schedule(this); + CleanCacheWorker.schedule(this); notificationHelper = new NotificationHelper(getApplicationContext()); @@ -551,7 +556,7 @@ public class FDroidApp extends Application { * problems that arise from executing the code twice. This happens due to the `android:process` * statement in AndroidManifest.xml causes another process to be created to run * {@link org.fdroid.fdroid.acra.CrashReportActivity}. This was causing lots of things to be - * started/run twice including {@link CleanCacheService} and {@link WifiStateChangeService}. + * started/run twice including {@link CleanCacheWorker} and {@link WifiStateChangeService}. *

* 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..08b33d394 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.CleanCacheWorker; 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); + CleanCacheWorker.schedule(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..4caf90faa 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.CleanCacheWorker; 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()); + CleanCacheWorker.schedule(requireContext()); } break; diff --git a/app/src/main/java/org/fdroid/fdroid/work/CleanCacheWorker.java b/app/src/main/java/org/fdroid/fdroid/work/CleanCacheWorker.java new file mode 100644 index 000000000..c28f37925 --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/work/CleanCacheWorker.java @@ -0,0 +1,215 @@ +package org.fdroid.fdroid.work; + +import android.content.Context; +import android.os.Build; +import android.os.Process; +import android.system.ErrnoException; +import android.system.Os; +import android.system.StructStat; +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; +import androidx.work.Constraints; +import androidx.work.ExistingPeriodicWorkPolicy; +import androidx.work.PeriodicWorkRequest; +import androidx.work.WorkManager; +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; + +public class CleanCacheWorker extends Worker { + public static final String TAG = "CleanCacheWorker"; + + public CleanCacheWorker(@NonNull Context context, @NonNull WorkerParameters workerParams) { + super(context, workerParams); + } + + /** + * 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 schedule(@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(TAG, ExistingPeriodicWorkPolicy.REPLACE, cleanCache); + Utils.debugLog(TAG, "Scheduled periodic work for cleaning the cache."); + } + + @NonNull + @Override + public Result doWork() { + Process.setThreadPriority(Process.THREAD_PRIORITY_LOWEST); + try { + deleteExpiredApksFromCache(); + deleteStrayIndexFiles(); + deleteOldInstallerFiles(); + deleteOldIcons(); + return Result.success(); + } catch (Exception e) { + return Result.failure(); + } + } + + /** + * All downloaded APKs will be cached for a certain amount of time, which is + * specified by the user in the "Keep Cache Time" preference. This removes + * any APK in the cache that is older than that preference specifies. + */ + private void deleteExpiredApksFromCache() { + File cacheDir = ApkCache.getApkCacheDir(getApplicationContext()); + clearOldFiles(cacheDir, Preferences.get().getKeepCacheTime()); + } + + /** + * {@link org.fdroid.fdroid.installer.Installer} instances copy the APK into + * a safe place before installing. It doesn't clean up them reliably yet. + */ + private void deleteOldInstallerFiles() { + 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; + } + + for (File f : files) { + if (f.getName().endsWith(".apk")) { + clearOldFiles(f, TimeUnit.HOURS.toMillis(1)); + } + } + } + + /** + * Delete index files which were downloaded, but not removed (e.g. due to F-Droid being + * force closed during processing of the file, before getting a chance to delete). This + * may include both "index-*-downloaded" and "index-*-extracted.xml" files. + *

+ * Note that if the SD card is not ready, then the cache directory will probably not be + * available. In this situation no files will be deleted (and thus they may still exist + * after the SD card becomes available). + *

+ * This also deletes temp files that are created by + * {@link org.fdroid.fdroid.net.DownloaderFactory#create(Context, String)}, e.g. "dl-*" + */ + private void deleteStrayIndexFiles() { + 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; + } + + for (File f : files) { + if (f.getName().startsWith("index-")) { + clearOldFiles(f, TimeUnit.HOURS.toMillis(1)); + } + if (f.getName().startsWith("dl-")) { + clearOldFiles(f, TimeUnit.HOURS.toMillis(1)); + } + } + } + + /** + * Delete cached icons that have not been accessed in over a year. + */ + private void deleteOldIcons() { + clearOldFiles(Utils.getImageCacheDir(getApplicationContext()), TimeUnit.DAYS.toMillis(365)); + } + + /** + * 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 f The file or directory to clean + * @param millisAgo The number of milliseconds old that marks a file for deletion. + */ + 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); + } + deleteFileAndLog(f); + } else if (Build.VERSION.SDK_INT <= 21) { + if (FileUtils.isFileOlder(f, olderThan)) { + deleteFileAndLog(f); + } + } else { + Impl21.deleteIfOld(f, olderThan); + } + } + + 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/gradle/verification-keyring.gpg b/gradle/verification-keyring.gpg index f506bf277..6349fbeee 100644 Binary files a/gradle/verification-keyring.gpg and b/gradle/verification-keyring.gpg differ diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index a0a56000a..5ce758fee 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -63,6 +63,7 @@ + @@ -79,11 +80,17 @@ - + + + + - + + + + @@ -163,6 +170,11 @@ + + + + + @@ -346,6 +358,11 @@ + + + + + @@ -367,6 +384,11 @@ + + + + + @@ -439,6 +461,16 @@ + + + + + + + + + + @@ -455,6 +487,16 @@ + + + + + + + + + + @@ -610,6 +652,21 @@ + + + + + + + + + + + + + + + @@ -768,6 +825,9 @@ + + + @@ -854,6 +914,7 @@ + @@ -1143,6 +1204,7 @@ + @@ -1173,6 +1235,7 @@ + @@ -1181,6 +1244,7 @@ + @@ -1306,6 +1370,7 @@ + @@ -1387,6 +1452,7 @@ + @@ -1463,6 +1529,7 @@ + @@ -1535,6 +1602,7 @@ + @@ -1552,6 +1620,7 @@ + @@ -1569,6 +1638,7 @@ + @@ -1611,6 +1681,7 @@ + @@ -1752,6 +1823,7 @@ + @@ -1916,6 +1988,7 @@ + @@ -2195,6 +2268,7 @@ + @@ -2224,6 +2298,7 @@ + @@ -2242,6 +2317,7 @@ + @@ -2374,6 +2450,7 @@ + @@ -2382,6 +2459,7 @@ +