("release") {
from(components["release"])
groupId = "com.crossbowffs.remotepreferences"
artifactId = "remotepreferences"
version = "0.8"
pom {
packaging = "aar"
name.set("RemotePreferences")
description.set("A drop-in solution for inter-app access to SharedPreferences on Android.")
url.set("https://github.com/apsun/RemotePreferences")
licenses {
license {
name.set("MIT")
url.set("https://opensource.org/licenses/MIT")
}
}
developers {
developer {
name.set("Andrew Sun")
email.set("andrew@crossbowffs.com")
}
}
scm {
url.set(pom.url.get())
connection.set("scm:git:${url.get()}.git")
developerConnection.set("scm:git:${url.get()}.git")
}
}
}
}
}
repositories {
maven {
name = "OSSRH"
url = uri("https://oss.sonatype.org/service/local/staging/deploy/maven2")
credentials {
username = project.findProperty("ossrhUsername") as String?
password = project.findProperty("ossrhPassword") as String?
}
}
}
}
signing {
useGpgCmd()
afterEvaluate {
sign(publishing.publications["release"])
}
}
================================================
FILE: library/src/main/AndroidManifest.xml
================================================
================================================
FILE: library/src/main/java/com/crossbowffs/remotepreferences/RemoteContract.java
================================================
package com.crossbowffs.remotepreferences;
/**
* Constants used for communicating with the preference provider.
*/
/* package */ final class RemoteContract {
public static final String COLUMN_KEY = "key";
public static final String COLUMN_TYPE = "type";
public static final String COLUMN_VALUE = "value";
public static final String[] COLUMN_ALL = {
RemoteContract.COLUMN_KEY,
RemoteContract.COLUMN_TYPE,
RemoteContract.COLUMN_VALUE
};
public static final int TYPE_NULL = 0;
public static final int TYPE_STRING = 1;
public static final int TYPE_STRING_SET = 2;
public static final int TYPE_INT = 3;
public static final int TYPE_LONG = 4;
public static final int TYPE_FLOAT = 5;
public static final int TYPE_BOOLEAN = 6;
private RemoteContract() {}
}
================================================
FILE: library/src/main/java/com/crossbowffs/remotepreferences/RemotePreferenceAccessException.java
================================================
package com.crossbowffs.remotepreferences;
/**
* Thrown if the preference provider could not be accessed.
* This is commonly thrown under these conditions:
*
* - Preference provider component is disabled
* - Preference provider denied access via {@link RemotePreferenceProvider#checkAccess(String, String, boolean)}
* - Insufficient permissions to access provider (via {@code AndroidManifest.xml})
* - Incorrect provider authority/file name passed to constructor
*
*/
public class RemotePreferenceAccessException extends RuntimeException {
public RemotePreferenceAccessException() {
}
public RemotePreferenceAccessException(String detailMessage) {
super(detailMessage);
}
public RemotePreferenceAccessException(String detailMessage, Throwable throwable) {
super(detailMessage, throwable);
}
public RemotePreferenceAccessException(Throwable throwable) {
super(throwable);
}
}
================================================
FILE: library/src/main/java/com/crossbowffs/remotepreferences/RemotePreferenceFile.java
================================================
package com.crossbowffs.remotepreferences;
/**
* Represents a single preference file and the information needed to
* access that preference file.
*/
public class RemotePreferenceFile {
private final String mFileName;
private final boolean mIsDeviceProtected;
/**
* Initializes the preference file information. If you are targeting Android
* N or above and the preference needs to be accessed before the first unlock,
* set {@code isDeviceProtected} to {@code true}.
*
* @param fileName Name of the preference file.
* @param isDeviceProtected {@code true} if the preference file is device protected,
* {@code false} if it is credential protected.
*/
public RemotePreferenceFile(String fileName, boolean isDeviceProtected) {
mFileName = fileName;
mIsDeviceProtected = isDeviceProtected;
}
/**
* Initializes the preference file information. Assumes the preferences are
* located in credential protected storage.
*
* @param fileName Name of the preference file.
*/
public RemotePreferenceFile(String fileName) {
this(fileName, false);
}
/**
* Returns the name of the preference file.
*
* @return The name of the preference file.
*/
public String getFileName() {
return mFileName;
}
/**
* Returns whether the preferences are located in device protected storage.
*
* @return {@code true} if the preference file is device protected,
* {@code false} if it is credential protected.
*/
public boolean isDeviceProtected() {
return mIsDeviceProtected;
}
/**
* Converts an array of preference file names to {@link RemotePreferenceFile}
* objects. Assumes all preference files are NOT in device protected storage.
*
* @param prefFileNames The names of the preference files to expose.
* @return An array of {@link RemotePreferenceFile} objects.
*/
public static RemotePreferenceFile[] fromFileNames(String[] prefFileNames) {
RemotePreferenceFile[] prefFiles = new RemotePreferenceFile[prefFileNames.length];
for (int i = 0; i < prefFileNames.length; i++) {
prefFiles[i] = new RemotePreferenceFile(prefFileNames[i]);
}
return prefFiles;
}
}
================================================
FILE: library/src/main/java/com/crossbowffs/remotepreferences/RemotePreferencePath.java
================================================
package com.crossbowffs.remotepreferences;
/**
* A path consists of a preference file name and optionally a key within
* the preference file. The key will be set for operations that involve
* a single preference (e.g. {@code getInt}), and {@code null} for operations
* on an entire preference file (e.g. {@code getAll}).
*/
/* package */ class RemotePreferencePath {
public final String fileName;
public final String key;
public RemotePreferencePath(String prefFileName, String prefKey) {
this.fileName = prefFileName;
this.key = prefKey;
}
public RemotePreferencePath withKey(String prefKey) {
if (this.key != null) {
throw new IllegalArgumentException("Path already has a key");
}
return new RemotePreferencePath(this.fileName, prefKey);
}
@Override
public String toString() {
String ret = "file:" + this.fileName;
if (this.key != null) {
ret += "/key:" + this.key;
}
return ret;
}
}
================================================
FILE: library/src/main/java/com/crossbowffs/remotepreferences/RemotePreferenceProvider.java
================================================
package com.crossbowffs.remotepreferences;
import android.content.ContentProvider;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.content.SharedPreferences;
import android.database.ContentObserver;
import android.database.Cursor;
import android.database.MatrixCursor;
import android.net.Uri;
import android.os.Build;
import java.util.HashMap;
import java.util.Map;
/**
*
* Exposes {@link SharedPreferences} to other apps running on the device.
*
*
*
* You must extend this class and declare a 0-argument constructor which
* calls the super constructor with the appropriate authority and
* preference file name parameters. Remember to add your provider to
* your {@code AndroidManifest.xml} file and set the {@code android:exported}
* property to true.
*
*
*
* For granular access control, override {@link #checkAccess(String, String, boolean)}
* and return {@code false} to deny the operation.
*
*
*
* To access the data from a remote process, use {@link RemotePreferences}
* initialized with the same authority and the desired preference file name.
* You may also manually query the provider; here are some example queries
* and their equivalent {@link SharedPreferences} API calls:
*
*
*
* query(uri = content://authority/foo/bar)
* = getSharedPreferences("foo").get("bar")
*
* query(uri = content://authority/foo)
* = getSharedPreferences("foo").getAll()
*
* insert(uri = content://authority/foo/bar, values = [{type = TYPE_STRING, value = "baz"}])
* = getSharedPreferences("foo").edit().putString("bar", "baz").commit()
*
* insert(uri = content://authority/foo, values = [{key = "bar", type = TYPE_STRING, value = "baz"}])
* = getSharedPreferences("foo").edit().putString("bar", "baz").commit()
*
* delete(uri = content://authority/foo/bar)
* = getSharedPreferences("foo").edit().remove("bar").commit()
*
* delete(uri = content://authority/foo)
* = getSharedPreferences("foo").edit().clear().commit()
*
*
*
* Also note that if you are querying string sets, they will be returned
* in a serialized form: {@code ["foo;bar", "baz"]} is converted to
* {@code "foo\\;bar;baz;"} (note the trailing semicolon). Booleans are
* converted into integers: 1 for true, 0 for false. This is only applicable
* if you are using raw queries; all of these subtleties are transparently
* handled by {@link RemotePreferences}.
*
*/
public abstract class RemotePreferenceProvider extends ContentProvider implements SharedPreferences.OnSharedPreferenceChangeListener {
private final Uri mBaseUri;
private final RemotePreferenceFile[] mPrefFiles;
private final Map mPreferences;
private final RemotePreferenceUriParser mUriParser;
/**
* Initializes the remote preference provider with the specified
* authority and preference file names. The authority must match the
* {@code android:authorities} property defined in your manifest
* file. Only the specified preference files will be accessible
* through the provider. This constructor assumes all preferences
* are located in credential protected storage; if you are using
* device protected storage, use
* {@link #RemotePreferenceProvider(String, RemotePreferenceFile[])}.
*
* @param authority The authority of the provider.
* @param prefFileNames The names of the preference files to expose.
*/
public RemotePreferenceProvider(String authority, String[] prefFileNames) {
this(authority, RemotePreferenceFile.fromFileNames(prefFileNames));
}
/**
* Initializes the remote preference provider with the specified
* authority and preference files. The authority must match the
* {@code android:authorities} property defined in your manifest
* file. Only the specified preference files will be accessible
* through the provider.
*
* @param authority The authority of the provider.
* @param prefFiles The preference files to expose.
*/
public RemotePreferenceProvider(String authority, RemotePreferenceFile[] prefFiles) {
mBaseUri = Uri.parse("content://" + authority);
mPrefFiles = prefFiles;
mPreferences = new HashMap(prefFiles.length);
mUriParser = new RemotePreferenceUriParser(authority);
}
/**
* Checks whether the specified preference is accessible by callers.
* The default implementation returns {@code true} for all accesses.
* You may override this method to control which preferences can be
* read or written. Note that {@code prefKey} will be {@code ""} when
* accessing an entire file, so a whitelist is strongly recommended
* over a blacklist (your default case should be {@code return false},
* not {@code return true}).
*
* @param prefFileName The name of the preference file.
* @param prefKey The preference key. This is an empty string when handling the
* {@link SharedPreferences#getAll()} and
* {@link SharedPreferences.Editor#clear()} operations.
* @param write {@code true} for put/remove/clear operations; {@code false} for get operations.
* @return {@code true} if the access is allowed; {@code false} otherwise.
*/
protected boolean checkAccess(String prefFileName, String prefKey, boolean write) {
return true;
}
/**
* Called at application startup to register preference change listeners.
*
* @return Always returns {@code true}.
*/
@Override
public boolean onCreate() {
// We register the shared preference listeners whenever the provider
// is created. This method is called before almost all other code in
// the app, which ensures that we never miss a preference change.
for (RemotePreferenceFile file : mPrefFiles) {
Context context = getContext();
if (file.isDeviceProtected() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
context = context.createDeviceProtectedStorageContext();
}
SharedPreferences prefs = getSharedPreferences(context, file.getFileName());
prefs.registerOnSharedPreferenceChangeListener(this);
mPreferences.put(file.getFileName(), prefs);
}
return true;
}
/**
* Generate {@link SharedPreferences} to store the key-value data.
* Override this method to provide a custom implementation of {@link SharedPreferences}.
*
* @param context The context that should be used to get the preferences object.
* @param prefFileName The name of the preference file.
* @return An object implementing the {@link SharedPreferences} interface.
*/
protected SharedPreferences getSharedPreferences(Context context, String prefFileName) {
return context.getSharedPreferences(prefFileName, Context.MODE_PRIVATE);
}
/**
* Returns a cursor for the specified preference(s). If {@code uri}
* is in the form {@code content://authority/prefFileName/prefKey}, the
* cursor will contain a single row containing the queried preference.
* If {@code uri} is in the form {@code content://authority/prefFileName},
* the cursor will contain one row for each preference in the specified
* file.
*
* @param uri Specifies the preference file and key (optional) to query.
* @param projection Specifies which fields should be returned in the cursor.
* @param selection Ignored.
* @param selectionArgs Ignored.
* @param sortOrder Ignored.
* @return A cursor used to access the queried preference data.
*/
@Override
public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
RemotePreferencePath prefPath = mUriParser.parse(uri);
SharedPreferences prefs = getSharedPreferencesOrThrow(prefPath, false);
Map prefMap = prefs.getAll();
// If no projection is specified, we return all columns.
if (projection == null) {
projection = RemoteContract.COLUMN_ALL;
}
// Fill out the cursor with the preference data. If the caller
// didn't ask for a particular preference, we return all of them.
MatrixCursor cursor = new MatrixCursor(projection);
if (isSingleKey(prefPath.key)) {
Object prefValue = prefMap.get(prefPath.key);
cursor.addRow(buildRow(projection, prefPath.key, prefValue));
} else {
for (Map.Entry entry : prefMap.entrySet()) {
String prefKey = entry.getKey();
Object prefValue = entry.getValue();
cursor.addRow(buildRow(projection, prefKey, prefValue));
}
}
return cursor;
}
/**
* Not used in RemotePreferences. Always returns {@code null}.
*
* @param uri Ignored.
* @return Always returns {@code null}.
*/
@Override
public String getType(Uri uri) {
return null;
}
/**
* Writes the value of the specified preference(s). If no key is specified,
* {@link RemoteContract#COLUMN_TYPE} must be equal to {@link RemoteContract#TYPE_NULL},
* representing the {@link SharedPreferences.Editor#clear()} operation.
*
* @param uri Specifies the preference file and key (optional) to write.
* @param values Specifies the key (optional), type and value of the preference to write.
* @return A URI representing the preference written, or {@code null} on failure.
*/
@Override
public Uri insert(Uri uri, ContentValues values) {
if (values == null) {
return null;
}
RemotePreferencePath prefPath = mUriParser.parse(uri);
String prefKey = getKeyFromUriOrValues(prefPath, values);
SharedPreferences prefs = getSharedPreferencesOrThrow(prefPath, true);
SharedPreferences.Editor editor = prefs.edit();
putPreference(editor, prefKey, values);
if (editor.commit()) {
return getPreferenceUri(prefPath.fileName, prefKey);
} else {
return null;
}
}
/**
* Writes multiple preference values at once. {@code uri} must
* be in the form {@code content://authority/prefFileName}. See
* {@link #insert(Uri, ContentValues)} for more information.
*
* @param uri Specifies the preference file to write to.
* @param values See {@link #insert(Uri, ContentValues)}.
* @return The number of preferences written, or 0 on failure.
*/
@Override
public int bulkInsert(Uri uri, ContentValues[] values) {
RemotePreferencePath prefPath = mUriParser.parse(uri);
if (isSingleKey(prefPath.key)) {
throw new IllegalArgumentException("Cannot bulk insert with single key URI");
}
SharedPreferences prefs = getSharedPreferencesByName(prefPath.fileName);
SharedPreferences.Editor editor = prefs.edit();
for (ContentValues value : values) {
String prefKey = getKeyFromValues(value);
checkAccessOrThrow(prefPath.withKey(prefKey), true);
putPreference(editor, prefKey, value);
}
if (editor.commit()) {
return values.length;
} else {
return 0;
}
}
/**
* Deletes the specified preference(s). If {@code uri} is in the form
* {@code content://authority/prefFileName/prefKey}, this will only delete
* the one preference specified in the URI; if {@code uri} is in the form
* {@code content://authority/prefFileName}, clears all preferences.
*
* @param uri Specifies the preference file and key (optional) to delete.
* @param selection Ignored.
* @param selectionArgs Ignored.
* @return 1 if the preferences committed successfully, or 0 on failure.
*/
@Override
public int delete(Uri uri, String selection, String[] selectionArgs) {
RemotePreferencePath prefPath = mUriParser.parse(uri);
SharedPreferences prefs = getSharedPreferencesOrThrow(prefPath, true);
SharedPreferences.Editor editor = prefs.edit();
if (isSingleKey(prefPath.key)) {
editor.remove(prefPath.key);
} else {
editor.clear();
}
// There's no reliable method of getting the actual number of
// preference values changed, so callers should not rely on this
// value. A return value of 1 means success, 0 means failure.
if (editor.commit()) {
return 1;
} else {
return 0;
}
}
/**
* Updates the value of the specified preference(s). This is a wrapper
* around {@link #insert(Uri, ContentValues)} if {@code values} is not
* {@code null}, or {@link #delete(Uri, String, String[])} if {@code values}
* is {@code null}.
*
* @param uri Specifies the preference file and key (optional) to update.
* @param values {@code null} to delete the preference,
* @param selection Ignored.
* @param selectionArgs Ignored.
* @return 1 if the preferences committed successfully, or 0 on failure.
*/
@Override
public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
if (values == null) {
return delete(uri, selection, selectionArgs);
} else {
return insert(uri, values) != null ? 1 : 0;
}
}
/**
* Listener for preference value changes in the local application.
* Re-raises the event through the
* {@link ContentResolver#notifyChange(Uri, ContentObserver)} API
* to any registered {@link ContentObserver} objects. Note that this
* is NOT called for {@link SharedPreferences.Editor#clear()}.
*
* @param prefs The preference file that changed.
* @param prefKey The preference key that changed.
*/
@Override
public void onSharedPreferenceChanged(SharedPreferences prefs, String prefKey) {
RemotePreferenceFile prefFile = getSharedPreferencesFile(prefs);
Uri uri = getPreferenceUri(prefFile.getFileName(), prefKey);
Context context = getContext();
if (prefFile.isDeviceProtected() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
context = context.createDeviceProtectedStorageContext();
}
ContentResolver resolver = context.getContentResolver();
resolver.notifyChange(uri, null);
}
/**
* Writes the value of the specified preference(s). If {@code prefKey}
* is empty, {@code values} must contain {@link RemoteContract#TYPE_NULL}
* for the type, representing the {@link SharedPreferences.Editor#clear()}
* operation.
*
* @param editor The preference file to modify.
* @param prefKey The preference key to modify, or {@code null} for the entire file.
* @param values The values to write.
*/
private void putPreference(SharedPreferences.Editor editor, String prefKey, ContentValues values) {
// Get the new value type. Note that we manually check
// for null, then unbox the Integer so we don't cause a NPE.
Integer type = values.getAsInteger(RemoteContract.COLUMN_TYPE);
if (type == null) {
throw new IllegalArgumentException("Invalid or no preference type specified");
}
// deserializeInput makes sure the actual object type matches
// the expected type, so we must perform this step before actually
// performing any actions.
Object rawValue = values.get(RemoteContract.COLUMN_VALUE);
Object value = RemoteUtils.deserializeInput(rawValue, type);
// If we are writing to the "directory" and the type is null,
// then we should clear the preferences.
if (!isSingleKey(prefKey)) {
if (type == RemoteContract.TYPE_NULL) {
editor.clear();
return;
} else {
throw new IllegalArgumentException("Attempting to insert preference with null or empty key");
}
}
switch (type) {
case RemoteContract.TYPE_NULL:
editor.remove(prefKey);
break;
case RemoteContract.TYPE_STRING:
editor.putString(prefKey, (String)value);
break;
case RemoteContract.TYPE_STRING_SET:
if (Build.VERSION.SDK_INT >= 11) {
editor.putStringSet(prefKey, RemoteUtils.castStringSet(value));
} else {
throw new IllegalArgumentException("String set preferences not supported on API < 11");
}
break;
case RemoteContract.TYPE_INT:
editor.putInt(prefKey, (Integer)value);
break;
case RemoteContract.TYPE_LONG:
editor.putLong(prefKey, (Long)value);
break;
case RemoteContract.TYPE_FLOAT:
editor.putFloat(prefKey, (Float)value);
break;
case RemoteContract.TYPE_BOOLEAN:
editor.putBoolean(prefKey, (Boolean)value);
break;
default:
throw new IllegalArgumentException("Cannot set preference with type " + type);
}
}
/**
* Used to project a preference value to the schema requested by the caller.
*
* @param projection The projection requested by the caller.
* @param key The preference key.
* @param value The preference value.
* @return A row representing the preference using the given schema.
*/
private Object[] buildRow(String[] projection, String key, Object value) {
Object[] row = new Object[projection.length];
for (int i = 0; i < row.length; ++i) {
String col = projection[i];
if (RemoteContract.COLUMN_KEY.equals(col)) {
row[i] = key;
} else if (RemoteContract.COLUMN_TYPE.equals(col)) {
row[i] = RemoteUtils.getPreferenceType(value);
} else if (RemoteContract.COLUMN_VALUE.equals(col)) {
row[i] = RemoteUtils.serializeOutput(value);
} else {
throw new IllegalArgumentException("Invalid column name: " + col);
}
}
return row;
}
/**
* Returns whether the specified key represents a single preference key
* (as opposed to the entire preference file).
*
* @param prefKey The preference key to check.
* @return Whether the key refers to a single preference.
*/
private static boolean isSingleKey(String prefKey) {
return prefKey != null;
}
/**
* Parses the preference key from {@code values}. If the key is not
* specified in the values, {@code null} is returned.
*
* @param values The query values to parse.
* @return The parsed key, or {@code null} if no key was found.
*/
private static String getKeyFromValues(ContentValues values) {
String key = values.getAsString(RemoteContract.COLUMN_KEY);
if (key != null && key.length() == 0) {
key = null;
}
return key;
}
/**
* Parses the preference key from the specified sources. Since there
* are two ways to specify the key (from the URI or from the query values),
* the only allowed combinations are:
*
* uri.key == values.key
* uri.key != null and values.key == null = URI key is used
* uri.key == null and values.key != null = values key is used
* uri.key == null and values.key == null = no key
*
* If none of these conditions are met, an exception is thrown.
*
* @param prefPath Parsed URI key from {@code mUriParser.parse(uri)}.
* @param values Query values provided by the caller.
* @return The parsed key, or {@code null} if the key refers to a preference file.
*/
private static String getKeyFromUriOrValues(RemotePreferencePath prefPath, ContentValues values) {
String uriKey = prefPath.key;
String valuesKey = getKeyFromValues(values);
if (isSingleKey(uriKey) && isSingleKey(valuesKey)) {
// If a key is specified in both the URI and
// ContentValues, they must match
if (!uriKey.equals(valuesKey)) {
throw new IllegalArgumentException("Conflicting keys specified in URI and ContentValues");
}
return uriKey;
} else if (isSingleKey(uriKey)) {
return uriKey;
} else if (isSingleKey(valuesKey)) {
return valuesKey;
} else {
return null;
}
}
/**
* Checks that the caller has permissions to access the specified preference.
* Throws an exception if permission is denied.
*
* @param prefPath The preference file and key to be accessed.
* @param write Whether the operation will modify the preference.
*/
private void checkAccessOrThrow(RemotePreferencePath prefPath, boolean write) {
// For backwards compatibility, checkAccess takes an empty string when
// referring to the whole file.
String prefKey = prefPath.key;
if (!isSingleKey(prefKey)) {
prefKey = "";
}
if (!checkAccess(prefPath.fileName, prefKey, write)) {
throw new SecurityException("Insufficient permissions to access: " + prefPath);
}
}
/**
* Returns the {@link SharedPreferences} instance with the specified name.
* This is essentially equivalent to {@link Context#getSharedPreferences(String, int)},
* except that it will used the internally cached version, and throws an
* exception if the provider was not configured to access that preference file.
*
* @param prefFileName The name of the preference file to access.
* @return The {@link SharedPreferences} instance with the specified file name.
*/
private SharedPreferences getSharedPreferencesByName(String prefFileName) {
SharedPreferences prefs = mPreferences.get(prefFileName);
if (prefs == null) {
throw new IllegalArgumentException("Unknown preference file name: " + prefFileName);
}
return prefs;
}
/**
* Returns the file name for a {@link SharedPreferences} instance.
* Throws an exception if the provider was not configured to access
* the specified preferences.
*
* @param prefs The shared preferences object.
* @return The name of the preference file.
*/
private String getSharedPreferencesFileName(SharedPreferences prefs) {
for (Map.Entry entry : mPreferences.entrySet()) {
if (entry.getValue() == prefs) {
return entry.getKey();
}
}
throw new IllegalArgumentException("Unknown preference file");
}
/**
* Get the corresponding {@link RemotePreferenceFile} object for a
* {@link SharedPreferences} instance. Throws an exception if the
* provider was not configured to access the specified preferences.
*
* @param prefs The shared preferences object.
* @return The corresponding {@link RemotePreferenceFile} object.
*/
private RemotePreferenceFile getSharedPreferencesFile(SharedPreferences prefs) {
String prefFileName = getSharedPreferencesFileName(prefs);
for (RemotePreferenceFile file : mPrefFiles) {
if (file.getFileName().equals(prefFileName)) {
return file;
}
}
throw new IllegalArgumentException("Unknown preference file");
}
/**
* Returns the {@link SharedPreferences} instance with the specified name,
* checking that the caller has permissions to access the specified key within
* that file. If not, an exception will be thrown.
*
* @param prefPath The preference file and key to be accessed.
* @param write Whether the operation will modify the preference.
* @return The {@link SharedPreferences} instance with the specified file name.
*/
private SharedPreferences getSharedPreferencesOrThrow(RemotePreferencePath prefPath, boolean write) {
checkAccessOrThrow(prefPath, write);
return getSharedPreferencesByName(prefPath.fileName);
}
/**
* Builds a URI for the specified preference file and key that can be used
* to later query the same preference.
*
* @param prefFileName The preference file.
* @param prefKey The preference key.
* @return A URI representing the specified preference.
*/
private Uri getPreferenceUri(String prefFileName, String prefKey) {
Uri.Builder builder = mBaseUri.buildUpon().appendPath(prefFileName);
if (isSingleKey(prefKey)) {
builder.appendPath(prefKey);
}
return builder.build();
}
}
================================================
FILE: library/src/main/java/com/crossbowffs/remotepreferences/RemotePreferenceUriParser.java
================================================
package com.crossbowffs.remotepreferences;
import android.content.UriMatcher;
import android.net.Uri;
import java.util.List;
/**
* Decodes URIs passed between {@link RemotePreferences} and {@link RemotePreferenceProvider}.
*/
/* package */ class RemotePreferenceUriParser {
private static final int PREFERENCES_ID = 1;
private static final int PREFERENCE_ID = 2;
private final UriMatcher mUriMatcher;
public RemotePreferenceUriParser(String authority) {
mUriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
mUriMatcher.addURI(authority, "*/", PREFERENCES_ID);
mUriMatcher.addURI(authority, "*/*", PREFERENCE_ID);
}
/**
* Parses the preference file and key from a query URI. If the key
* is not specified, the returned path will contain {@code null} as the key.
*
* @param uri The URI to parse.
* @return A path object containing the preference file name and key.
*/
public RemotePreferencePath parse(Uri uri) {
int match = mUriMatcher.match(uri);
if (match != PREFERENCE_ID && match != PREFERENCES_ID) {
throw new IllegalArgumentException("Invalid URI: " + uri);
}
// The URI must fall under one of these patterns:
//
// content://authority/prefFileName/prefKey
// content://authority/prefFileName/
// content://authority/prefFileName
//
// The match ID will be PREFERENCE_ID under the first case,
// and PREFERENCES_ID under the second and third cases
// (UriMatcher ignores trailing slashes).
List pathSegments = uri.getPathSegments();
String prefFileName = pathSegments.get(0);
String prefKey = null;
if (match == PREFERENCE_ID) {
prefKey = pathSegments.get(1);
}
return new RemotePreferencePath(prefFileName, prefKey);
}
}
================================================
FILE: library/src/main/java/com/crossbowffs/remotepreferences/RemotePreferences.java
================================================
package com.crossbowffs.remotepreferences;
import android.annotation.TargetApi;
import android.content.ContentValues;
import android.content.Context;
import android.content.SharedPreferences;
import android.database.ContentObserver;
import android.database.Cursor;
import android.net.Uri;
import android.os.Build;
import android.os.Handler;
import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.WeakHashMap;
/**
*
* Provides a {@link SharedPreferences} compatible API to
* {@link RemotePreferenceProvider}. See {@link RemotePreferenceProvider}
* for more information.
*
*
*
* If you are reading preferences from the same context as the
* provider, you should not use this class; just access the
* {@link SharedPreferences} API as you would normally.
*
*/
public class RemotePreferences implements SharedPreferences {
private final Context mContext;
private final Handler mHandler;
private final Uri mBaseUri;
private final boolean mStrictMode;
private final WeakHashMap mListeners;
private final RemotePreferenceUriParser mUriParser;
/**
* Initializes a new remote preferences object, with strict
* mode disabled.
*
* @param context Used to access the preference provider.
* @param authority The authority of the preference provider.
* @param prefFileName The name of the preference file to access.
*/
public RemotePreferences(Context context, String authority, String prefFileName) {
this(context, authority, prefFileName, false);
}
/**
* Initializes a new remote preferences object. If {@code strictMode}
* is {@code true} and the remote preference provider cannot be accessed,
* read/write operations on this object will throw a
* {@link RemotePreferenceAccessException}. Otherwise, default values
* will be returned.
*
* @param context Used to access the preference provider.
* @param authority The authority of the preference provider.
* @param prefFileName The name of the preference file to access.
* @param strictMode Whether strict mode is enabled.
*/
public RemotePreferences(Context context, String authority, String prefFileName, boolean strictMode) {
this(context, new Handler(context.getMainLooper()), authority, prefFileName, strictMode);
}
/**
* Initializes a new remote preferences object. If {@code strictMode}
* is {@code true} and the remote preference provider cannot be accessed,
* read/write operations on this object will throw a
* {@link RemotePreferenceAccessException}. Otherwise, default values
* will be returned.
*
* @param context Used to access the preference provider.
* @param handler Used to receive preference change events.
* @param authority The authority of the preference provider.
* @param prefFileName The name of the preference file to access.
* @param strictMode Whether strict mode is enabled.
*/
/* package */ RemotePreferences(Context context, Handler handler, String authority, String prefFileName, boolean strictMode) {
checkNotNull("context", context);
checkNotNull("handler", handler);
checkNotNull("authority", authority);
checkNotNull("prefFileName", prefFileName);
mContext = context;
mHandler = handler;
mBaseUri = Uri.parse("content://" + authority).buildUpon().appendPath(prefFileName).build();
mStrictMode = strictMode;
mListeners = new WeakHashMap();
mUriParser = new RemotePreferenceUriParser(authority);
}
@Override
public Map getAll() {
return queryAll();
}
@Override
public String getString(String key, String defValue) {
return (String)querySingle(key, defValue, RemoteContract.TYPE_STRING);
}
@Override
@TargetApi(11)
public Set getStringSet(String key, Set defValues) {
if (Build.VERSION.SDK_INT < 11) {
throw new UnsupportedOperationException("String sets only supported on API 11 and above");
}
return RemoteUtils.castStringSet(querySingle(key, defValues, RemoteContract.TYPE_STRING_SET));
}
@Override
public int getInt(String key, int defValue) {
return (Integer)querySingle(key, defValue, RemoteContract.TYPE_INT);
}
@Override
public long getLong(String key, long defValue) {
return (Long)querySingle(key, defValue, RemoteContract.TYPE_LONG);
}
@Override
public float getFloat(String key, float defValue) {
return (Float)querySingle(key, defValue, RemoteContract.TYPE_FLOAT);
}
@Override
public boolean getBoolean(String key, boolean defValue) {
return (Boolean)querySingle(key, defValue, RemoteContract.TYPE_BOOLEAN);
}
@Override
public boolean contains(String key) {
return containsKey(key);
}
@Override
public Editor edit() {
return new RemotePreferencesEditor();
}
@Override
public void registerOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {
checkNotNull("listener", listener);
if (mListeners.containsKey(listener)) return;
PreferenceContentObserver observer = new PreferenceContentObserver(listener);
mListeners.put(listener, observer);
mContext.getContentResolver().registerContentObserver(mBaseUri, true, observer);
}
@Override
public void unregisterOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {
checkNotNull("listener", listener);
PreferenceContentObserver observer = mListeners.remove(listener);
if (observer != null) {
mContext.getContentResolver().unregisterContentObserver(observer);
}
}
/**
* If {@code object} is {@code null}, throws an exception.
*
* @param name The name of the object, for use in the exception message.
* @param object The object to check.
*/
private static void checkNotNull(String name, Object object) {
if (object == null) {
throw new IllegalArgumentException(name + " is null");
}
}
/**
* If {@code key} is {@code null} or {@code ""}, throws an exception.
*
* @param key The object to check.
*/
private static void checkKeyNotEmpty(String key) {
if (key == null || key.length() == 0) {
throw new IllegalArgumentException("Key is null or empty");
}
}
/**
* If strict mode is enabled, wraps and throws the given exception.
* Otherwise, does nothing.
*
* @param e The exception to wrap.
*/
private void wrapException(Exception e) {
if (mStrictMode) {
throw new RemotePreferenceAccessException(e);
}
}
/**
* Queries the specified URI. If the query fails and strict mode is
* enabled, an exception will be thrown; otherwise {@code null} will
* be returned.
*
* @param uri The URI to query.
* @param columns The columns to include in the returned cursor.
* @return A cursor used to access the queried preference data.
*/
private Cursor query(Uri uri, String[] columns) {
Cursor cursor = null;
try {
cursor = mContext.getContentResolver().query(uri, columns, null, null, null);
} catch (Exception e) {
wrapException(e);
}
if (cursor == null && mStrictMode) {
throw new RemotePreferenceAccessException("query() failed or returned null cursor");
}
return cursor;
}
/**
* Writes multiple preferences at once to the preference provider.
* If the operation fails and strict mode is enabled, an exception
* will be thrown; otherwise {@code false} will be returned.
*
* @param uri The URI to modify.
* @param values The values to write.
* @return Whether the operation succeeded.
*/
private boolean bulkInsert(Uri uri, ContentValues[] values) {
int count;
try {
count = mContext.getContentResolver().bulkInsert(uri, values);
} catch (Exception e) {
wrapException(e);
return false;
}
if (count != values.length && mStrictMode) {
throw new RemotePreferenceAccessException("bulkInsert() failed");
}
return count == values.length;
}
/**
* Reads a single preference from the preference provider. This may
* throw a {@link ClassCastException} even if strict mode is disabled
* if the provider returns an incompatible type. If strict mode is
* disabled and the preference cannot be read, the default value is returned.
*
* @param key The preference key to read.
* @param defValue The default value, if there is no existing value.
* @param expectedType The expected type of the value.
* @return The value of the preference, or {@code defValue} if no value exists.
*/
private Object querySingle(String key, Object defValue, int expectedType) {
checkKeyNotEmpty(key);
Uri uri = mBaseUri.buildUpon().appendPath(key).build();
String[] columns = {RemoteContract.COLUMN_TYPE, RemoteContract.COLUMN_VALUE};
Cursor cursor = query(uri, columns);
try {
if (cursor == null || !cursor.moveToFirst()) {
return defValue;
}
int typeCol = cursor.getColumnIndexOrThrow(RemoteContract.COLUMN_TYPE);
int type = cursor.getInt(typeCol);
if (type == RemoteContract.TYPE_NULL) {
return defValue;
} else if (type != expectedType) {
throw new ClassCastException("Preference type mismatch");
}
int valueCol = cursor.getColumnIndexOrThrow(RemoteContract.COLUMN_VALUE);
return getValue(cursor, typeCol, valueCol);
} finally {
if (cursor != null) {
cursor.close();
}
}
}
/**
* Reads all preferences from the preference provider. If strict
* mode is disabled and the preferences cannot be read, an empty
* map is returned.
*
* @return A map containing all preferences.
*/
private Map queryAll() {
Uri uri = mBaseUri.buildUpon().appendPath("").build();
String[] columns = {RemoteContract.COLUMN_KEY, RemoteContract.COLUMN_TYPE, RemoteContract.COLUMN_VALUE};
Cursor cursor = query(uri, columns);
try {
HashMap map = new HashMap();
if (cursor == null) {
return map;
}
int keyCol = cursor.getColumnIndexOrThrow(RemoteContract.COLUMN_KEY);
int typeCol = cursor.getColumnIndexOrThrow(RemoteContract.COLUMN_TYPE);
int valueCol = cursor.getColumnIndexOrThrow(RemoteContract.COLUMN_VALUE);
while (cursor.moveToNext()) {
String key = cursor.getString(keyCol);
map.put(key, getValue(cursor, typeCol, valueCol));
}
return map;
} finally {
if (cursor != null) {
cursor.close();
}
}
}
/**
* Checks whether the preference exists. If strict mode is
* disabled and the preferences cannot be read, {@code false}
* is returned.
*
* @param key The key to check existence for.
* @return Whether the preference exists.
*/
private boolean containsKey(String key) {
checkKeyNotEmpty(key);
Uri uri = mBaseUri.buildUpon().appendPath(key).build();
String[] columns = {RemoteContract.COLUMN_TYPE};
Cursor cursor = query(uri, columns);
try {
if (cursor == null || !cursor.moveToFirst()) {
return false;
}
int typeCol = cursor.getColumnIndexOrThrow(RemoteContract.COLUMN_TYPE);
return cursor.getInt(typeCol) != RemoteContract.TYPE_NULL;
} finally {
if (cursor != null) {
cursor.close();
}
}
}
/**
* Extracts a preference value from a cursor. Performs deserialization
* of the value if necessary.
*
* @param cursor The cursor containing the preference value.
* @param typeCol The index containing the {@link RemoteContract#COLUMN_TYPE} column.
* @param valueCol The index containing the {@link RemoteContract#COLUMN_VALUE} column.
* @return The value from the cursor.
*/
private Object getValue(Cursor cursor, int typeCol, int valueCol) {
int expectedType = cursor.getInt(typeCol);
switch (expectedType) {
case RemoteContract.TYPE_STRING:
return cursor.getString(valueCol);
case RemoteContract.TYPE_STRING_SET:
return RemoteUtils.deserializeStringSet(cursor.getString(valueCol));
case RemoteContract.TYPE_INT:
return cursor.getInt(valueCol);
case RemoteContract.TYPE_LONG:
return cursor.getLong(valueCol);
case RemoteContract.TYPE_FLOAT:
return cursor.getFloat(valueCol);
case RemoteContract.TYPE_BOOLEAN:
return cursor.getInt(valueCol) != 0;
default:
throw new AssertionError("Invalid expected type: " + expectedType);
}
}
/**
* Implementation of the {@link SharedPreferences.Editor} interface
* for use with RemotePreferences.
*/
private class RemotePreferencesEditor implements Editor {
private final ArrayList mValues = new ArrayList();
/**
* Creates a new {@link ContentValues} with the specified key and
* type columns pre-filled. The {@link RemoteContract#COLUMN_VALUE}
* field is NOT filled in.
*
* @param key The preference key.
* @param type The preference type.
* @return The pre-filled values.
*/
private ContentValues createContentValues(String key, int type) {
ContentValues values = new ContentValues(4);
values.put(RemoteContract.COLUMN_KEY, key);
values.put(RemoteContract.COLUMN_TYPE, type);
return values;
}
/**
* Creates an operation to add/set a new preference. Again, the
* {@link RemoteContract#COLUMN_VALUE} field is NOT filled in.
* This will also add the values to the operation queue.
*
* @param key The preference key to add.
* @param type The preference type to add.
* @return The pre-filled values.
*/
private ContentValues createAddOp(String key, int type) {
checkKeyNotEmpty(key);
ContentValues values = createContentValues(key, type);
mValues.add(values);
return values;
}
/**
* Creates an operation to delete a preference. All fields
* are pre-filled. This will also add the values to the
* operation queue.
*
* @param key The preference key to delete.
* @return The pre-filled values.
*/
private ContentValues createRemoveOp(String key) {
// Note: Remove operations are inserted at the beginning
// of the list (this preserves the SharedPreferences behavior
// that all removes are performed before any adds)
ContentValues values = createContentValues(key, RemoteContract.TYPE_NULL);
values.putNull(RemoteContract.COLUMN_VALUE);
mValues.add(0, values);
return values;
}
@Override
public Editor putString(String key, String value) {
createAddOp(key, RemoteContract.TYPE_STRING).put(RemoteContract.COLUMN_VALUE, value);
return this;
}
@Override
@TargetApi(11)
public Editor putStringSet(String key, Set value) {
if (Build.VERSION.SDK_INT < 11) {
throw new UnsupportedOperationException("String sets only supported on API 11 and above");
}
String serializedSet = RemoteUtils.serializeStringSet(value);
createAddOp(key, RemoteContract.TYPE_STRING_SET).put(RemoteContract.COLUMN_VALUE, serializedSet);
return this;
}
@Override
public Editor putInt(String key, int value) {
createAddOp(key, RemoteContract.TYPE_INT).put(RemoteContract.COLUMN_VALUE, value);
return this;
}
@Override
public Editor putLong(String key, long value) {
createAddOp(key, RemoteContract.TYPE_LONG).put(RemoteContract.COLUMN_VALUE, value);
return this;
}
@Override
public Editor putFloat(String key, float value) {
createAddOp(key, RemoteContract.TYPE_FLOAT).put(RemoteContract.COLUMN_VALUE, value);
return this;
}
@Override
public Editor putBoolean(String key, boolean value) {
createAddOp(key, RemoteContract.TYPE_BOOLEAN).put(RemoteContract.COLUMN_VALUE, value ? 1 : 0);
return this;
}
@Override
public Editor remove(String key) {
checkKeyNotEmpty(key);
createRemoveOp(key);
return this;
}
@Override
public Editor clear() {
createRemoveOp("");
return this;
}
@Override
public boolean commit() {
ContentValues[] values = mValues.toArray(new ContentValues[mValues.size()]);
Uri uri = mBaseUri.buildUpon().appendPath("").build();
return bulkInsert(uri, values);
}
@Override
public void apply() {
commit();
}
}
/**
* {@link ContentObserver} subclass used to monitor preference changes
* in the remote preference provider. When a change is detected, this will notify
* the corresponding {@link SharedPreferences.OnSharedPreferenceChangeListener}.
*/
private class PreferenceContentObserver extends ContentObserver {
private final WeakReference mListener;
private PreferenceContentObserver(OnSharedPreferenceChangeListener listener) {
super(mHandler);
mListener = new WeakReference(listener);
}
@Override
public boolean deliverSelfNotifications() {
return true;
}
@Override
public void onChange(boolean selfChange, Uri uri) {
RemotePreferencePath path = mUriParser.parse(uri);
// We use a weak reference to mimic the behavior of SharedPreferences.
// The code which registered the listener is responsible for holding a
// reference to it. If at any point we find that the listener has been
// garbage collected, we unregister the observer.
OnSharedPreferenceChangeListener listener = mListener.get();
if (listener == null) {
mContext.getContentResolver().unregisterContentObserver(this);
} else {
listener.onSharedPreferenceChanged(RemotePreferences.this, path.key);
}
}
}
}
================================================
FILE: library/src/main/java/com/crossbowffs/remotepreferences/RemoteUtils.java
================================================
package com.crossbowffs.remotepreferences;
import java.util.HashSet;
import java.util.Set;
/**
* Common utilities used to serialize and deserialize
* preferences between the preference provider and caller.
*/
/* package */ final class RemoteUtils {
private RemoteUtils() {}
/**
* Casts the parameter to a string set. Useful to avoid the unchecked
* warning that would normally come with the cast. The value must
* already be a string set; this does not deserialize it.
*
* @param value The value, as type {@link Object}.
* @return The value, as type {@link Set}.
*/
@SuppressWarnings("unchecked")
public static Set castStringSet(Object value) {
return (Set)value;
}
/**
* Returns the {@code TYPE_*} constant corresponding to the given
* object's type.
*
* @param value The original object.
* @return One of the {@link RemoteContract}{@code .TYPE_*} constants.
*/
public static int getPreferenceType(Object value) {
if (value == null) return RemoteContract.TYPE_NULL;
if (value instanceof String) return RemoteContract.TYPE_STRING;
if (value instanceof Set>) return RemoteContract.TYPE_STRING_SET;
if (value instanceof Integer) return RemoteContract.TYPE_INT;
if (value instanceof Long) return RemoteContract.TYPE_LONG;
if (value instanceof Float) return RemoteContract.TYPE_FLOAT;
if (value instanceof Boolean) return RemoteContract.TYPE_BOOLEAN;
throw new AssertionError("Unknown preference type: " + value.getClass());
}
/**
* Serializes the specified object to a format that is safe to use
* with {@link android.content.ContentValues}. To recover the original
* object, use {@link #deserializeInput(Object, int)}.
*
* @param value The object to serialize.
* @return The serialized object.
*/
public static Object serializeOutput(Object value) {
if (value instanceof Boolean) {
return serializeBoolean((Boolean)value);
} else if (value instanceof Set>) {
return serializeStringSet(castStringSet(value));
} else {
return value;
}
}
/**
* Deserializes an object that was serialized using
* {@link #serializeOutput(Object)}. If the expected type does
* not match the actual type of the object, a {@link ClassCastException}
* will be thrown.
*
* @param value The object to deserialize.
* @param expectedType The expected type of the deserialized object.
* @return The deserialized object.
*/
public static Object deserializeInput(Object value, int expectedType) {
if (expectedType == RemoteContract.TYPE_NULL) {
if (value != null) {
throw new IllegalArgumentException("Expected null, got non-null value");
} else {
return null;
}
}
try {
switch (expectedType) {
case RemoteContract.TYPE_STRING:
return (String)value;
case RemoteContract.TYPE_STRING_SET:
return deserializeStringSet((String)value);
case RemoteContract.TYPE_INT:
return (Integer)value;
case RemoteContract.TYPE_LONG:
return (Long)value;
case RemoteContract.TYPE_FLOAT:
return (Float)value;
case RemoteContract.TYPE_BOOLEAN:
return deserializeBoolean(value);
}
} catch (ClassCastException e) {
throw new IllegalArgumentException("Expected type " + expectedType + ", got " + value.getClass(), e);
}
throw new IllegalArgumentException("Unknown type: " + expectedType);
}
/**
* Serializes a {@link Boolean} to a format that is safe to use
* with {@link android.content.ContentValues}.
*
* @param value The {@link Boolean} to serialize.
* @return 1 if {@code value} is {@code true}, 0 if {@code value} is {@code false}.
*/
private static Integer serializeBoolean(Boolean value) {
if (value == null) {
return null;
} else {
return value ? 1 : 0;
}
}
/**
* Deserializes a {@link Boolean} that was serialized using
* {@link #serializeBoolean(Boolean)}.
*
* @param value The {@link Boolean} to deserialize.
* @return {@code true} if {@code value} is 1, {@code false} if {@code value} is 0.
*/
private static Boolean deserializeBoolean(Object value) {
if (value == null) {
return null;
} else if (value instanceof Boolean) {
return (Boolean)value;
} else {
return (Integer)value != 0;
}
}
/**
* Serializes a {@link Set} to a format that is safe to use
* with {@link android.content.ContentValues}.
*
* @param stringSet The {@link Set} to serialize.
* @return The serialized string set.
*/
public static String serializeStringSet(Set stringSet) {
if (stringSet == null) {
return null;
}
StringBuilder sb = new StringBuilder();
for (String s : stringSet) {
sb.append(s.replace("\\", "\\\\").replace(";", "\\;"));
sb.append(';');
}
return sb.toString();
}
/**
* Deserializes a {@link Set} that was serialized using
* {@link #serializeStringSet(Set)}.
*
* @param serializedString The {@link Set} to deserialize.
* @return The deserialized string set.
*/
public static Set deserializeStringSet(String serializedString) {
if (serializedString == null) {
return null;
}
HashSet stringSet = new HashSet();
StringBuilder sb = new StringBuilder();
for (int i = 0; i < serializedString.length(); ++i) {
char c = serializedString.charAt(i);
if (c == '\\') {
char next = serializedString.charAt(++i);
sb.append(next);
} else if (c == ';') {
stringSet.add(sb.toString());
sb.delete(0, sb.length());
} else {
sb.append(c);
}
}
// We require that the serialized string ends with a ; per element
// since that's how we distinguish empty sets from sets containing
// an empty string. Assume caller is doing unsafe string joins
// instead of using the serializeStringSet API, and fail fast.
if (sb.length() != 0) {
throw new IllegalArgumentException("Serialized string set contains trailing chars");
}
return stringSet;
}
}
================================================
FILE: settings.gradle.kts
================================================
include(":library")
include(":testapp")
================================================
FILE: testapp/build.gradle.kts
================================================
plugins {
id("com.android.application")
}
android {
namespace = "com.crossbowffs.remotepreferences.testapp"
compileSdk = 34
defaultConfig {
minSdk = 14
targetSdk = 34
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
}
dependencies {
implementation(project(":library"))
androidTestImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test:core:1.5.0")
androidTestImplementation("androidx.test:runner:1.5.2")
androidTestImplementation("androidx.test:rules:1.5.0")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
}
================================================
FILE: testapp/src/androidTest/java/com/crossbowffs/remotepreferences/RemotePreferenceProviderTest.java
================================================
package com.crossbowffs.remotepreferences;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.content.SharedPreferences;
import android.database.Cursor;
import android.net.Uri;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;
import com.crossbowffs.remotepreferences.testapp.TestConstants;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import java.util.HashSet;
@RunWith(AndroidJUnit4.class)
public class RemotePreferenceProviderTest {
private Context getLocalContext() {
return InstrumentationRegistry.getInstrumentation().getContext();
}
private Context getRemoteContext() {
return InstrumentationRegistry.getInstrumentation().getTargetContext();
}
private SharedPreferences getSharedPreferences() {
Context context = getRemoteContext();
return context.getSharedPreferences(TestConstants.PREF_FILE, Context.MODE_PRIVATE);
}
private Uri getQueryUri(String key) {
String uri = "content://" + TestConstants.AUTHORITY + "/" + TestConstants.PREF_FILE;
if (key != null) {
uri += "/" + key;
}
return Uri.parse(uri);
}
@Before
public void resetPreferences() {
getSharedPreferences().edit().clear().commit();
}
@Test
public void testQueryAllPrefs() {
getSharedPreferences()
.edit()
.putString("string", "foobar")
.putInt("int", 1337)
.apply();
ContentResolver resolver = getLocalContext().getContentResolver();
Cursor q = resolver.query(getQueryUri(null), null, null, null, null);
Assert.assertEquals(2, q.getCount());
int key = q.getColumnIndex(RemoteContract.COLUMN_KEY);
int type = q.getColumnIndex(RemoteContract.COLUMN_TYPE);
int value = q.getColumnIndex(RemoteContract.COLUMN_VALUE);
while (q.moveToNext()) {
if (q.getString(key).equals("string")) {
Assert.assertEquals(RemoteContract.TYPE_STRING, q.getInt(type));
Assert.assertEquals("foobar", q.getString(value));
} else if (q.getString(key).equals("int")) {
Assert.assertEquals(RemoteContract.TYPE_INT, q.getInt(type));
Assert.assertEquals(1337, q.getInt(value));
} else {
Assert.fail();
}
}
}
@Test
public void testQuerySinglePref() {
getSharedPreferences()
.edit()
.putString("string", "foobar")
.putInt("int", 1337)
.apply();
ContentResolver resolver = getLocalContext().getContentResolver();
Cursor q = resolver.query(getQueryUri("string"), null, null, null, null);
Assert.assertEquals(1, q.getCount());
int key = q.getColumnIndex(RemoteContract.COLUMN_KEY);
int type = q.getColumnIndex(RemoteContract.COLUMN_TYPE);
int value = q.getColumnIndex(RemoteContract.COLUMN_VALUE);
q.moveToFirst();
Assert.assertEquals("string", q.getString(key));
Assert.assertEquals(RemoteContract.TYPE_STRING, q.getInt(type));
Assert.assertEquals("foobar", q.getString(value));
}
@Test
public void testQueryFailPermissionCheck() {
getSharedPreferences()
.edit()
.putString(TestConstants.UNREADABLE_PREF_KEY, "foobar")
.apply();
ContentResolver resolver = getLocalContext().getContentResolver();
try {
resolver.query(getQueryUri(TestConstants.UNREADABLE_PREF_KEY), null, null, null, null);
Assert.fail();
} catch (SecurityException e) {
// Expected
}
}
@Test
public void testInsertPref() {
ContentValues values = new ContentValues();
values.put(RemoteContract.COLUMN_KEY, "string");
values.put(RemoteContract.COLUMN_TYPE, RemoteContract.TYPE_STRING);
values.put(RemoteContract.COLUMN_VALUE, "foobar");
ContentResolver resolver = getLocalContext().getContentResolver();
Uri uri = resolver.insert(getQueryUri(null), values);
Assert.assertEquals(getQueryUri("string"), uri);
SharedPreferences prefs = getSharedPreferences();
Assert.assertEquals("foobar", prefs.getString("string", null));
}
@Test
public void testInsertOverridePref() {
SharedPreferences prefs = getSharedPreferences();
prefs
.edit()
.putString("string", "nyaa")
.putInt("int", 1337)
.apply();
ContentValues values = new ContentValues();
values.put(RemoteContract.COLUMN_KEY, "string");
values.put(RemoteContract.COLUMN_TYPE, RemoteContract.TYPE_STRING);
values.put(RemoteContract.COLUMN_VALUE, "foobar");
ContentResolver resolver = getLocalContext().getContentResolver();
Uri uri = resolver.insert(getQueryUri(null), values);
Assert.assertEquals(getQueryUri("string"), uri);
Assert.assertEquals("foobar", prefs.getString("string", null));
Assert.assertEquals(1337, prefs.getInt("int", 0));
}
@Test
public void testInsertPrefKeyInUri() {
ContentValues values = new ContentValues();
values.put(RemoteContract.COLUMN_TYPE, RemoteContract.TYPE_STRING);
values.put(RemoteContract.COLUMN_VALUE, "foobar");
ContentResolver resolver = getLocalContext().getContentResolver();
Uri uri = resolver.insert(getQueryUri("string"), values);
Assert.assertEquals(getQueryUri("string"), uri);
SharedPreferences prefs = getSharedPreferences();
Assert.assertEquals("foobar", prefs.getString("string", null));
}
@Test
public void testInsertPrefKeyInUriAndValues() {
ContentValues values = new ContentValues();
values.put(RemoteContract.COLUMN_KEY, "string");
values.put(RemoteContract.COLUMN_TYPE, RemoteContract.TYPE_STRING);
values.put(RemoteContract.COLUMN_VALUE, "foobar");
ContentResolver resolver = getLocalContext().getContentResolver();
Uri uri = resolver.insert(getQueryUri("string"), values);
Assert.assertEquals(getQueryUri("string"), uri);
SharedPreferences prefs = getSharedPreferences();
Assert.assertEquals("foobar", prefs.getString("string", null));
}
@Test
public void testInsertPrefFailKeyInUriAndValuesMismatch() {
ContentValues values = new ContentValues();
values.put(RemoteContract.COLUMN_KEY, "string");
values.put(RemoteContract.COLUMN_TYPE, RemoteContract.TYPE_STRING);
values.put(RemoteContract.COLUMN_VALUE, "foobar");
ContentResolver resolver = getLocalContext().getContentResolver();
try {
resolver.insert(getQueryUri("string2"), values);
Assert.fail();
} catch (IllegalArgumentException e) {
// Expected
}
SharedPreferences prefs = getSharedPreferences();
Assert.assertEquals("default", prefs.getString("string", "default"));
}
@Test
public void testInsertMultiplePrefs() {
ContentValues[] values = new ContentValues[2];
values[0] = new ContentValues();
values[0].put(RemoteContract.COLUMN_KEY, "string");
values[0].put(RemoteContract.COLUMN_TYPE, RemoteContract.TYPE_STRING);
values[0].put(RemoteContract.COLUMN_VALUE, "foobar");
values[1] = new ContentValues();
values[1].put(RemoteContract.COLUMN_KEY, "int");
values[1].put(RemoteContract.COLUMN_TYPE, RemoteContract.TYPE_INT);
values[1].put(RemoteContract.COLUMN_VALUE, 1337);
ContentResolver resolver = getLocalContext().getContentResolver();
int ret = resolver.bulkInsert(getQueryUri(null), values);
Assert.assertEquals(2, ret);
SharedPreferences prefs = getSharedPreferences();
Assert.assertEquals("foobar", prefs.getString("string", null));
Assert.assertEquals(1337, prefs.getInt("int", 0));
}
@Test
public void testInsertFailPermissionCheck() {
ContentValues[] values = new ContentValues[2];
values[0] = new ContentValues();
values[0].put(RemoteContract.COLUMN_KEY, "string");
values[0].put(RemoteContract.COLUMN_TYPE, RemoteContract.TYPE_STRING);
values[0].put(RemoteContract.COLUMN_VALUE, "foobar");
values[1] = new ContentValues();
values[1].put(RemoteContract.COLUMN_KEY, TestConstants.UNWRITABLE_PREF_KEY);
values[1].put(RemoteContract.COLUMN_TYPE, RemoteContract.TYPE_INT);
values[1].put(RemoteContract.COLUMN_VALUE, 1337);
ContentResolver resolver = getLocalContext().getContentResolver();
try {
resolver.bulkInsert(getQueryUri(null), values);
Assert.fail();
} catch (SecurityException e) {
// Expected
}
SharedPreferences prefs = getSharedPreferences();
Assert.assertEquals("default", prefs.getString("string", "default"));
Assert.assertEquals(0, prefs.getInt(TestConstants.UNWRITABLE_PREF_KEY, 0));
}
@Test
public void testInsertMultipleFailUriContainingKey() {
ContentValues[] values = new ContentValues[1];
values[0] = new ContentValues();
values[0].put(RemoteContract.COLUMN_KEY, "string");
values[0].put(RemoteContract.COLUMN_TYPE, RemoteContract.TYPE_STRING);
values[0].put(RemoteContract.COLUMN_VALUE, "foobar");
ContentResolver resolver = getLocalContext().getContentResolver();
try {
resolver.bulkInsert(getQueryUri("key"), values);
Assert.fail();
} catch (IllegalArgumentException e) {
// Expected
}
SharedPreferences prefs = getSharedPreferences();
Assert.assertEquals("default", prefs.getString("string", "default"));
}
@Test
public void testDeletePref() {
SharedPreferences prefs = getSharedPreferences();
prefs
.edit()
.putString("string", "nyaa")
.apply();
ContentResolver resolver = getLocalContext().getContentResolver();
resolver.delete(getQueryUri("string"), null, null);
Assert.assertEquals("default", prefs.getString("string", "default"));
}
@Test
public void testDeleteUnwritablePref() {
SharedPreferences prefs = getSharedPreferences();
prefs
.edit()
.putString(TestConstants.UNWRITABLE_PREF_KEY, "nyaa")
.apply();
ContentResolver resolver = getLocalContext().getContentResolver();
try {
resolver.delete(getQueryUri(TestConstants.UNWRITABLE_PREF_KEY), null, null);
Assert.fail();
} catch (SecurityException e) {
// Expected
}
Assert.assertEquals("nyaa", prefs.getString(TestConstants.UNWRITABLE_PREF_KEY, "default"));
}
@Test
public void testReadBoolean() {
getSharedPreferences()
.edit()
.putBoolean("true", true)
.putBoolean("false", false)
.apply();
ContentResolver resolver = getLocalContext().getContentResolver();
Cursor q = resolver.query(getQueryUri(null), null, null, null, null);
Assert.assertEquals(2, q.getCount());
int key = q.getColumnIndex(RemoteContract.COLUMN_KEY);
int type = q.getColumnIndex(RemoteContract.COLUMN_TYPE);
int value = q.getColumnIndex(RemoteContract.COLUMN_VALUE);
while (q.moveToNext()) {
if (q.getString(key).equals("true")) {
Assert.assertEquals(RemoteContract.TYPE_BOOLEAN, q.getInt(type));
Assert.assertEquals(1, q.getInt(value));
} else if (q.getString(key).equals("false")) {
Assert.assertEquals(RemoteContract.TYPE_BOOLEAN, q.getInt(type));
Assert.assertEquals(0, q.getInt(value));
} else {
Assert.fail();
}
}
}
@Test
public void testReadStringSet() {
HashSet set = new HashSet<>();
set.add("foo");
set.add("bar;");
set.add("baz");
set.add("");
getSharedPreferences()
.edit()
.putStringSet("pref", set)
.apply();
ContentResolver resolver = getLocalContext().getContentResolver();
Cursor q = resolver.query(getQueryUri("pref"), null, null, null, null);
Assert.assertEquals(1, q.getCount());
int key = q.getColumnIndex(RemoteContract.COLUMN_KEY);
int type = q.getColumnIndex(RemoteContract.COLUMN_TYPE);
int value = q.getColumnIndex(RemoteContract.COLUMN_VALUE);
while (q.moveToNext()) {
if (q.getString(key).equals("pref")) {
Assert.assertEquals(RemoteContract.TYPE_STRING_SET, q.getInt(type));
String serialized = q.getString(value);
Assert.assertEquals(set, RemoteUtils.deserializeStringSet(serialized));
} else {
Assert.fail();
}
}
}
@Test
public void testInsertStringSet() {
HashSet set = new HashSet<>();
set.add("foo");
set.add("bar;");
set.add("baz");
set.add("");
ContentValues values = new ContentValues();
values.put(RemoteContract.COLUMN_KEY, "pref");
values.put(RemoteContract.COLUMN_TYPE, RemoteContract.TYPE_STRING_SET);
values.put(RemoteContract.COLUMN_VALUE, RemoteUtils.serializeStringSet(set));
ContentResolver resolver = getLocalContext().getContentResolver();
Uri uri = resolver.insert(getQueryUri(null), values);
Assert.assertEquals(getQueryUri("pref"), uri);
Assert.assertEquals(set, getSharedPreferences().getStringSet("pref", null));
}
}
================================================
FILE: testapp/src/androidTest/java/com/crossbowffs/remotepreferences/RemotePreferencesTest.java
================================================
package com.crossbowffs.remotepreferences;
import android.content.Context;
import android.content.SharedPreferences;
import android.os.Build;
import android.os.Handler;
import android.os.HandlerThread;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.filters.SdkSuppress;
import androidx.test.platform.app.InstrumentationRegistry;
import com.crossbowffs.remotepreferences.testapp.TestConstants;
import com.crossbowffs.remotepreferences.testapp.TestPreferenceListener;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import java.util.HashSet;
import java.util.Map;
@RunWith(AndroidJUnit4.class)
public class RemotePreferencesTest {
private Context getLocalContext() {
return InstrumentationRegistry.getInstrumentation().getContext();
}
private Context getRemoteContext() {
return InstrumentationRegistry.getInstrumentation().getTargetContext();
}
private SharedPreferences getSharedPreferences() {
Context context = getRemoteContext();
return context.getSharedPreferences(TestConstants.PREF_FILE, Context.MODE_PRIVATE);
}
private RemotePreferences getRemotePreferences(boolean strictMode) {
// This is not a typo! We are using the LOCAL context to initialize a REMOTE prefs
// instance. This is the whole point of RemotePreferences!
Context context = getLocalContext();
return new RemotePreferences(context, TestConstants.AUTHORITY, TestConstants.PREF_FILE, strictMode);
}
private RemotePreferences getDisabledRemotePreferences(boolean strictMode) {
Context context = getLocalContext();
return new RemotePreferences(context, TestConstants.AUTHORITY_DISABLED, TestConstants.PREF_FILE, strictMode);
}
private RemotePreferences getRemotePreferencesWithHandler(Handler handler, boolean strictMode) {
Context context = getLocalContext();
return new RemotePreferences(context, handler, TestConstants.AUTHORITY, TestConstants.PREF_FILE, strictMode);
}
@Before
public void resetPreferences() {
getSharedPreferences().edit().clear().commit();
}
@Test
public void testBasicRead() {
getSharedPreferences()
.edit()
.putString("string", "foobar")
.putInt("int", 0xeceb3026)
.putFloat("float", 3.14f)
.putBoolean("bool", true)
.apply();
RemotePreferences remotePrefs = getRemotePreferences(true);
Assert.assertEquals("foobar", remotePrefs.getString("string", null));
Assert.assertEquals(0xeceb3026, remotePrefs.getInt("int", 0));
Assert.assertEquals(3.14f, remotePrefs.getFloat("float", 0f), 0.0);
Assert.assertEquals(true, remotePrefs.getBoolean("bool", false));
}
@Test
public void testBasicWrite() {
getRemotePreferences(true)
.edit()
.putString("string", "foobar")
.putInt("int", 0xeceb3026)
.putFloat("float", 3.14f)
.putBoolean("bool", true)
.apply();
SharedPreferences sharedPrefs = getSharedPreferences();
Assert.assertEquals("foobar", sharedPrefs.getString("string", null));
Assert.assertEquals(0xeceb3026, sharedPrefs.getInt("int", 0));
Assert.assertEquals(3.14f, sharedPrefs.getFloat("float", 0f), 0.0);
Assert.assertEquals(true, sharedPrefs.getBoolean("bool", false));
}
@Test
public void testRemove() {
getSharedPreferences()
.edit()
.putString("string", "foobar")
.putInt("int", 0xeceb3026)
.apply();
RemotePreferences remotePrefs = getRemotePreferences(true);
remotePrefs.edit().remove("string").apply();
Assert.assertEquals("default", remotePrefs.getString("string", "default"));
Assert.assertEquals(0xeceb3026, remotePrefs.getInt("int", 0));
}
@Test
public void testClear() {
SharedPreferences sharedPrefs = getSharedPreferences();
getSharedPreferences()
.edit()
.putString("string", "foobar")
.putInt("int", 0xeceb3026)
.apply();
RemotePreferences remotePrefs = getRemotePreferences(true);
remotePrefs.edit().clear().apply();
Assert.assertEquals(0, sharedPrefs.getAll().size());
Assert.assertEquals("default", remotePrefs.getString("string", "default"));
Assert.assertEquals(0, remotePrefs.getInt("int", 0));
}
@Test
public void testGetAll() {
getSharedPreferences()
.edit()
.putString("string", "foobar")
.putInt("int", 0xeceb3026)
.putFloat("float", 3.14f)
.putBoolean("bool", true)
.apply();
RemotePreferences remotePrefs = getRemotePreferences(true);
Map prefs = remotePrefs.getAll();
Assert.assertEquals("foobar", prefs.get("string"));
Assert.assertEquals(0xeceb3026, prefs.get("int"));
Assert.assertEquals(3.14f, prefs.get("float"));
Assert.assertEquals(true, prefs.get("bool"));
}
@Test
public void testContains() {
getSharedPreferences()
.edit()
.putString("string", "foobar")
.putInt("int", 0xeceb3026)
.putFloat("float", 3.14f)
.putBoolean("bool", true)
.apply();
RemotePreferences remotePrefs = getRemotePreferences(true);
Assert.assertTrue(remotePrefs.contains("string"));
Assert.assertTrue(remotePrefs.contains("int"));
Assert.assertFalse(remotePrefs.contains("nonexistent"));
}
@Test
public void testReadNonexistentPref() {
RemotePreferences remotePrefs = getRemotePreferences(true);
Assert.assertEquals("default", remotePrefs.getString("nonexistent_string", "default"));
Assert.assertEquals(1337, remotePrefs.getInt("nonexistent_int", 1337));
}
@Test
public void testStringSetRead() {
HashSet set = new HashSet<>();
set.add("Chocola");
set.add("Vanilla");
set.add("Coconut");
set.add("Azuki");
set.add("Maple");
set.add("Cinnamon");
getSharedPreferences()
.edit()
.putStringSet("pref", set)
.apply();
RemotePreferences remotePrefs = getRemotePreferences(true);
Assert.assertEquals(set, remotePrefs.getStringSet("pref", null));
}
@Test
public void testStringSetWrite() {
HashSet set = new HashSet<>();
set.add("Chocola");
set.add("Vanilla");
set.add("Coconut");
set.add("Azuki");
set.add("Maple");
set.add("Cinnamon");
getRemotePreferences(true)
.edit()
.putStringSet("pref", set)
.apply();
SharedPreferences sharedPrefs = getSharedPreferences();
Assert.assertEquals(set, sharedPrefs.getStringSet("pref", null));
}
@Test
public void testEmptyStringSetRead() {
HashSet set = new HashSet<>();
getSharedPreferences()
.edit()
.putStringSet("pref", set)
.apply();
RemotePreferences remotePrefs = getRemotePreferences(true);
Assert.assertEquals(set, remotePrefs.getStringSet("pref", null));
}
@Test
public void testEmptyStringSetWrite() {
HashSet set = new HashSet<>();
getRemotePreferences(true)
.edit()
.putStringSet("pref", set)
.apply();
SharedPreferences sharedPrefs = getSharedPreferences();
Assert.assertEquals(set, sharedPrefs.getStringSet("pref", null));
}
@Test
public void testSetContainingEmptyStringRead() {
HashSet set = new HashSet<>();
set.add("");
getSharedPreferences()
.edit()
.putStringSet("pref", set)
.apply();
RemotePreferences remotePrefs = getRemotePreferences(true);
Assert.assertEquals(set, remotePrefs.getStringSet("pref", null));
}
@Test
public void testSetContainingEmptyStringWrite() {
HashSet set = new HashSet<>();
set.add("");
getRemotePreferences(true)
.edit()
.putStringSet("pref", set)
.apply();
SharedPreferences sharedPrefs = getSharedPreferences();
Assert.assertEquals(set, sharedPrefs.getStringSet("pref", null));
}
@Test
public void testReadStringAsStringSetFail() {
getSharedPreferences()
.edit()
.putString("pref", "foo;bar;")
.apply();
RemotePreferences remotePrefs = getRemotePreferences(true);
try {
remotePrefs.getStringSet("pref", null);
Assert.fail();
} catch (ClassCastException e) {
// Expected
}
}
@Test
public void testReadStringSetAsStringFail() {
HashSet set = new HashSet<>();
set.add("foo");
set.add("bar");
getSharedPreferences()
.edit()
.putStringSet("pref", set)
.apply();
RemotePreferences remotePrefs = getRemotePreferences(true);
try {
remotePrefs.getString("pref", null);
Assert.fail();
} catch (ClassCastException e) {
// Expected
}
}
@Test
public void testReadBooleanAsIntFail() {
getSharedPreferences()
.edit()
.putBoolean("pref", true)
.apply();
RemotePreferences remotePrefs = getRemotePreferences(true);
try {
remotePrefs.getInt("pref", 0);
Assert.fail();
} catch (ClassCastException e) {
// Expected
}
}
@Test
public void testReadIntAsBooleanFail() {
getSharedPreferences()
.edit()
.putInt("pref", 42)
.apply();
RemotePreferences remotePrefs = getRemotePreferences(true);
try {
remotePrefs.getBoolean("pref", false);
Assert.fail();
} catch (ClassCastException e) {
// Expected
}
}
@Test
public void testInvalidAuthorityStrictMode() {
Context context = getLocalContext();
RemotePreferences remotePrefs = new RemotePreferences(context, "foo", "bar", true);
try {
remotePrefs.getString("pref", null);
Assert.fail();
} catch (RemotePreferenceAccessException e) {
// Expected
}
}
@Test
public void testInvalidAuthorityNonStrictMode() {
Context context = getLocalContext();
RemotePreferences remotePrefs = new RemotePreferences(context, "foo", "bar", false);
Assert.assertEquals("default", remotePrefs.getString("pref", "default"));
}
@Test
public void testDisabledProviderStrictMode() {
RemotePreferences remotePrefs = getDisabledRemotePreferences(true);
try {
remotePrefs.getString("pref", null);
Assert.fail();
} catch (RemotePreferenceAccessException e) {
// Expected
}
}
@Test
public void testDisabledProviderNonStrictMode() {
RemotePreferences remotePrefs = getDisabledRemotePreferences(false);
Assert.assertEquals("default", remotePrefs.getString("pref", "default"));
}
@Test
public void testUnreadablePrefStrictMode() {
RemotePreferences remotePrefs = getRemotePreferences(true);
try {
remotePrefs.getString(TestConstants.UNREADABLE_PREF_KEY, null);
Assert.fail();
} catch (RemotePreferenceAccessException e) {
// Expected
}
}
@Test
public void testUnreadablePrefNonStrictMode() {
RemotePreferences remotePrefs = getRemotePreferences(false);
Assert.assertEquals("default", remotePrefs.getString(TestConstants.UNREADABLE_PREF_KEY, "default"));
}
@Test
public void testUnwritablePrefStrictMode() {
RemotePreferences remotePrefs = getRemotePreferences(true);
try {
remotePrefs.edit().putString(TestConstants.UNWRITABLE_PREF_KEY, "foobar").commit();
Assert.fail();
} catch (RemotePreferenceAccessException e) {
// Expected
}
}
@Test
public void testUnwritablePrefNonStrictMode() {
RemotePreferences remotePrefs = getRemotePreferences(false);
Assert.assertFalse(
remotePrefs
.edit()
.putString(TestConstants.UNWRITABLE_PREF_KEY, "foobar")
.commit()
);
}
@Test
public void testRemoveUnwritablePrefStrictMode() {
getSharedPreferences()
.edit()
.putString(TestConstants.UNWRITABLE_PREF_KEY, "foobar")
.apply();
RemotePreferences remotePrefs = getRemotePreferences(true);
try {
remotePrefs.edit().remove(TestConstants.UNWRITABLE_PREF_KEY).commit();
Assert.fail();
} catch (RemotePreferenceAccessException e) {
// Expected
}
Assert.assertEquals("foobar", remotePrefs.getString(TestConstants.UNWRITABLE_PREF_KEY, "default"));
}
@Test
public void testRemoveUnwritablePrefNonStrictMode() {
getSharedPreferences()
.edit()
.putString(TestConstants.UNWRITABLE_PREF_KEY, "foobar")
.apply();
RemotePreferences remotePrefs = getRemotePreferences(false);
Assert.assertFalse(remotePrefs.edit().remove(TestConstants.UNWRITABLE_PREF_KEY).commit());
Assert.assertEquals("foobar", remotePrefs.getString(TestConstants.UNWRITABLE_PREF_KEY, "default"));
}
@Test
public void testPreferenceChangeListener() {
HandlerThread ht = new HandlerThread(getClass().getName());
try {
ht.start();
Handler handler = new Handler(ht.getLooper());
RemotePreferences remotePrefs = getRemotePreferencesWithHandler(handler, true);
TestPreferenceListener listener = new TestPreferenceListener();
try {
remotePrefs.registerOnSharedPreferenceChangeListener(listener);
getSharedPreferences()
.edit()
.putInt("foobar", 1337)
.apply();
Assert.assertTrue(listener.waitForChange(1));
Assert.assertEquals("foobar", listener.getKey());
} finally {
remotePrefs.unregisterOnSharedPreferenceChangeListener(listener);
}
} finally {
ht.quit();
}
}
@Test
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.R)
public void testPreferenceChangeListenerClear() {
HandlerThread ht = new HandlerThread(getClass().getName());
try {
ht.start();
Handler handler = new Handler(ht.getLooper());
RemotePreferences remotePrefs = getRemotePreferencesWithHandler(handler, true);
TestPreferenceListener listener = new TestPreferenceListener();
try {
remotePrefs.registerOnSharedPreferenceChangeListener(listener);
getSharedPreferences()
.edit()
.clear()
.apply();
Assert.assertTrue(listener.waitForChange(1));
Assert.assertNull(listener.getKey());
} finally {
remotePrefs.unregisterOnSharedPreferenceChangeListener(listener);
}
} finally {
ht.quit();
}
}
@Test
public void testUnregisterPreferenceChangeListener() {
HandlerThread ht = new HandlerThread(getClass().getName());
try {
ht.start();
Handler handler = new Handler(ht.getLooper());
RemotePreferences remotePrefs = getRemotePreferencesWithHandler(handler, true);
TestPreferenceListener listener = new TestPreferenceListener();
try {
remotePrefs.registerOnSharedPreferenceChangeListener(listener);
remotePrefs.unregisterOnSharedPreferenceChangeListener(listener);
getSharedPreferences()
.edit()
.putInt("foobar", 1337)
.apply();
Assert.assertFalse(listener.waitForChange(1));
} finally {
remotePrefs.unregisterOnSharedPreferenceChangeListener(listener);
}
} finally {
ht.quit();
}
}
}
================================================
FILE: testapp/src/androidTest/java/com/crossbowffs/remotepreferences/RemoteUtilsTest.java
================================================
package com.crossbowffs.remotepreferences;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.Set;
@RunWith(AndroidJUnit4.class)
public class RemoteUtilsTest {
@Test
public void testSerializeStringSet() {
Set set = new LinkedHashSet();
set.add("foo");
set.add("bar;");
set.add("baz");
set.add("");
String serialized = RemoteUtils.serializeStringSet(set);
Assert.assertEquals("foo;bar\\;;baz;;", serialized);
}
@Test
public void testDeserializeStringSet() {
Set set = new LinkedHashSet();
set.add("foo");
set.add("bar;");
set.add("baz");
set.add("");
String serialized = RemoteUtils.serializeStringSet(set);
Set deserialized = RemoteUtils.deserializeStringSet(serialized);
Assert.assertEquals(set, deserialized);
}
@Test
public void testSerializeEmptyStringSet() {
Assert.assertEquals("", RemoteUtils.serializeStringSet(new HashSet()));
}
@Test
public void testDeserializeEmptyStringSet() {
Assert.assertEquals(new HashSet(), RemoteUtils.deserializeStringSet(""));
}
@Test
public void testDeserializeInvalidStringSet() {
try {
RemoteUtils.deserializeStringSet("foo;bar");
Assert.fail();
} catch (IllegalArgumentException e) {
// Expected
}
}
}
================================================
FILE: testapp/src/main/AndroidManifest.xml
================================================
================================================
FILE: testapp/src/main/java/com/crossbowffs/remotepreferences/testapp/TestConstants.java
================================================
package com.crossbowffs.remotepreferences.testapp;
public final class TestConstants {
private TestConstants() {}
public static final String AUTHORITY = BuildConfig.APPLICATION_ID + ".preferences";
public static final String AUTHORITY_DISABLED = BuildConfig.APPLICATION_ID + ".preferences.disabled";
public static final String PREF_FILE = "main_prefs";
public static final String UNREADABLE_PREF_KEY = "cannot_read_me";
public static final String UNWRITABLE_PREF_KEY = "cannot_write_me";
}
================================================
FILE: testapp/src/main/java/com/crossbowffs/remotepreferences/testapp/TestPreferenceListener.java
================================================
package com.crossbowffs.remotepreferences.testapp;
import android.content.SharedPreferences;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
public class TestPreferenceListener implements SharedPreferences.OnSharedPreferenceChangeListener {
private boolean mIsCalled;
private String mKey;
private final CountDownLatch mLatch;
public TestPreferenceListener() {
mIsCalled = false;
mKey = null;
mLatch = new CountDownLatch(1);
}
public boolean isCalled() {
return mIsCalled;
}
public String getKey() {
if (!mIsCalled) {
throw new IllegalStateException("Listener was not called");
}
return mKey;
}
public boolean waitForChange(long seconds) {
try {
return mLatch.await(seconds, TimeUnit.SECONDS);
} catch (InterruptedException e) {
throw new IllegalStateException("Listener wait was interrupted");
}
}
@Override
public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) {
mIsCalled = true;
mKey = key;
mLatch.countDown();
}
}
================================================
FILE: testapp/src/main/java/com/crossbowffs/remotepreferences/testapp/TestPreferenceProvider.java
================================================
package com.crossbowffs.remotepreferences.testapp;
import com.crossbowffs.remotepreferences.RemotePreferenceProvider;
public class TestPreferenceProvider extends RemotePreferenceProvider {
public TestPreferenceProvider() {
super(TestConstants.AUTHORITY, new String[] {TestConstants.PREF_FILE});
}
@Override
protected boolean checkAccess(String prefName, String prefKey, boolean write) {
if (prefKey.equals(TestConstants.UNREADABLE_PREF_KEY) && !write) return false;
if (prefKey.equals(TestConstants.UNWRITABLE_PREF_KEY) && write) return false;
return true;
}
}
================================================
FILE: testapp/src/main/java/com/crossbowffs/remotepreferences/testapp/TestPreferenceProviderDisabled.java
================================================
package com.crossbowffs.remotepreferences.testapp;
import com.crossbowffs.remotepreferences.RemotePreferenceProvider;
public class TestPreferenceProviderDisabled extends RemotePreferenceProvider {
public TestPreferenceProviderDisabled() {
super(TestConstants.AUTHORITY_DISABLED, new String[] {TestConstants.PREF_FILE});
}
}