package org.fdroid.fdroid;
import android.content.Context;
import android.content.SharedPreferences;
import android.os.Build;
import android.preference.PreferenceManager;
import android.util.Log;
import java.net.InetSocketAddress;
import java.net.Proxy;
import java.net.SocketAddress;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.concurrent.TimeUnit;
import info.guardianproject.netcipher.NetCipher;
/**
* Handles shared preferences for FDroid, looking after the names of
* preferences, default values and caching. Needs to be setup in the FDroidApp
* (using {@link Preferences#setup(android.content.Context)} before it gets
* accessed via the {@link org.fdroid.fdroid.Preferences#get()}
* singleton method.
*/
public final class Preferences implements SharedPreferences.OnSharedPreferenceChangeListener {
private static final String TAG = "Preferences";
private final SharedPreferences preferences;
private Preferences(Context context) {
preferences = PreferenceManager.getDefaultSharedPreferences(context);
preferences.registerOnSharedPreferenceChangeListener(this);
if (preferences.getString(PREF_LOCAL_REPO_NAME, null) == null) {
preferences.edit()
.putString(PREF_LOCAL_REPO_NAME, getDefaultLocalRepoName())
.apply();
}
}
public static final String PREF_UPD_INTERVAL = "updateInterval";
public static final String PREF_UPD_WIFI_ONLY = "updateOnWifiOnly";
public static final String PREF_AUTO_DOWNLOAD_INSTALL_UPDATES = "updateAutoDownload";
public static final String PREF_UPD_NOTIFY = "updateNotify";
public static final String PREF_UPD_HISTORY = "updateHistoryDays";
public static final String PREF_ROOTED = "rooted";
public static final String PREF_HIDE_ANTI_FEATURE_APPS = "hideAntiFeatureApps";
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_KEEP_CACHE_TIME = "keepCacheFor";
public static final String PREF_UNSTABLE_UPDATES = "unstableUpdates";
public static final String PREF_KEEP_INSTALL_HISTORY = "keepInstallHistory";
public static final String PREF_EXPERT = "expert";
public static final String PREF_PRIVILEGED_INSTALLER = "privilegedInstaller";
public static final String PREF_UNINSTALL_PRIVILEGED_APP = "uninstallPrivilegedApp";
public static final String PREF_LOCAL_REPO_NAME = "localRepoName";
public static final String PREF_LOCAL_REPO_HTTPS = "localRepoHttps";
public static final String PREF_LANGUAGE = "language";
public static final String PREF_USE_TOR = "useTor";
public static final String PREF_ENABLE_PROXY = "enableProxy";
public static final String PREF_PROXY_HOST = "proxyHost";
public static final String PREF_PROXY_PORT = "proxyPort";
public static final String PREF_SHOW_NFC_DURING_SWAP = "showNfcDuringSwap";
public static final String PREF_POST_PRIVILEGED_INSTALL = "postPrivilegedInstall";
private static final boolean DEFAULT_ROOTED = true;
private static final boolean DEFAULT_HIDE_ANTI_FEATURE_APPS = false;
private static final int DEFAULT_UPD_HISTORY = 14;
private static final boolean DEFAULT_PRIVILEGED_INSTALLER = true;
//private static final boolean DEFAULT_LOCAL_REPO_BONJOUR = true;
private static final long DEFAULT_KEEP_CACHE_TIME = TimeUnit.DAYS.toMillis(1);
private static final boolean DEFAULT_UNSTABLE_UPDATES = false;
private static final boolean DEFAULT_KEEP_INSTALL_HISTORY = false;
//private static final boolean DEFAULT_LOCAL_REPO_HTTPS = false;
private static final boolean DEFAULT_INCOMP_VER = false;
private static final boolean DEFAULT_EXPERT = false;
private static final boolean DEFAULT_ENABLE_PROXY = false;
public static final String DEFAULT_THEME = "light";
@SuppressWarnings("PMD.AvoidUsingHardCodedIP")
public static final String DEFAULT_PROXY_HOST = "127.0.0.1";
public static final int DEFAULT_PROXY_PORT = 8118;
private static final boolean DEFAULT_SHOW_NFC_DURING_SWAP = true;
private static final boolean DEFAULT_POST_PRIVILEGED_INSTALL = false;
public enum Theme {
light,
dark,
night,
lightWithDarkActionBar, // Obsolete
}
private boolean filterAppsRequiringRoot = DEFAULT_ROOTED;
private boolean filterAppsWithAntiFeatures = DEFAULT_HIDE_ANTI_FEATURE_APPS;
private final Map<String, Boolean> initialized = new HashMap<>();
private final List<ChangeListener> filterAppsRequiringRootListeners = new ArrayList<>();
private final List<ChangeListener> filterAppsRequiringAntiFeaturesListeners = new ArrayList<>();
private final List<ChangeListener> updateHistoryListeners = new ArrayList<>();
private final List<ChangeListener> localRepoNameListeners = new ArrayList<>();
private final List<ChangeListener> localRepoHttpsListeners = new ArrayList<>();
private final List<ChangeListener> unstableUpdatesListeners = new ArrayList<>();
private boolean isInitialized(String key) {
return initialized.containsKey(key) && initialized.get(key);
}
private void initialize(String key) {
initialized.put(key, true);
}
private void uninitialize(String key) {
initialized.put(key, false);
}
/**
* Whether to use the Privileged Installer, based on if it is installed. Only the disabled
* state is stored as a preference since the enabled state is based entirely on the presence
* of the Privileged Extension. The preference provides a way to disable using the
* Privileged Extension even though its installed.
*
* @see org.fdroid.fdroid.views.fragments.PreferencesFragment#initPrivilegedInstallerPreference()
*/
public boolean isPrivilegedInstallerEnabled() {
return preferences.getBoolean(PREF_PRIVILEGED_INSTALLER, DEFAULT_PRIVILEGED_INSTALLER);
}
public boolean isPostPrivilegedInstall() {
return preferences.getBoolean(PREF_POST_PRIVILEGED_INSTALL, DEFAULT_POST_PRIVILEGED_INSTALL);
}
public void setPostPrivilegedInstall(boolean postInstall) {
preferences.edit().putBoolean(PREF_POST_PRIVILEGED_INSTALL, postInstall).apply();
}
/**
* Old preference replaced by {@link #PREF_KEEP_CACHE_TIME}
*/
private static final String PREF_CACHE_APK = "cacheDownloaded";
/**
* Time in millis 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_TIME));
// the first time this was migrated, it was botched, so reset to default
switch (value) {
case "3600":
case "86400":
case "604800":
case "2592000":
case "31449600":
case "2147483647":
SharedPreferences.Editor editor = preferences.edit();
editor.remove(PREF_KEEP_CACHE_TIME);
editor.apply();
return Preferences.DEFAULT_KEEP_CACHE_TIME;
}
if (preferences.contains(PREF_CACHE_APK)) {
if (preferences.getBoolean(PREF_CACHE_APK, false)) {
value = String.valueOf(Long.MAX_VALUE);
}
SharedPreferences.Editor editor = preferences.edit();
editor.remove(PREF_CACHE_APK);
editor.putString(PREF_KEEP_CACHE_TIME, value);
editor.apply();
}
try {
return Long.parseLong(value);
} catch (NumberFormatException e) {
return DEFAULT_KEEP_CACHE_TIME;
}
}
public boolean getUnstableUpdates() {
return preferences.getBoolean(PREF_UNSTABLE_UPDATES, DEFAULT_UNSTABLE_UPDATES);
}
public boolean isKeepingInstallHistory() {
return preferences.getBoolean(PREF_KEEP_INSTALL_HISTORY, DEFAULT_KEEP_INSTALL_HISTORY);
}
public boolean showIncompatibleVersions() {
return preferences.getBoolean(PREF_INCOMP_VER, DEFAULT_INCOMP_VER);
}
public boolean showNfcDuringSwap() {
return preferences.getBoolean(PREF_SHOW_NFC_DURING_SWAP, DEFAULT_SHOW_NFC_DURING_SWAP);
}
public void setShowNfcDuringSwap(boolean show) {
preferences.edit().putBoolean(PREF_SHOW_NFC_DURING_SWAP, show).apply();
}
public boolean expertMode() {
return preferences.getBoolean(PREF_EXPERT, DEFAULT_EXPERT);
}
public Theme getTheme() {
return Theme.valueOf(preferences.getString(Preferences.PREF_THEME, Preferences.DEFAULT_THEME));
}
public boolean isLocalRepoHttpsEnabled() {
return false; // disabled until it works well
}
private String getDefaultLocalRepoName() {
return (Build.BRAND + " " + Build.MODEL + new Random().nextInt(9999))
.replaceAll(" ", "-");
}
public String getLocalRepoName() {
return preferences.getString(PREF_LOCAL_REPO_NAME, getDefaultLocalRepoName());
}
public boolean isUpdateNotificationEnabled() {
return preferences.getBoolean(PREF_UPD_NOTIFY, true);
}
public boolean isAutoDownloadEnabled() {
return preferences.getBoolean(PREF_AUTO_DOWNLOAD_INSTALL_UPDATES, false);
}
public boolean isUpdateOnlyOnUnmeteredNetworks() {
return preferences.getBoolean(PREF_UPD_WIFI_ONLY, false);
}
/**
* This preference's default is set dynamically based on whether Orbot is
* installed. If Orbot is installed, default to using Tor, the user can still override
*/
public boolean isTorEnabled() {
// TODO enable once Orbot can auto-start after first install
//return preferences.getBoolean(PREF_USE_TOR, OrbotHelper.requestStartTor(context));
return preferences.getBoolean(PREF_USE_TOR, false);
}
private boolean isProxyEnabled() {
return preferences.getBoolean(PREF_ENABLE_PROXY, DEFAULT_ENABLE_PROXY);
}
/**
* Configure the proxy settings based on whether its enabled and set up. This must be
* run once at app startup, then whenever any of these settings changes.
*/
public void configureProxy() {
if (isProxyEnabled()) {
// if "Use Tor" is set, NetCipher will ignore these proxy settings
SocketAddress sa = new InetSocketAddress(getProxyHost(), getProxyPort());
NetCipher.setProxy(new Proxy(Proxy.Type.HTTP, sa));
}
}
public String getProxyHost() {
return preferences.getString(PREF_PROXY_HOST, DEFAULT_PROXY_HOST);
}
public int getProxyPort() {
final String port = preferences.getString(PREF_PROXY_PORT, String.valueOf(DEFAULT_PROXY_PORT));
try {
return Integer.parseInt(port);
} catch (NumberFormatException e) {
// hack until this can be a number-only preference
try {
return Integer.parseInt(port.replaceAll("[^0-9]", ""));
} catch (Exception e1) {
return DEFAULT_PROXY_PORT;
}
}
}
/**
* Calculate the cutoff date we'll use for What's New and Recently
* Updated...
*/
public Date calcMaxHistory() {
final String daysString = preferences.getString(PREF_UPD_HISTORY, Integer.toString(DEFAULT_UPD_HISTORY));
int maxHistoryDays;
try {
maxHistoryDays = Integer.parseInt(daysString);
} catch (NumberFormatException e) {
maxHistoryDays = DEFAULT_UPD_HISTORY;
}
Calendar recent = Calendar.getInstance();
recent.add(Calendar.DAY_OF_YEAR, -maxHistoryDays);
return recent.getTime();
}
/**
* This is cached as it is called several times inside the AppListAdapter.
* Providing it here means the shared preferences file only needs to be
* read once, and we will keep our copy up to date by listening to changes
* in PREF_ROOTED.
*/
public boolean filterAppsRequiringRoot() {
if (!isInitialized(PREF_ROOTED)) {
initialize(PREF_ROOTED);
filterAppsRequiringRoot = preferences.getBoolean(PREF_ROOTED, DEFAULT_ROOTED);
}
return filterAppsRequiringRoot;
}
/**
* This is cached as it is called several times inside the AppListAdapter.
* Providing it here means the shared preferences file only needs to be
* read once, and we will keep our copy up to date by listening to changes
* in PREF_HIDE_ANTI_FEATURE_APPS.
*/
public boolean filterAppsWithAntiFeatures() {
if (!isInitialized(PREF_HIDE_ANTI_FEATURE_APPS)) {
initialize(PREF_HIDE_ANTI_FEATURE_APPS);
filterAppsWithAntiFeatures = preferences.getBoolean(PREF_HIDE_ANTI_FEATURE_APPS, DEFAULT_HIDE_ANTI_FEATURE_APPS);
}
return filterAppsWithAntiFeatures;
}
public void registerAppsRequiringRootChangeListener(ChangeListener listener) {
filterAppsRequiringRootListeners.add(listener);
}
public void unregisterAppsRequiringRootChangeListener(ChangeListener listener) {
filterAppsRequiringRootListeners.remove(listener);
}
public void registerAppsRequiringAntiFeaturesChangeListener(ChangeListener listener) {
filterAppsRequiringAntiFeaturesListeners.add(listener);
}
public void unregisterAppsRequiringAntiFeaturesChangeListener(ChangeListener listener) {
filterAppsRequiringAntiFeaturesListeners.remove(listener);
}
public void registerUnstableUpdatesChangeListener(ChangeListener listener) {
unstableUpdatesListeners.add(listener);
}
public void unregisterUnstableUpdatesChangeListener(ChangeListener listener) {
unstableUpdatesListeners.remove(listener);
}
@Override
public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) {
Utils.debugLog(TAG, "Invalidating preference '" + key + "'.");
uninitialize(key);
switch (key) {
case PREF_ROOTED:
for (ChangeListener listener : filterAppsRequiringRootListeners) {
listener.onPreferenceChange();
}
break;
case PREF_HIDE_ANTI_FEATURE_APPS:
for (ChangeListener listener : filterAppsRequiringAntiFeaturesListeners) {
listener.onPreferenceChange();
}
break;
case PREF_UPD_HISTORY:
for (ChangeListener listener : updateHistoryListeners) {
listener.onPreferenceChange();
}
break;
case PREF_LOCAL_REPO_NAME:
for (ChangeListener listener : localRepoNameListeners) {
listener.onPreferenceChange();
}
break;
case PREF_LOCAL_REPO_HTTPS:
for (ChangeListener listener : localRepoHttpsListeners) {
listener.onPreferenceChange();
}
break;
case PREF_UNSTABLE_UPDATES:
for (ChangeListener listener : unstableUpdatesListeners) {
listener.onPreferenceChange();
}
break;
}
}
public void registerUpdateHistoryListener(ChangeListener listener) {
updateHistoryListeners.add(listener);
}
public void unregisterUpdateHistoryListener(ChangeListener listener) {
updateHistoryListeners.remove(listener);
}
public void registerLocalRepoNameListeners(ChangeListener listener) {
localRepoNameListeners.add(listener);
}
public void unregisterLocalRepoNameListeners(ChangeListener listener) {
localRepoNameListeners.remove(listener);
}
public void registerLocalRepoHttpsListeners(ChangeListener listener) {
localRepoHttpsListeners.add(listener);
}
public void unregisterLocalRepoHttpsListeners(ChangeListener listener) {
localRepoHttpsListeners.remove(listener);
}
public interface ChangeListener {
void onPreferenceChange();
}
private static Preferences instance;
/**
* Should only be used for unit testing, whereby separate tests are required to invoke `setup()`.
* The reason we don't instead ask for the singleton to be lazily loaded in the {@link Preferences#get()}
* method is because that would require each call to that method to require a {@link Context}.
* While it is likely that most places asking for preferences have access to a {@link Context},
* it is a minor convenience to be able to ask for preferences without.
*/
public static void clearSingletonForTesting() {
instance = null;
}
/**
* 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 " +
"has already been initialized in FDroidApp";
Log.e(TAG, error);
throw new RuntimeException(error);
}
instance = new Preferences(context);
}
public static Preferences get() {
if (instance == null) {
final String error = "Attempted to access preferences before it " +
"has been initialized in FDroidApp";
Log.e(TAG, error);
throw new RuntimeException(error);
}
return instance;
}
}