diff --git a/app/src/androidTest/java/org/fdroid/fdroid/UtilsTest.java b/app/src/androidTest/java/org/fdroid/fdroid/UtilsTest.java index da038dd10..96f701328 100644 --- a/app/src/androidTest/java/org/fdroid/fdroid/UtilsTest.java +++ b/app/src/androidTest/java/org/fdroid/fdroid/UtilsTest.java @@ -1,13 +1,18 @@ package org.fdroid.fdroid; +import android.app.Instrumentation; import android.content.Context; import android.support.test.InstrumentationRegistry; import android.support.test.runner.AndroidJUnit4; +import org.apache.commons.io.FileUtils; import org.junit.Test; import org.junit.runner.RunWith; +import java.io.File; +import java.io.IOException; + import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; @@ -137,4 +142,34 @@ public class UtilsTest { } // TODO write tests that work with a Certificate + + @Test + public void testClearOldFiles() throws IOException, InterruptedException { + Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation(); + File dir = new File(TestUtils.getWriteableDir(instrumentation), "clearOldFiles"); + FileUtils.deleteQuietly(dir); + dir.mkdirs(); + assertTrue(dir.isDirectory()); + + File first = new File(dir, "first"); + File second = new File(dir, "second"); + assertFalse(first.exists()); + assertFalse(second.exists()); + + first.createNewFile(); + assertTrue(first.exists()); + + Thread.sleep(7000); + second.createNewFile(); + assertTrue(second.exists()); + + Utils.clearOldFiles(dir, 3); + assertFalse(first.exists()); + assertTrue(second.exists()); + + Thread.sleep(7000); + Utils.clearOldFiles(dir, 3); + assertFalse(first.exists()); + assertFalse(second.exists()); + } } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c499b50d7..b9b3419c2 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -445,6 +445,9 @@ + diff --git a/app/src/main/java/org/fdroid/fdroid/CleanCacheService.java b/app/src/main/java/org/fdroid/fdroid/CleanCacheService.java new file mode 100644 index 000000000..0838b6770 --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/CleanCacheService.java @@ -0,0 +1,84 @@ +package org.fdroid.fdroid; + +import android.app.AlarmManager; +import android.app.IntentService; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.os.Process; +import android.os.SystemClock; +import android.util.Log; + +import org.apache.commons.io.FileUtils; + +import java.io.File; + +/** + * 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()} + */ +public class CleanCacheService extends IntentService { + public static final String TAG = "CleanCacheService"; + + /** + * 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 = 604800000; // 1 day + if (keepTime < interval) { + interval = keepTime * 1000; + } + Log.i(TAG, "schedule " + keepTime + " " + interval); + + 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); + } + + public CleanCacheService() { + super("CleanCacheService"); + } + + @Override + protected void onHandleIntent(Intent intent) { + Process.setThreadPriority(Process.THREAD_PRIORITY_LOWEST); + Utils.clearOldFiles(Utils.getApkCacheDir(this), Preferences.get().getKeepCacheTime()); + deleteStrayIndexFiles(); + } + + /** + * 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). + */ + 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-")) { + FileUtils.deleteQuietly(f); + } + } + } +} diff --git a/app/src/main/java/org/fdroid/fdroid/FDroidApp.java b/app/src/main/java/org/fdroid/fdroid/FDroidApp.java index d086d8e3f..c774fccd9 100644 --- a/app/src/main/java/org/fdroid/fdroid/FDroidApp.java +++ b/app/src/main/java/org/fdroid/fdroid/FDroidApp.java @@ -136,6 +136,9 @@ public class FDroidApp extends Application { } } + /** + * Initialize the settings needed to run a local swap repo. + */ public static void initWifiSettings() { port = 8888; ipAddressString = null; @@ -182,26 +185,14 @@ public class FDroidApp extends Application { updateLanguage(); ACRA.init(this); - // Needs to be setup before anything else tries to access it. - // Perhaps the constructor is a better place, but then again, - // it is more deterministic as to when this gets called... - Preferences.setup(this); - curTheme = Preferences.get().getTheme(); - - // Apply the Google PRNG fixes to properly seed SecureRandom PRNGFixes.apply(); - // Check that the installed app cache hasn't gotten out of sync somehow. - // e.g. if we crashed/ran out of battery half way through responding - // to a package installed intent. It doesn't really matter where - // we put this in the bootstrap process, because it runs on a different - // thread, which will be delayed by some seconds to avoid an error where - // the database is locked due to the database updater. - InstalledAppCacheUpdater.updateInBackground(getApplicationContext()); - - // make sure the current proxy stuff is configured + Preferences.setup(this); + curTheme = Preferences.get().getTheme(); Preferences.get().configureProxy(); + InstalledAppCacheUpdater.updateInBackground(getApplicationContext()); + // If the user changes the preference to do with filtering rooted apps, // it is easier to just notify a change in the app provider, // so that the newly updated list will correctly filter relevant apps. @@ -230,29 +221,7 @@ public class FDroidApp extends Application { } }); - // Clear cached apk files. We used to just remove them after they'd - // been installed, but this causes problems for proprietary gapps - // users since the introduction of verification (on pre-4.2 Android), - // because the install intent says it's finished when it hasn't. - if (!Preferences.get().shouldCacheApks()) { - Utils.deleteFiles(Utils.getApkCacheDir(this), null, ".apk"); - } - - // Index files which downloaded, but were 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. The first is from - // either signed or unsigned repos, and the later is from signed repos. - Utils.deleteFiles(getCacheDir(), "index-", null); - - // As above, but for legacy F-Droid clients that downloaded under a different name, and - // extracted to the files directory rather than the cache directory. - // TODO: This can be removed in a a few months or a year (e.g. 2016) because people will - // have upgraded their clients, this code will have executed, and they will not have any - // left over files any more. Even if they do hold off upgrading until this code is removed, - // the only side effect is that they will have a few more MiB of storage taken up on their - // device until they uninstall and re-install F-Droid. - Utils.deleteFiles(getCacheDir(), "dl-", null); - Utils.deleteFiles(getFilesDir(), "index-", null); + CleanCacheService.schedule(this); UpdateService.schedule(getApplicationContext()); bluetoothAdapter = getBluetoothAdapter(); @@ -278,9 +247,6 @@ public class FDroidApp extends Application { .build(); ImageLoader.getInstance().init(config); - // TODO reintroduce PinningTrustManager and MemorizingTrustManager - - // initialized the local repo information FDroidApp.initWifiSettings(); startService(new Intent(this, WifiStateChangeService.class)); // if the HTTPS pref changes, then update all affected things diff --git a/app/src/main/java/org/fdroid/fdroid/Preferences.java b/app/src/main/java/org/fdroid/fdroid/Preferences.java index aed547b90..160e655d3 100644 --- a/app/src/main/java/org/fdroid/fdroid/Preferences.java +++ b/app/src/main/java/org/fdroid/fdroid/Preferences.java @@ -32,9 +32,11 @@ public final class Preferences implements SharedPreferences.OnSharedPreferenceCh private static final String TAG = "Preferences"; + private final Context context; private final SharedPreferences preferences; private Preferences(Context context) { + this.context = context; preferences = PreferenceManager.getDefaultSharedPreferences(context); preferences.registerOnSharedPreferenceChangeListener(this); if (preferences.getString(PREF_LOCAL_REPO_NAME, null) == null) { @@ -52,7 +54,7 @@ public final class Preferences implements SharedPreferences.OnSharedPreferenceCh public static final String PREF_INCOMP_VER = "incompatibleVersions"; public static final String PREF_THEME = "theme"; public static final String PREF_IGN_TOUCH = "ignoreTouchscreen"; - public static final String PREF_CACHE_APK = "cacheDownloaded"; + public static final String PREF_KEEP_CACHE_TIME = "keepCacheFor"; public static final String PREF_UNSTABLE_UPDATES = "unstableUpdates"; public static final String PREF_EXPERT = "expert"; public static final String PREF_PRIVILEGED_INSTALLER = "privilegedInstaller"; @@ -72,7 +74,7 @@ public final class Preferences implements SharedPreferences.OnSharedPreferenceCh private static final int DEFAULT_UPD_HISTORY = 14; private static final boolean DEFAULT_PRIVILEGED_INSTALLER = false; //private static final boolean DEFAULT_LOCAL_REPO_BONJOUR = true; - private static final boolean DEFAULT_CACHE_APK = false; + private static final long DEFAULT_KEEP_CACHE_SECONDS = 86400; // one day private static final boolean DEFAULT_UNSTABLE_UPDATES = false; //private static final boolean DEFAULT_LOCAL_REPO_HTTPS = false; private static final boolean DEFAULT_INCOMP_VER = false; @@ -139,8 +141,32 @@ public final class Preferences implements SharedPreferences.OnSharedPreferenceCh PreferencesCompat.apply(preferences.edit().putBoolean(PREF_POST_PRIVILEGED_INSTALL, postInstall)); } - public boolean shouldCacheApks() { - return preferences.getBoolean(PREF_CACHE_APK, DEFAULT_CACHE_APK); + /** + * Old preference replaced by {@link #PREF_KEEP_CACHE_TIME} + */ + private static final String PREF_CACHE_APK = "cacheDownloaded"; + + /** + * Time in seconds to keep cached files. Anything that has been around longer will be deleted + */ + public long getKeepCacheTime() { + String value = preferences.getString(PREF_KEEP_CACHE_TIME, String.valueOf(DEFAULT_KEEP_CACHE_SECONDS)); + + if (preferences.contains(PREF_CACHE_APK)) { + if (preferences.getBoolean(PREF_CACHE_APK, false)) { + value = context.getString(R.string.keep_forever); + } + SharedPreferences.Editor editor = preferences.edit(); + editor.remove(PREF_CACHE_APK); + editor.putString(PREF_KEEP_CACHE_TIME, value); + PreferencesCompat.apply(editor); + } + + try { + return Long.parseLong(value); + } catch (NumberFormatException e) { + return DEFAULT_KEEP_CACHE_SECONDS; + } } public boolean getUnstableUpdates() { @@ -347,6 +373,9 @@ public final class Preferences implements SharedPreferences.OnSharedPreferenceCh private static Preferences instance; + /** + * Needs to be setup before anything else tries to access it. + */ public static void setup(Context context) { if (instance != null) { final String error = "Attempted to reinitialize preferences after it " + diff --git a/app/src/main/java/org/fdroid/fdroid/RepoUpdater.java b/app/src/main/java/org/fdroid/fdroid/RepoUpdater.java index 5fd62034b..4dc986e05 100644 --- a/app/src/main/java/org/fdroid/fdroid/RepoUpdater.java +++ b/app/src/main/java/org/fdroid/fdroid/RepoUpdater.java @@ -92,15 +92,6 @@ public class RepoUpdater { return hasChanged; } - private static void cleanupDownloader(Downloader d) { - if (d == null || d.outputFile == null) { - return; - } - if (!d.outputFile.delete()) { - Log.w(TAG, "Couldn't delete file: " + d.outputFile.getAbsolutePath()); - } - } - private Downloader downloadIndex() throws UpdateException { Downloader downloader = null; try { @@ -115,7 +106,11 @@ public class RepoUpdater { } } catch (IOException e) { - cleanupDownloader(downloader); + if (downloader != null && downloader.outputFile != null) { + if (!downloader.outputFile.delete()) { + Log.w(TAG, "Couldn't delete file: " + downloader.outputFile.getAbsolutePath()); + } + } throw new UpdateException(repo, "Error getting index file", e); } catch (InterruptedException e) { @@ -202,8 +197,10 @@ public class RepoUpdater { } finally { FDroidApp.enableSpongyCastleOnLollipop(); Utils.closeQuietly(indexInputStream); - if (downloadedFile != null && !downloadedFile.delete()) { - Log.w(TAG, "Couldn't delete file: " + downloadedFile.getAbsolutePath()); + if (downloadedFile != null) { + if (!downloadedFile.delete()) { + Log.w(TAG, "Couldn't delete file: " + downloadedFile.getAbsolutePath()); + } } } } diff --git a/app/src/main/java/org/fdroid/fdroid/UpdateService.java b/app/src/main/java/org/fdroid/fdroid/UpdateService.java index 860b6b198..c3cadf50b 100644 --- a/app/src/main/java/org/fdroid/fdroid/UpdateService.java +++ b/app/src/main/java/org/fdroid/fdroid/UpdateService.java @@ -101,9 +101,11 @@ public class UpdateService extends IntentService implements ProgressListener { context.startService(intent); } - // Schedule (or cancel schedule for) this service, 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. + /** + * 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 ctx) { SharedPreferences prefs = PreferenceManager diff --git a/app/src/main/java/org/fdroid/fdroid/Utils.java b/app/src/main/java/org/fdroid/fdroid/Utils.java index 88a994bd1..fa6148ece 100644 --- a/app/src/main/java/org/fdroid/fdroid/Utils.java +++ b/app/src/main/java/org/fdroid/fdroid/Utils.java @@ -37,6 +37,7 @@ import com.nostra13.universalimageloader.core.assist.ImageScaleType; import com.nostra13.universalimageloader.core.display.FadeInBitmapDisplayer; import com.nostra13.universalimageloader.utils.StorageUtils; +import org.apache.commons.io.FileUtils; import org.fdroid.fdroid.compat.FileCompat; import org.fdroid.fdroid.data.Apk; import org.fdroid.fdroid.data.Repo; @@ -325,14 +326,37 @@ public final class Utils { * Using {@link org.fdroid.fdroid.installer.Installer#installPackage(File, String, String)} * is fine since that does the right thing. */ - public static SanitizedFile getApkCacheDir(Context context) { - final SanitizedFile apkCacheDir = new SanitizedFile(StorageUtils.getCacheDirectory(context, true), "apks"); + public static File getApkCacheDir(Context context) { + File apkCacheDir = new File(StorageUtils.getCacheDirectory(context, true), "apks"); + if (apkCacheDir.isFile()) { + apkCacheDir.delete(); + } if (!apkCacheDir.exists()) { apkCacheDir.mkdir(); } return apkCacheDir; } + /** + * Recursively delete files in {@code dir} that were last modified + * {@code secondsAgo} seconds ago, e.g. when it was downloaded. + * + * @param dir The directory to recurse in + * @param secondsAgo The number of seconds old that marks a file for deletion. + */ + public static void clearOldFiles(File dir, long secondsAgo) { + long olderThan = System.currentTimeMillis() - (secondsAgo * 1000L); + for (File f : dir.listFiles()) { + if (f.isDirectory()) { + clearOldFiles(f, olderThan); + f.delete(); + } + if (FileUtils.isFileOlder(f, olderThan)) { + f.delete(); + } + } + } + public static String calcFingerprint(String keyHexString) { if (TextUtils.isEmpty(keyHexString) || keyHexString.matches(".*[^a-fA-F0-9].*")) { @@ -647,40 +671,6 @@ public final class Utils { } } - /** - * Remove all files from the {@param directory} either beginning with {@param startsWith} - * or ending with {@param endsWith}. 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). - */ - public static void deleteFiles(@Nullable File directory, @Nullable String startsWith, @Nullable String endsWith) { - - if (directory == null) { - return; - } - - final File[] files = directory.listFiles(); - if (files == null) { - return; - } - - if (startsWith != null) { - debugLog(TAG, "Cleaning up files in " + directory + " that start with \"" + startsWith + "\""); - } - - if (endsWith != null) { - debugLog(TAG, "Cleaning up files in " + directory + " that end with \"" + endsWith + "\""); - } - - for (File f : files) { - if (((startsWith != null && f.getName().startsWith(startsWith)) - || (endsWith != null && f.getName().endsWith(endsWith))) - && !f.delete()) { - Log.w(TAG, "Couldn't delete cache file " + f); - } - } - } - public static void debugLog(String tag, String msg) { if (BuildConfig.DEBUG) { Log.d(tag, msg); diff --git a/app/src/main/java/org/fdroid/fdroid/compat/FileCompat.java b/app/src/main/java/org/fdroid/fdroid/compat/FileCompat.java index 2dc047785..44c6ae8cb 100644 --- a/app/src/main/java/org/fdroid/fdroid/compat/FileCompat.java +++ b/app/src/main/java/org/fdroid/fdroid/compat/FileCompat.java @@ -85,20 +85,21 @@ public class FileCompat { } } + /** + * Set a {@link SanitizedFile} readable by all if {@code readable} is {@code true}. + * + * @return {@code true} if the operation succeeded + */ @TargetApi(9) - public static boolean setReadable(SanitizedFile file, boolean readable, boolean ownerOnly) { - + public static boolean setReadable(SanitizedFile file, boolean readable) { if (Build.VERSION.SDK_INT >= 9) { - return file.setReadable(readable, ownerOnly); + return file.setReadable(readable, false); } - String mode; if (readable) { - mode = ownerOnly ? "0600" : "0644"; + return setMode(file, "0644"); } else { - mode = "0000"; + return setMode(file, "0000"); } - return setMode(file, mode); - } private static boolean setMode(SanitizedFile file, String mode) { diff --git a/app/src/main/java/org/fdroid/fdroid/data/InstalledAppCacheUpdater.java b/app/src/main/java/org/fdroid/fdroid/data/InstalledAppCacheUpdater.java index 137921cf7..1e86bc8c9 100644 --- a/app/src/main/java/org/fdroid/fdroid/data/InstalledAppCacheUpdater.java +++ b/app/src/main/java/org/fdroid/fdroid/data/InstalledAppCacheUpdater.java @@ -51,8 +51,13 @@ public final class InstalledAppCacheUpdater { /** * Ensure our database of installed apps is in sync with what the PackageManager tells us is installed. - * Once completed, the relevant ContentProviders will be notified of any changes to installed statuses. - * This method returns immediately, and will continue to work in an AsyncTask. + * The installed app cache hasn't gotten out of sync somehow, e.g. if we crashed/ran out of battery + * half way through responding to a package installed {@link android.content.Intent}. Once completed, + * the relevant {@link android.content.ContentProvider}s will be notified of any changes to installed + * statuses. This method returns immediately, and will continue to work in an AsyncTask. It doesn't + * really matter where we put this in the bootstrap process, because it runs on a different thread, + * which will be delayed by some seconds to avoid an error where the database is locked due to the + * database updater. */ public static void updateInBackground(Context context) { InstalledAppCacheUpdater updater = new InstalledAppCacheUpdater(context); diff --git a/app/src/main/java/org/fdroid/fdroid/installer/Installer.java b/app/src/main/java/org/fdroid/fdroid/installer/Installer.java index cdbb39bc0..8a2e21ed6 100644 --- a/app/src/main/java/org/fdroid/fdroid/installer/Installer.java +++ b/app/src/main/java/org/fdroid/fdroid/installer/Installer.java @@ -222,7 +222,7 @@ public abstract class Installer { // have access is insecure, because apps with permission to write to the external // storage can overwrite the app between F-Droid asking for it to be installed and // the installer actually installing it. - FileCompat.setReadable(apkToInstall, true, false); + FileCompat.setReadable(apkToInstall, true); installPackageInternal(apkToInstall); NotificationManager nm = (NotificationManager) diff --git a/app/src/main/java/org/fdroid/fdroid/views/fragments/PreferencesFragment.java b/app/src/main/java/org/fdroid/fdroid/views/fragments/PreferencesFragment.java index a150db286..5043fc914 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/fragments/PreferencesFragment.java +++ b/app/src/main/java/org/fdroid/fdroid/views/fragments/PreferencesFragment.java @@ -39,7 +39,7 @@ public class PreferencesFragment extends PreferenceFragment Preferences.PREF_IGN_TOUCH, Preferences.PREF_LOCAL_REPO_NAME, Preferences.PREF_LANGUAGE, - Preferences.PREF_CACHE_APK, + Preferences.PREF_KEEP_CACHE_TIME, Preferences.PREF_EXPERT, Preferences.PREF_PRIVILEGED_INSTALLER, Preferences.PREF_ENABLE_PROXY, @@ -143,8 +143,8 @@ public class PreferencesFragment extends PreferenceFragment } break; - case Preferences.PREF_CACHE_APK: - checkSummary(key, R.string.cache_downloaded_on); + case Preferences.PREF_KEEP_CACHE_TIME: + entrySummary(key); break; case Preferences.PREF_EXPERT: diff --git a/app/src/main/res/values/array.xml b/app/src/main/res/values/array.xml index de5f1f86b..c575ea5a4 100644 --- a/app/src/main/res/values/array.xml +++ b/app/src/main/res/values/array.xml @@ -10,6 +10,15 @@ @string/interval_2w + + @string/keep_hour + @string/keep_day + @string/keep_week + @string/keep_month + @string/keep_year + @string/keep_forever + + @string/theme_light @string/theme_dark diff --git a/app/src/main/res/values/donottranslate.xml b/app/src/main/res/values/donottranslate.xml index d6a202fab..c7d387726 100644 --- a/app/src/main/res/values/donottranslate.xml +++ b/app/src/main/res/values/donottranslate.xml @@ -23,6 +23,15 @@ 336 + + 3600 + 86400 + 604800 + 2592000 + 31449600 + 2147483647 + + light dark diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d2203a659..2b2cb2c8b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -10,8 +10,8 @@ by Delete Enable NFC Send… - Cache packages - Keep downloaded package files on device + Keep cached apps + Keep downloaded APK files on device Updates Unstable updates Suggest updates to unstable versions @@ -383,6 +383,13 @@ Weekly Every 2 weeks + 1 Hour + 1 Day + 1 Week + 1 Month + 1 Year + Forever + Light Dark Night diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml index 4b2c7d14f..31fb9aee9 100644 --- a/app/src/main/res/xml/preferences.xml +++ b/app/src/main/res/xml/preferences.xml @@ -73,9 +73,10 @@ android:dependency="enableProxy" /> - +