("release") {
27 | from(components["release"])
28 |
29 | groupId = "com.crossbowffs.remotepreferences"
30 | artifactId = "remotepreferences"
31 | version = "0.8"
32 |
33 | pom {
34 | packaging = "aar"
35 | name.set("RemotePreferences")
36 | description.set("A drop-in solution for inter-app access to SharedPreferences on Android.")
37 | url.set("https://github.com/apsun/RemotePreferences")
38 | licenses {
39 | license {
40 | name.set("MIT")
41 | url.set("https://opensource.org/licenses/MIT")
42 | }
43 | }
44 | developers {
45 | developer {
46 | name.set("Andrew Sun")
47 | email.set("andrew@crossbowffs.com")
48 | }
49 | }
50 | scm {
51 | url.set(pom.url.get())
52 | connection.set("scm:git:${url.get()}.git")
53 | developerConnection.set("scm:git:${url.get()}.git")
54 | }
55 | }
56 | }
57 | }
58 | }
59 |
60 | repositories {
61 | maven {
62 | name = "OSSRH"
63 | url = uri("https://oss.sonatype.org/service/local/staging/deploy/maven2")
64 | credentials {
65 | username = project.findProperty("ossrhUsername") as String?
66 | password = project.findProperty("ossrhPassword") as String?
67 | }
68 | }
69 | }
70 | }
71 |
72 | signing {
73 | useGpgCmd()
74 | afterEvaluate {
75 | sign(publishing.publications["release"])
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/library/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/library/src/main/java/com/crossbowffs/remotepreferences/RemoteContract.java:
--------------------------------------------------------------------------------
1 | package com.crossbowffs.remotepreferences;
2 |
3 | /**
4 | * Constants used for communicating with the preference provider.
5 | */
6 | /* package */ final class RemoteContract {
7 | public static final String COLUMN_KEY = "key";
8 | public static final String COLUMN_TYPE = "type";
9 | public static final String COLUMN_VALUE = "value";
10 | public static final String[] COLUMN_ALL = {
11 | RemoteContract.COLUMN_KEY,
12 | RemoteContract.COLUMN_TYPE,
13 | RemoteContract.COLUMN_VALUE
14 | };
15 |
16 | public static final int TYPE_NULL = 0;
17 | public static final int TYPE_STRING = 1;
18 | public static final int TYPE_STRING_SET = 2;
19 | public static final int TYPE_INT = 3;
20 | public static final int TYPE_LONG = 4;
21 | public static final int TYPE_FLOAT = 5;
22 | public static final int TYPE_BOOLEAN = 6;
23 |
24 | private RemoteContract() {}
25 | }
26 |
--------------------------------------------------------------------------------
/library/src/main/java/com/crossbowffs/remotepreferences/RemotePreferenceAccessException.java:
--------------------------------------------------------------------------------
1 | package com.crossbowffs.remotepreferences;
2 |
3 | /**
4 | * Thrown if the preference provider could not be accessed.
5 | * This is commonly thrown under these conditions:
6 | *
7 | * - Preference provider component is disabled
8 | * - Preference provider denied access via {@link RemotePreferenceProvider#checkAccess(String, String, boolean)}
9 | * - Insufficient permissions to access provider (via {@code AndroidManifest.xml})
10 | * - Incorrect provider authority/file name passed to constructor
11 | *
12 | */
13 | public class RemotePreferenceAccessException extends RuntimeException {
14 | public RemotePreferenceAccessException() {
15 |
16 | }
17 |
18 | public RemotePreferenceAccessException(String detailMessage) {
19 | super(detailMessage);
20 | }
21 |
22 | public RemotePreferenceAccessException(String detailMessage, Throwable throwable) {
23 | super(detailMessage, throwable);
24 | }
25 |
26 | public RemotePreferenceAccessException(Throwable throwable) {
27 | super(throwable);
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/library/src/main/java/com/crossbowffs/remotepreferences/RemotePreferenceFile.java:
--------------------------------------------------------------------------------
1 | package com.crossbowffs.remotepreferences;
2 |
3 | /**
4 | * Represents a single preference file and the information needed to
5 | * access that preference file.
6 | */
7 | public class RemotePreferenceFile {
8 | private final String mFileName;
9 | private final boolean mIsDeviceProtected;
10 |
11 | /**
12 | * Initializes the preference file information. If you are targeting Android
13 | * N or above and the preference needs to be accessed before the first unlock,
14 | * set {@code isDeviceProtected} to {@code true}.
15 | *
16 | * @param fileName Name of the preference file.
17 | * @param isDeviceProtected {@code true} if the preference file is device protected,
18 | * {@code false} if it is credential protected.
19 | */
20 | public RemotePreferenceFile(String fileName, boolean isDeviceProtected) {
21 | mFileName = fileName;
22 | mIsDeviceProtected = isDeviceProtected;
23 | }
24 |
25 | /**
26 | * Initializes the preference file information. Assumes the preferences are
27 | * located in credential protected storage.
28 | *
29 | * @param fileName Name of the preference file.
30 | */
31 | public RemotePreferenceFile(String fileName) {
32 | this(fileName, false);
33 | }
34 |
35 | /**
36 | * Returns the name of the preference file.
37 | *
38 | * @return The name of the preference file.
39 | */
40 | public String getFileName() {
41 | return mFileName;
42 | }
43 |
44 | /**
45 | * Returns whether the preferences are located in device protected storage.
46 | *
47 | * @return {@code true} if the preference file is device protected,
48 | * {@code false} if it is credential protected.
49 | */
50 | public boolean isDeviceProtected() {
51 | return mIsDeviceProtected;
52 | }
53 |
54 | /**
55 | * Converts an array of preference file names to {@link RemotePreferenceFile}
56 | * objects. Assumes all preference files are NOT in device protected storage.
57 | *
58 | * @param prefFileNames The names of the preference files to expose.
59 | * @return An array of {@link RemotePreferenceFile} objects.
60 | */
61 | public static RemotePreferenceFile[] fromFileNames(String[] prefFileNames) {
62 | RemotePreferenceFile[] prefFiles = new RemotePreferenceFile[prefFileNames.length];
63 | for (int i = 0; i < prefFileNames.length; i++) {
64 | prefFiles[i] = new RemotePreferenceFile(prefFileNames[i]);
65 | }
66 | return prefFiles;
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/library/src/main/java/com/crossbowffs/remotepreferences/RemotePreferencePath.java:
--------------------------------------------------------------------------------
1 | package com.crossbowffs.remotepreferences;
2 |
3 | /**
4 | * A path consists of a preference file name and optionally a key within
5 | * the preference file. The key will be set for operations that involve
6 | * a single preference (e.g. {@code getInt}), and {@code null} for operations
7 | * on an entire preference file (e.g. {@code getAll}).
8 | */
9 | /* package */ class RemotePreferencePath {
10 | public final String fileName;
11 | public final String key;
12 |
13 | public RemotePreferencePath(String prefFileName, String prefKey) {
14 | this.fileName = prefFileName;
15 | this.key = prefKey;
16 | }
17 |
18 | public RemotePreferencePath withKey(String prefKey) {
19 | if (this.key != null) {
20 | throw new IllegalArgumentException("Path already has a key");
21 | }
22 | return new RemotePreferencePath(this.fileName, prefKey);
23 | }
24 |
25 | @Override
26 | public String toString() {
27 | String ret = "file:" + this.fileName;
28 | if (this.key != null) {
29 | ret += "/key:" + this.key;
30 | }
31 | return ret;
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/library/src/main/java/com/crossbowffs/remotepreferences/RemotePreferenceProvider.java:
--------------------------------------------------------------------------------
1 | package com.crossbowffs.remotepreferences;
2 |
3 | import android.content.ContentProvider;
4 | import android.content.ContentResolver;
5 | import android.content.ContentValues;
6 | import android.content.Context;
7 | import android.content.SharedPreferences;
8 | import android.database.ContentObserver;
9 | import android.database.Cursor;
10 | import android.database.MatrixCursor;
11 | import android.net.Uri;
12 | import android.os.Build;
13 |
14 | import java.util.HashMap;
15 | import java.util.Map;
16 |
17 | /**
18 | *
19 | * Exposes {@link SharedPreferences} to other apps running on the device.
20 | *
21 | *
22 | *
23 | * You must extend this class and declare a 0-argument constructor which
24 | * calls the super constructor with the appropriate authority and
25 | * preference file name parameters. Remember to add your provider to
26 | * your {@code AndroidManifest.xml} file and set the {@code android:exported}
27 | * property to true.
28 | *
29 | *
30 | *
31 | * For granular access control, override {@link #checkAccess(String, String, boolean)}
32 | * and return {@code false} to deny the operation.
33 | *
34 | *
35 | *
36 | * To access the data from a remote process, use {@link RemotePreferences}
37 | * initialized with the same authority and the desired preference file name.
38 | * You may also manually query the provider; here are some example queries
39 | * and their equivalent {@link SharedPreferences} API calls:
40 | *
41 | *
42 | *
43 | * query(uri = content://authority/foo/bar)
44 | * = getSharedPreferences("foo").get("bar")
45 | *
46 | * query(uri = content://authority/foo)
47 | * = getSharedPreferences("foo").getAll()
48 | *
49 | * insert(uri = content://authority/foo/bar, values = [{type = TYPE_STRING, value = "baz"}])
50 | * = getSharedPreferences("foo").edit().putString("bar", "baz").commit()
51 | *
52 | * insert(uri = content://authority/foo, values = [{key = "bar", type = TYPE_STRING, value = "baz"}])
53 | * = getSharedPreferences("foo").edit().putString("bar", "baz").commit()
54 | *
55 | * delete(uri = content://authority/foo/bar)
56 | * = getSharedPreferences("foo").edit().remove("bar").commit()
57 | *
58 | * delete(uri = content://authority/foo)
59 | * = getSharedPreferences("foo").edit().clear().commit()
60 | *
61 | *
62 | *
63 | * Also note that if you are querying string sets, they will be returned
64 | * in a serialized form: {@code ["foo;bar", "baz"]} is converted to
65 | * {@code "foo\\;bar;baz;"} (note the trailing semicolon). Booleans are
66 | * converted into integers: 1 for true, 0 for false. This is only applicable
67 | * if you are using raw queries; all of these subtleties are transparently
68 | * handled by {@link RemotePreferences}.
69 | *
70 | */
71 | public abstract class RemotePreferenceProvider extends ContentProvider implements SharedPreferences.OnSharedPreferenceChangeListener {
72 | private final Uri mBaseUri;
73 | private final RemotePreferenceFile[] mPrefFiles;
74 | private final Map mPreferences;
75 | private final RemotePreferenceUriParser mUriParser;
76 |
77 | /**
78 | * Initializes the remote preference provider with the specified
79 | * authority and preference file names. The authority must match the
80 | * {@code android:authorities} property defined in your manifest
81 | * file. Only the specified preference files will be accessible
82 | * through the provider. This constructor assumes all preferences
83 | * are located in credential protected storage; if you are using
84 | * device protected storage, use
85 | * {@link #RemotePreferenceProvider(String, RemotePreferenceFile[])}.
86 | *
87 | * @param authority The authority of the provider.
88 | * @param prefFileNames The names of the preference files to expose.
89 | */
90 | public RemotePreferenceProvider(String authority, String[] prefFileNames) {
91 | this(authority, RemotePreferenceFile.fromFileNames(prefFileNames));
92 | }
93 |
94 | /**
95 | * Initializes the remote preference provider with the specified
96 | * authority and preference files. The authority must match the
97 | * {@code android:authorities} property defined in your manifest
98 | * file. Only the specified preference files will be accessible
99 | * through the provider.
100 | *
101 | * @param authority The authority of the provider.
102 | * @param prefFiles The preference files to expose.
103 | */
104 | public RemotePreferenceProvider(String authority, RemotePreferenceFile[] prefFiles) {
105 | mBaseUri = Uri.parse("content://" + authority);
106 | mPrefFiles = prefFiles;
107 | mPreferences = new HashMap(prefFiles.length);
108 | mUriParser = new RemotePreferenceUriParser(authority);
109 | }
110 |
111 | /**
112 | * Checks whether the specified preference is accessible by callers.
113 | * The default implementation returns {@code true} for all accesses.
114 | * You may override this method to control which preferences can be
115 | * read or written. Note that {@code prefKey} will be {@code ""} when
116 | * accessing an entire file, so a whitelist is strongly recommended
117 | * over a blacklist (your default case should be {@code return false},
118 | * not {@code return true}).
119 | *
120 | * @param prefFileName The name of the preference file.
121 | * @param prefKey The preference key. This is an empty string when handling the
122 | * {@link SharedPreferences#getAll()} and
123 | * {@link SharedPreferences.Editor#clear()} operations.
124 | * @param write {@code true} for put/remove/clear operations; {@code false} for get operations.
125 | * @return {@code true} if the access is allowed; {@code false} otherwise.
126 | */
127 | protected boolean checkAccess(String prefFileName, String prefKey, boolean write) {
128 | return true;
129 | }
130 |
131 | /**
132 | * Called at application startup to register preference change listeners.
133 | *
134 | * @return Always returns {@code true}.
135 | */
136 | @Override
137 | public boolean onCreate() {
138 | // We register the shared preference listeners whenever the provider
139 | // is created. This method is called before almost all other code in
140 | // the app, which ensures that we never miss a preference change.
141 | for (RemotePreferenceFile file : mPrefFiles) {
142 | Context context = getContext();
143 | if (file.isDeviceProtected() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
144 | context = context.createDeviceProtectedStorageContext();
145 | }
146 | SharedPreferences prefs = getSharedPreferences(context, file.getFileName());
147 | prefs.registerOnSharedPreferenceChangeListener(this);
148 | mPreferences.put(file.getFileName(), prefs);
149 | }
150 | return true;
151 | }
152 |
153 | /**
154 | * Generate {@link SharedPreferences} to store the key-value data.
155 | * Override this method to provide a custom implementation of {@link SharedPreferences}.
156 | *
157 | * @param context The context that should be used to get the preferences object.
158 | * @param prefFileName The name of the preference file.
159 | * @return An object implementing the {@link SharedPreferences} interface.
160 | */
161 | protected SharedPreferences getSharedPreferences(Context context, String prefFileName) {
162 | return context.getSharedPreferences(prefFileName, Context.MODE_PRIVATE);
163 | }
164 |
165 | /**
166 | * Returns a cursor for the specified preference(s). If {@code uri}
167 | * is in the form {@code content://authority/prefFileName/prefKey}, the
168 | * cursor will contain a single row containing the queried preference.
169 | * If {@code uri} is in the form {@code content://authority/prefFileName},
170 | * the cursor will contain one row for each preference in the specified
171 | * file.
172 | *
173 | * @param uri Specifies the preference file and key (optional) to query.
174 | * @param projection Specifies which fields should be returned in the cursor.
175 | * @param selection Ignored.
176 | * @param selectionArgs Ignored.
177 | * @param sortOrder Ignored.
178 | * @return A cursor used to access the queried preference data.
179 | */
180 | @Override
181 | public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
182 | RemotePreferencePath prefPath = mUriParser.parse(uri);
183 |
184 | SharedPreferences prefs = getSharedPreferencesOrThrow(prefPath, false);
185 | Map prefMap = prefs.getAll();
186 |
187 | // If no projection is specified, we return all columns.
188 | if (projection == null) {
189 | projection = RemoteContract.COLUMN_ALL;
190 | }
191 |
192 | // Fill out the cursor with the preference data. If the caller
193 | // didn't ask for a particular preference, we return all of them.
194 | MatrixCursor cursor = new MatrixCursor(projection);
195 | if (isSingleKey(prefPath.key)) {
196 | Object prefValue = prefMap.get(prefPath.key);
197 | cursor.addRow(buildRow(projection, prefPath.key, prefValue));
198 | } else {
199 | for (Map.Entry entry : prefMap.entrySet()) {
200 | String prefKey = entry.getKey();
201 | Object prefValue = entry.getValue();
202 | cursor.addRow(buildRow(projection, prefKey, prefValue));
203 | }
204 | }
205 |
206 | return cursor;
207 | }
208 |
209 | /**
210 | * Not used in RemotePreferences. Always returns {@code null}.
211 | *
212 | * @param uri Ignored.
213 | * @return Always returns {@code null}.
214 | */
215 | @Override
216 | public String getType(Uri uri) {
217 | return null;
218 | }
219 |
220 | /**
221 | * Writes the value of the specified preference(s). If no key is specified,
222 | * {@link RemoteContract#COLUMN_TYPE} must be equal to {@link RemoteContract#TYPE_NULL},
223 | * representing the {@link SharedPreferences.Editor#clear()} operation.
224 | *
225 | * @param uri Specifies the preference file and key (optional) to write.
226 | * @param values Specifies the key (optional), type and value of the preference to write.
227 | * @return A URI representing the preference written, or {@code null} on failure.
228 | */
229 | @Override
230 | public Uri insert(Uri uri, ContentValues values) {
231 | if (values == null) {
232 | return null;
233 | }
234 |
235 | RemotePreferencePath prefPath = mUriParser.parse(uri);
236 | String prefKey = getKeyFromUriOrValues(prefPath, values);
237 |
238 | SharedPreferences prefs = getSharedPreferencesOrThrow(prefPath, true);
239 | SharedPreferences.Editor editor = prefs.edit();
240 |
241 | putPreference(editor, prefKey, values);
242 |
243 | if (editor.commit()) {
244 | return getPreferenceUri(prefPath.fileName, prefKey);
245 | } else {
246 | return null;
247 | }
248 | }
249 |
250 | /**
251 | * Writes multiple preference values at once. {@code uri} must
252 | * be in the form {@code content://authority/prefFileName}. See
253 | * {@link #insert(Uri, ContentValues)} for more information.
254 | *
255 | * @param uri Specifies the preference file to write to.
256 | * @param values See {@link #insert(Uri, ContentValues)}.
257 | * @return The number of preferences written, or 0 on failure.
258 | */
259 | @Override
260 | public int bulkInsert(Uri uri, ContentValues[] values) {
261 | RemotePreferencePath prefPath = mUriParser.parse(uri);
262 |
263 | if (isSingleKey(prefPath.key)) {
264 | throw new IllegalArgumentException("Cannot bulk insert with single key URI");
265 | }
266 |
267 | SharedPreferences prefs = getSharedPreferencesByName(prefPath.fileName);
268 | SharedPreferences.Editor editor = prefs.edit();
269 |
270 | for (ContentValues value : values) {
271 | String prefKey = getKeyFromValues(value);
272 | checkAccessOrThrow(prefPath.withKey(prefKey), true);
273 | putPreference(editor, prefKey, value);
274 | }
275 |
276 | if (editor.commit()) {
277 | return values.length;
278 | } else {
279 | return 0;
280 | }
281 | }
282 |
283 | /**
284 | * Deletes the specified preference(s). If {@code uri} is in the form
285 | * {@code content://authority/prefFileName/prefKey}, this will only delete
286 | * the one preference specified in the URI; if {@code uri} is in the form
287 | * {@code content://authority/prefFileName}, clears all preferences.
288 | *
289 | * @param uri Specifies the preference file and key (optional) to delete.
290 | * @param selection Ignored.
291 | * @param selectionArgs Ignored.
292 | * @return 1 if the preferences committed successfully, or 0 on failure.
293 | */
294 | @Override
295 | public int delete(Uri uri, String selection, String[] selectionArgs) {
296 | RemotePreferencePath prefPath = mUriParser.parse(uri);
297 |
298 | SharedPreferences prefs = getSharedPreferencesOrThrow(prefPath, true);
299 | SharedPreferences.Editor editor = prefs.edit();
300 |
301 | if (isSingleKey(prefPath.key)) {
302 | editor.remove(prefPath.key);
303 | } else {
304 | editor.clear();
305 | }
306 |
307 | // There's no reliable method of getting the actual number of
308 | // preference values changed, so callers should not rely on this
309 | // value. A return value of 1 means success, 0 means failure.
310 | if (editor.commit()) {
311 | return 1;
312 | } else {
313 | return 0;
314 | }
315 | }
316 |
317 | /**
318 | * Updates the value of the specified preference(s). This is a wrapper
319 | * around {@link #insert(Uri, ContentValues)} if {@code values} is not
320 | * {@code null}, or {@link #delete(Uri, String, String[])} if {@code values}
321 | * is {@code null}.
322 | *
323 | * @param uri Specifies the preference file and key (optional) to update.
324 | * @param values {@code null} to delete the preference,
325 | * @param selection Ignored.
326 | * @param selectionArgs Ignored.
327 | * @return 1 if the preferences committed successfully, or 0 on failure.
328 | */
329 | @Override
330 | public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
331 | if (values == null) {
332 | return delete(uri, selection, selectionArgs);
333 | } else {
334 | return insert(uri, values) != null ? 1 : 0;
335 | }
336 | }
337 |
338 | /**
339 | * Listener for preference value changes in the local application.
340 | * Re-raises the event through the
341 | * {@link ContentResolver#notifyChange(Uri, ContentObserver)} API
342 | * to any registered {@link ContentObserver} objects. Note that this
343 | * is NOT called for {@link SharedPreferences.Editor#clear()}.
344 | *
345 | * @param prefs The preference file that changed.
346 | * @param prefKey The preference key that changed.
347 | */
348 | @Override
349 | public void onSharedPreferenceChanged(SharedPreferences prefs, String prefKey) {
350 | RemotePreferenceFile prefFile = getSharedPreferencesFile(prefs);
351 | Uri uri = getPreferenceUri(prefFile.getFileName(), prefKey);
352 | Context context = getContext();
353 | if (prefFile.isDeviceProtected() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
354 | context = context.createDeviceProtectedStorageContext();
355 | }
356 | ContentResolver resolver = context.getContentResolver();
357 | resolver.notifyChange(uri, null);
358 | }
359 |
360 | /**
361 | * Writes the value of the specified preference(s). If {@code prefKey}
362 | * is empty, {@code values} must contain {@link RemoteContract#TYPE_NULL}
363 | * for the type, representing the {@link SharedPreferences.Editor#clear()}
364 | * operation.
365 | *
366 | * @param editor The preference file to modify.
367 | * @param prefKey The preference key to modify, or {@code null} for the entire file.
368 | * @param values The values to write.
369 | */
370 | private void putPreference(SharedPreferences.Editor editor, String prefKey, ContentValues values) {
371 | // Get the new value type. Note that we manually check
372 | // for null, then unbox the Integer so we don't cause a NPE.
373 | Integer type = values.getAsInteger(RemoteContract.COLUMN_TYPE);
374 | if (type == null) {
375 | throw new IllegalArgumentException("Invalid or no preference type specified");
376 | }
377 |
378 | // deserializeInput makes sure the actual object type matches
379 | // the expected type, so we must perform this step before actually
380 | // performing any actions.
381 | Object rawValue = values.get(RemoteContract.COLUMN_VALUE);
382 | Object value = RemoteUtils.deserializeInput(rawValue, type);
383 |
384 | // If we are writing to the "directory" and the type is null,
385 | // then we should clear the preferences.
386 | if (!isSingleKey(prefKey)) {
387 | if (type == RemoteContract.TYPE_NULL) {
388 | editor.clear();
389 | return;
390 | } else {
391 | throw new IllegalArgumentException("Attempting to insert preference with null or empty key");
392 | }
393 | }
394 |
395 | switch (type) {
396 | case RemoteContract.TYPE_NULL:
397 | editor.remove(prefKey);
398 | break;
399 | case RemoteContract.TYPE_STRING:
400 | editor.putString(prefKey, (String)value);
401 | break;
402 | case RemoteContract.TYPE_STRING_SET:
403 | if (Build.VERSION.SDK_INT >= 11) {
404 | editor.putStringSet(prefKey, RemoteUtils.castStringSet(value));
405 | } else {
406 | throw new IllegalArgumentException("String set preferences not supported on API < 11");
407 | }
408 | break;
409 | case RemoteContract.TYPE_INT:
410 | editor.putInt(prefKey, (Integer)value);
411 | break;
412 | case RemoteContract.TYPE_LONG:
413 | editor.putLong(prefKey, (Long)value);
414 | break;
415 | case RemoteContract.TYPE_FLOAT:
416 | editor.putFloat(prefKey, (Float)value);
417 | break;
418 | case RemoteContract.TYPE_BOOLEAN:
419 | editor.putBoolean(prefKey, (Boolean)value);
420 | break;
421 | default:
422 | throw new IllegalArgumentException("Cannot set preference with type " + type);
423 | }
424 | }
425 |
426 | /**
427 | * Used to project a preference value to the schema requested by the caller.
428 | *
429 | * @param projection The projection requested by the caller.
430 | * @param key The preference key.
431 | * @param value The preference value.
432 | * @return A row representing the preference using the given schema.
433 | */
434 | private Object[] buildRow(String[] projection, String key, Object value) {
435 | Object[] row = new Object[projection.length];
436 | for (int i = 0; i < row.length; ++i) {
437 | String col = projection[i];
438 | if (RemoteContract.COLUMN_KEY.equals(col)) {
439 | row[i] = key;
440 | } else if (RemoteContract.COLUMN_TYPE.equals(col)) {
441 | row[i] = RemoteUtils.getPreferenceType(value);
442 | } else if (RemoteContract.COLUMN_VALUE.equals(col)) {
443 | row[i] = RemoteUtils.serializeOutput(value);
444 | } else {
445 | throw new IllegalArgumentException("Invalid column name: " + col);
446 | }
447 | }
448 | return row;
449 | }
450 |
451 | /**
452 | * Returns whether the specified key represents a single preference key
453 | * (as opposed to the entire preference file).
454 | *
455 | * @param prefKey The preference key to check.
456 | * @return Whether the key refers to a single preference.
457 | */
458 | private static boolean isSingleKey(String prefKey) {
459 | return prefKey != null;
460 | }
461 |
462 | /**
463 | * Parses the preference key from {@code values}. If the key is not
464 | * specified in the values, {@code null} is returned.
465 | *
466 | * @param values The query values to parse.
467 | * @return The parsed key, or {@code null} if no key was found.
468 | */
469 | private static String getKeyFromValues(ContentValues values) {
470 | String key = values.getAsString(RemoteContract.COLUMN_KEY);
471 | if (key != null && key.length() == 0) {
472 | key = null;
473 | }
474 | return key;
475 | }
476 |
477 | /**
478 | * Parses the preference key from the specified sources. Since there
479 | * are two ways to specify the key (from the URI or from the query values),
480 | * the only allowed combinations are:
481 | *
482 | * uri.key == values.key
483 | * uri.key != null and values.key == null = URI key is used
484 | * uri.key == null and values.key != null = values key is used
485 | * uri.key == null and values.key == null = no key
486 | *
487 | * If none of these conditions are met, an exception is thrown.
488 | *
489 | * @param prefPath Parsed URI key from {@code mUriParser.parse(uri)}.
490 | * @param values Query values provided by the caller.
491 | * @return The parsed key, or {@code null} if the key refers to a preference file.
492 | */
493 | private static String getKeyFromUriOrValues(RemotePreferencePath prefPath, ContentValues values) {
494 | String uriKey = prefPath.key;
495 | String valuesKey = getKeyFromValues(values);
496 | if (isSingleKey(uriKey) && isSingleKey(valuesKey)) {
497 | // If a key is specified in both the URI and
498 | // ContentValues, they must match
499 | if (!uriKey.equals(valuesKey)) {
500 | throw new IllegalArgumentException("Conflicting keys specified in URI and ContentValues");
501 | }
502 | return uriKey;
503 | } else if (isSingleKey(uriKey)) {
504 | return uriKey;
505 | } else if (isSingleKey(valuesKey)) {
506 | return valuesKey;
507 | } else {
508 | return null;
509 | }
510 | }
511 |
512 | /**
513 | * Checks that the caller has permissions to access the specified preference.
514 | * Throws an exception if permission is denied.
515 | *
516 | * @param prefPath The preference file and key to be accessed.
517 | * @param write Whether the operation will modify the preference.
518 | */
519 | private void checkAccessOrThrow(RemotePreferencePath prefPath, boolean write) {
520 | // For backwards compatibility, checkAccess takes an empty string when
521 | // referring to the whole file.
522 | String prefKey = prefPath.key;
523 | if (!isSingleKey(prefKey)) {
524 | prefKey = "";
525 | }
526 |
527 | if (!checkAccess(prefPath.fileName, prefKey, write)) {
528 | throw new SecurityException("Insufficient permissions to access: " + prefPath);
529 | }
530 | }
531 |
532 | /**
533 | * Returns the {@link SharedPreferences} instance with the specified name.
534 | * This is essentially equivalent to {@link Context#getSharedPreferences(String, int)},
535 | * except that it will used the internally cached version, and throws an
536 | * exception if the provider was not configured to access that preference file.
537 | *
538 | * @param prefFileName The name of the preference file to access.
539 | * @return The {@link SharedPreferences} instance with the specified file name.
540 | */
541 | private SharedPreferences getSharedPreferencesByName(String prefFileName) {
542 | SharedPreferences prefs = mPreferences.get(prefFileName);
543 | if (prefs == null) {
544 | throw new IllegalArgumentException("Unknown preference file name: " + prefFileName);
545 | }
546 | return prefs;
547 | }
548 |
549 | /**
550 | * Returns the file name for a {@link SharedPreferences} instance.
551 | * Throws an exception if the provider was not configured to access
552 | * the specified preferences.
553 | *
554 | * @param prefs The shared preferences object.
555 | * @return The name of the preference file.
556 | */
557 | private String getSharedPreferencesFileName(SharedPreferences prefs) {
558 | for (Map.Entry entry : mPreferences.entrySet()) {
559 | if (entry.getValue() == prefs) {
560 | return entry.getKey();
561 | }
562 | }
563 | throw new IllegalArgumentException("Unknown preference file");
564 | }
565 |
566 | /**
567 | * Get the corresponding {@link RemotePreferenceFile} object for a
568 | * {@link SharedPreferences} instance. Throws an exception if the
569 | * provider was not configured to access the specified preferences.
570 | *
571 | * @param prefs The shared preferences object.
572 | * @return The corresponding {@link RemotePreferenceFile} object.
573 | */
574 | private RemotePreferenceFile getSharedPreferencesFile(SharedPreferences prefs) {
575 | String prefFileName = getSharedPreferencesFileName(prefs);
576 | for (RemotePreferenceFile file : mPrefFiles) {
577 | if (file.getFileName().equals(prefFileName)) {
578 | return file;
579 | }
580 | }
581 | throw new IllegalArgumentException("Unknown preference file");
582 | }
583 |
584 | /**
585 | * Returns the {@link SharedPreferences} instance with the specified name,
586 | * checking that the caller has permissions to access the specified key within
587 | * that file. If not, an exception will be thrown.
588 | *
589 | * @param prefPath The preference file and key to be accessed.
590 | * @param write Whether the operation will modify the preference.
591 | * @return The {@link SharedPreferences} instance with the specified file name.
592 | */
593 | private SharedPreferences getSharedPreferencesOrThrow(RemotePreferencePath prefPath, boolean write) {
594 | checkAccessOrThrow(prefPath, write);
595 | return getSharedPreferencesByName(prefPath.fileName);
596 | }
597 |
598 | /**
599 | * Builds a URI for the specified preference file and key that can be used
600 | * to later query the same preference.
601 | *
602 | * @param prefFileName The preference file.
603 | * @param prefKey The preference key.
604 | * @return A URI representing the specified preference.
605 | */
606 | private Uri getPreferenceUri(String prefFileName, String prefKey) {
607 | Uri.Builder builder = mBaseUri.buildUpon().appendPath(prefFileName);
608 | if (isSingleKey(prefKey)) {
609 | builder.appendPath(prefKey);
610 | }
611 | return builder.build();
612 | }
613 | }
614 |
--------------------------------------------------------------------------------
/library/src/main/java/com/crossbowffs/remotepreferences/RemotePreferenceUriParser.java:
--------------------------------------------------------------------------------
1 | package com.crossbowffs.remotepreferences;
2 |
3 | import android.content.UriMatcher;
4 | import android.net.Uri;
5 |
6 | import java.util.List;
7 |
8 | /**
9 | * Decodes URIs passed between {@link RemotePreferences} and {@link RemotePreferenceProvider}.
10 | */
11 | /* package */ class RemotePreferenceUriParser {
12 | private static final int PREFERENCES_ID = 1;
13 | private static final int PREFERENCE_ID = 2;
14 |
15 | private final UriMatcher mUriMatcher;
16 |
17 | public RemotePreferenceUriParser(String authority) {
18 | mUriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
19 | mUriMatcher.addURI(authority, "*/", PREFERENCES_ID);
20 | mUriMatcher.addURI(authority, "*/*", PREFERENCE_ID);
21 | }
22 |
23 | /**
24 | * Parses the preference file and key from a query URI. If the key
25 | * is not specified, the returned path will contain {@code null} as the key.
26 | *
27 | * @param uri The URI to parse.
28 | * @return A path object containing the preference file name and key.
29 | */
30 | public RemotePreferencePath parse(Uri uri) {
31 | int match = mUriMatcher.match(uri);
32 | if (match != PREFERENCE_ID && match != PREFERENCES_ID) {
33 | throw new IllegalArgumentException("Invalid URI: " + uri);
34 | }
35 |
36 | // The URI must fall under one of these patterns:
37 | //
38 | // content://authority/prefFileName/prefKey
39 | // content://authority/prefFileName/
40 | // content://authority/prefFileName
41 | //
42 | // The match ID will be PREFERENCE_ID under the first case,
43 | // and PREFERENCES_ID under the second and third cases
44 | // (UriMatcher ignores trailing slashes).
45 | List pathSegments = uri.getPathSegments();
46 | String prefFileName = pathSegments.get(0);
47 | String prefKey = null;
48 | if (match == PREFERENCE_ID) {
49 | prefKey = pathSegments.get(1);
50 | }
51 | return new RemotePreferencePath(prefFileName, prefKey);
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/library/src/main/java/com/crossbowffs/remotepreferences/RemotePreferences.java:
--------------------------------------------------------------------------------
1 | package com.crossbowffs.remotepreferences;
2 |
3 | import android.annotation.TargetApi;
4 | import android.content.ContentValues;
5 | import android.content.Context;
6 | import android.content.SharedPreferences;
7 | import android.database.ContentObserver;
8 | import android.database.Cursor;
9 | import android.net.Uri;
10 | import android.os.Build;
11 | import android.os.Handler;
12 |
13 | import java.lang.ref.WeakReference;
14 | import java.util.ArrayList;
15 | import java.util.HashMap;
16 | import java.util.Map;
17 | import java.util.Set;
18 | import java.util.WeakHashMap;
19 |
20 | /**
21 | *
22 | * Provides a {@link SharedPreferences} compatible API to
23 | * {@link RemotePreferenceProvider}. See {@link RemotePreferenceProvider}
24 | * for more information.
25 | *
26 | *
27 | *
28 | * If you are reading preferences from the same context as the
29 | * provider, you should not use this class; just access the
30 | * {@link SharedPreferences} API as you would normally.
31 | *
32 | */
33 | public class RemotePreferences implements SharedPreferences {
34 | private final Context mContext;
35 | private final Handler mHandler;
36 | private final Uri mBaseUri;
37 | private final boolean mStrictMode;
38 | private final WeakHashMap mListeners;
39 | private final RemotePreferenceUriParser mUriParser;
40 |
41 | /**
42 | * Initializes a new remote preferences object, with strict
43 | * mode disabled.
44 | *
45 | * @param context Used to access the preference provider.
46 | * @param authority The authority of the preference provider.
47 | * @param prefFileName The name of the preference file to access.
48 | */
49 | public RemotePreferences(Context context, String authority, String prefFileName) {
50 | this(context, authority, prefFileName, false);
51 | }
52 |
53 | /**
54 | * Initializes a new remote preferences object. If {@code strictMode}
55 | * is {@code true} and the remote preference provider cannot be accessed,
56 | * read/write operations on this object will throw a
57 | * {@link RemotePreferenceAccessException}. Otherwise, default values
58 | * will be returned.
59 | *
60 | * @param context Used to access the preference provider.
61 | * @param authority The authority of the preference provider.
62 | * @param prefFileName The name of the preference file to access.
63 | * @param strictMode Whether strict mode is enabled.
64 | */
65 | public RemotePreferences(Context context, String authority, String prefFileName, boolean strictMode) {
66 | this(context, new Handler(context.getMainLooper()), authority, prefFileName, strictMode);
67 | }
68 |
69 | /**
70 | * Initializes a new remote preferences object. If {@code strictMode}
71 | * is {@code true} and the remote preference provider cannot be accessed,
72 | * read/write operations on this object will throw a
73 | * {@link RemotePreferenceAccessException}. Otherwise, default values
74 | * will be returned.
75 | *
76 | * @param context Used to access the preference provider.
77 | * @param handler Used to receive preference change events.
78 | * @param authority The authority of the preference provider.
79 | * @param prefFileName The name of the preference file to access.
80 | * @param strictMode Whether strict mode is enabled.
81 | */
82 | /* package */ RemotePreferences(Context context, Handler handler, String authority, String prefFileName, boolean strictMode) {
83 | checkNotNull("context", context);
84 | checkNotNull("handler", handler);
85 | checkNotNull("authority", authority);
86 | checkNotNull("prefFileName", prefFileName);
87 | mContext = context;
88 | mHandler = handler;
89 | mBaseUri = Uri.parse("content://" + authority).buildUpon().appendPath(prefFileName).build();
90 | mStrictMode = strictMode;
91 | mListeners = new WeakHashMap();
92 | mUriParser = new RemotePreferenceUriParser(authority);
93 | }
94 |
95 | @Override
96 | public Map getAll() {
97 | return queryAll();
98 | }
99 |
100 | @Override
101 | public String getString(String key, String defValue) {
102 | return (String)querySingle(key, defValue, RemoteContract.TYPE_STRING);
103 | }
104 |
105 | @Override
106 | @TargetApi(11)
107 | public Set getStringSet(String key, Set defValues) {
108 | if (Build.VERSION.SDK_INT < 11) {
109 | throw new UnsupportedOperationException("String sets only supported on API 11 and above");
110 | }
111 | return RemoteUtils.castStringSet(querySingle(key, defValues, RemoteContract.TYPE_STRING_SET));
112 | }
113 |
114 | @Override
115 | public int getInt(String key, int defValue) {
116 | return (Integer)querySingle(key, defValue, RemoteContract.TYPE_INT);
117 | }
118 |
119 | @Override
120 | public long getLong(String key, long defValue) {
121 | return (Long)querySingle(key, defValue, RemoteContract.TYPE_LONG);
122 | }
123 |
124 | @Override
125 | public float getFloat(String key, float defValue) {
126 | return (Float)querySingle(key, defValue, RemoteContract.TYPE_FLOAT);
127 | }
128 |
129 | @Override
130 | public boolean getBoolean(String key, boolean defValue) {
131 | return (Boolean)querySingle(key, defValue, RemoteContract.TYPE_BOOLEAN);
132 | }
133 |
134 | @Override
135 | public boolean contains(String key) {
136 | return containsKey(key);
137 | }
138 |
139 | @Override
140 | public Editor edit() {
141 | return new RemotePreferencesEditor();
142 | }
143 |
144 | @Override
145 | public void registerOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {
146 | checkNotNull("listener", listener);
147 | if (mListeners.containsKey(listener)) return;
148 | PreferenceContentObserver observer = new PreferenceContentObserver(listener);
149 | mListeners.put(listener, observer);
150 | mContext.getContentResolver().registerContentObserver(mBaseUri, true, observer);
151 | }
152 |
153 | @Override
154 | public void unregisterOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {
155 | checkNotNull("listener", listener);
156 | PreferenceContentObserver observer = mListeners.remove(listener);
157 | if (observer != null) {
158 | mContext.getContentResolver().unregisterContentObserver(observer);
159 | }
160 | }
161 |
162 | /**
163 | * If {@code object} is {@code null}, throws an exception.
164 | *
165 | * @param name The name of the object, for use in the exception message.
166 | * @param object The object to check.
167 | */
168 | private static void checkNotNull(String name, Object object) {
169 | if (object == null) {
170 | throw new IllegalArgumentException(name + " is null");
171 | }
172 | }
173 |
174 | /**
175 | * If {@code key} is {@code null} or {@code ""}, throws an exception.
176 | *
177 | * @param key The object to check.
178 | */
179 | private static void checkKeyNotEmpty(String key) {
180 | if (key == null || key.length() == 0) {
181 | throw new IllegalArgumentException("Key is null or empty");
182 | }
183 | }
184 |
185 | /**
186 | * If strict mode is enabled, wraps and throws the given exception.
187 | * Otherwise, does nothing.
188 | *
189 | * @param e The exception to wrap.
190 | */
191 | private void wrapException(Exception e) {
192 | if (mStrictMode) {
193 | throw new RemotePreferenceAccessException(e);
194 | }
195 | }
196 |
197 | /**
198 | * Queries the specified URI. If the query fails and strict mode is
199 | * enabled, an exception will be thrown; otherwise {@code null} will
200 | * be returned.
201 | *
202 | * @param uri The URI to query.
203 | * @param columns The columns to include in the returned cursor.
204 | * @return A cursor used to access the queried preference data.
205 | */
206 | private Cursor query(Uri uri, String[] columns) {
207 | Cursor cursor = null;
208 | try {
209 | cursor = mContext.getContentResolver().query(uri, columns, null, null, null);
210 | } catch (Exception e) {
211 | wrapException(e);
212 | }
213 | if (cursor == null && mStrictMode) {
214 | throw new RemotePreferenceAccessException("query() failed or returned null cursor");
215 | }
216 | return cursor;
217 | }
218 |
219 | /**
220 | * Writes multiple preferences at once to the preference provider.
221 | * If the operation fails and strict mode is enabled, an exception
222 | * will be thrown; otherwise {@code false} will be returned.
223 | *
224 | * @param uri The URI to modify.
225 | * @param values The values to write.
226 | * @return Whether the operation succeeded.
227 | */
228 | private boolean bulkInsert(Uri uri, ContentValues[] values) {
229 | int count;
230 | try {
231 | count = mContext.getContentResolver().bulkInsert(uri, values);
232 | } catch (Exception e) {
233 | wrapException(e);
234 | return false;
235 | }
236 | if (count != values.length && mStrictMode) {
237 | throw new RemotePreferenceAccessException("bulkInsert() failed");
238 | }
239 | return count == values.length;
240 | }
241 |
242 | /**
243 | * Reads a single preference from the preference provider. This may
244 | * throw a {@link ClassCastException} even if strict mode is disabled
245 | * if the provider returns an incompatible type. If strict mode is
246 | * disabled and the preference cannot be read, the default value is returned.
247 | *
248 | * @param key The preference key to read.
249 | * @param defValue The default value, if there is no existing value.
250 | * @param expectedType The expected type of the value.
251 | * @return The value of the preference, or {@code defValue} if no value exists.
252 | */
253 | private Object querySingle(String key, Object defValue, int expectedType) {
254 | checkKeyNotEmpty(key);
255 | Uri uri = mBaseUri.buildUpon().appendPath(key).build();
256 | String[] columns = {RemoteContract.COLUMN_TYPE, RemoteContract.COLUMN_VALUE};
257 | Cursor cursor = query(uri, columns);
258 | try {
259 | if (cursor == null || !cursor.moveToFirst()) {
260 | return defValue;
261 | }
262 |
263 | int typeCol = cursor.getColumnIndexOrThrow(RemoteContract.COLUMN_TYPE);
264 | int type = cursor.getInt(typeCol);
265 | if (type == RemoteContract.TYPE_NULL) {
266 | return defValue;
267 | } else if (type != expectedType) {
268 | throw new ClassCastException("Preference type mismatch");
269 | }
270 |
271 | int valueCol = cursor.getColumnIndexOrThrow(RemoteContract.COLUMN_VALUE);
272 | return getValue(cursor, typeCol, valueCol);
273 | } finally {
274 | if (cursor != null) {
275 | cursor.close();
276 | }
277 | }
278 | }
279 |
280 | /**
281 | * Reads all preferences from the preference provider. If strict
282 | * mode is disabled and the preferences cannot be read, an empty
283 | * map is returned.
284 | *
285 | * @return A map containing all preferences.
286 | */
287 | private Map queryAll() {
288 | Uri uri = mBaseUri.buildUpon().appendPath("").build();
289 | String[] columns = {RemoteContract.COLUMN_KEY, RemoteContract.COLUMN_TYPE, RemoteContract.COLUMN_VALUE};
290 | Cursor cursor = query(uri, columns);
291 | try {
292 | HashMap map = new HashMap();
293 | if (cursor == null) {
294 | return map;
295 | }
296 |
297 | int keyCol = cursor.getColumnIndexOrThrow(RemoteContract.COLUMN_KEY);
298 | int typeCol = cursor.getColumnIndexOrThrow(RemoteContract.COLUMN_TYPE);
299 | int valueCol = cursor.getColumnIndexOrThrow(RemoteContract.COLUMN_VALUE);
300 | while (cursor.moveToNext()) {
301 | String key = cursor.getString(keyCol);
302 | map.put(key, getValue(cursor, typeCol, valueCol));
303 | }
304 | return map;
305 | } finally {
306 | if (cursor != null) {
307 | cursor.close();
308 | }
309 | }
310 | }
311 |
312 | /**
313 | * Checks whether the preference exists. If strict mode is
314 | * disabled and the preferences cannot be read, {@code false}
315 | * is returned.
316 | *
317 | * @param key The key to check existence for.
318 | * @return Whether the preference exists.
319 | */
320 | private boolean containsKey(String key) {
321 | checkKeyNotEmpty(key);
322 | Uri uri = mBaseUri.buildUpon().appendPath(key).build();
323 | String[] columns = {RemoteContract.COLUMN_TYPE};
324 | Cursor cursor = query(uri, columns);
325 | try {
326 | if (cursor == null || !cursor.moveToFirst()) {
327 | return false;
328 | }
329 |
330 | int typeCol = cursor.getColumnIndexOrThrow(RemoteContract.COLUMN_TYPE);
331 | return cursor.getInt(typeCol) != RemoteContract.TYPE_NULL;
332 | } finally {
333 | if (cursor != null) {
334 | cursor.close();
335 | }
336 | }
337 | }
338 |
339 | /**
340 | * Extracts a preference value from a cursor. Performs deserialization
341 | * of the value if necessary.
342 | *
343 | * @param cursor The cursor containing the preference value.
344 | * @param typeCol The index containing the {@link RemoteContract#COLUMN_TYPE} column.
345 | * @param valueCol The index containing the {@link RemoteContract#COLUMN_VALUE} column.
346 | * @return The value from the cursor.
347 | */
348 | private Object getValue(Cursor cursor, int typeCol, int valueCol) {
349 | int expectedType = cursor.getInt(typeCol);
350 | switch (expectedType) {
351 | case RemoteContract.TYPE_STRING:
352 | return cursor.getString(valueCol);
353 | case RemoteContract.TYPE_STRING_SET:
354 | return RemoteUtils.deserializeStringSet(cursor.getString(valueCol));
355 | case RemoteContract.TYPE_INT:
356 | return cursor.getInt(valueCol);
357 | case RemoteContract.TYPE_LONG:
358 | return cursor.getLong(valueCol);
359 | case RemoteContract.TYPE_FLOAT:
360 | return cursor.getFloat(valueCol);
361 | case RemoteContract.TYPE_BOOLEAN:
362 | return cursor.getInt(valueCol) != 0;
363 | default:
364 | throw new AssertionError("Invalid expected type: " + expectedType);
365 | }
366 | }
367 |
368 | /**
369 | * Implementation of the {@link SharedPreferences.Editor} interface
370 | * for use with RemotePreferences.
371 | */
372 | private class RemotePreferencesEditor implements Editor {
373 | private final ArrayList mValues = new ArrayList();
374 |
375 | /**
376 | * Creates a new {@link ContentValues} with the specified key and
377 | * type columns pre-filled. The {@link RemoteContract#COLUMN_VALUE}
378 | * field is NOT filled in.
379 | *
380 | * @param key The preference key.
381 | * @param type The preference type.
382 | * @return The pre-filled values.
383 | */
384 | private ContentValues createContentValues(String key, int type) {
385 | ContentValues values = new ContentValues(4);
386 | values.put(RemoteContract.COLUMN_KEY, key);
387 | values.put(RemoteContract.COLUMN_TYPE, type);
388 | return values;
389 | }
390 |
391 | /**
392 | * Creates an operation to add/set a new preference. Again, the
393 | * {@link RemoteContract#COLUMN_VALUE} field is NOT filled in.
394 | * This will also add the values to the operation queue.
395 | *
396 | * @param key The preference key to add.
397 | * @param type The preference type to add.
398 | * @return The pre-filled values.
399 | */
400 | private ContentValues createAddOp(String key, int type) {
401 | checkKeyNotEmpty(key);
402 | ContentValues values = createContentValues(key, type);
403 | mValues.add(values);
404 | return values;
405 | }
406 |
407 | /**
408 | * Creates an operation to delete a preference. All fields
409 | * are pre-filled. This will also add the values to the
410 | * operation queue.
411 | *
412 | * @param key The preference key to delete.
413 | * @return The pre-filled values.
414 | */
415 | private ContentValues createRemoveOp(String key) {
416 | // Note: Remove operations are inserted at the beginning
417 | // of the list (this preserves the SharedPreferences behavior
418 | // that all removes are performed before any adds)
419 | ContentValues values = createContentValues(key, RemoteContract.TYPE_NULL);
420 | values.putNull(RemoteContract.COLUMN_VALUE);
421 | mValues.add(0, values);
422 | return values;
423 | }
424 |
425 | @Override
426 | public Editor putString(String key, String value) {
427 | createAddOp(key, RemoteContract.TYPE_STRING).put(RemoteContract.COLUMN_VALUE, value);
428 | return this;
429 | }
430 |
431 | @Override
432 | @TargetApi(11)
433 | public Editor putStringSet(String key, Set value) {
434 | if (Build.VERSION.SDK_INT < 11) {
435 | throw new UnsupportedOperationException("String sets only supported on API 11 and above");
436 | }
437 | String serializedSet = RemoteUtils.serializeStringSet(value);
438 | createAddOp(key, RemoteContract.TYPE_STRING_SET).put(RemoteContract.COLUMN_VALUE, serializedSet);
439 | return this;
440 | }
441 |
442 | @Override
443 | public Editor putInt(String key, int value) {
444 | createAddOp(key, RemoteContract.TYPE_INT).put(RemoteContract.COLUMN_VALUE, value);
445 | return this;
446 | }
447 |
448 | @Override
449 | public Editor putLong(String key, long value) {
450 | createAddOp(key, RemoteContract.TYPE_LONG).put(RemoteContract.COLUMN_VALUE, value);
451 | return this;
452 | }
453 |
454 | @Override
455 | public Editor putFloat(String key, float value) {
456 | createAddOp(key, RemoteContract.TYPE_FLOAT).put(RemoteContract.COLUMN_VALUE, value);
457 | return this;
458 | }
459 |
460 | @Override
461 | public Editor putBoolean(String key, boolean value) {
462 | createAddOp(key, RemoteContract.TYPE_BOOLEAN).put(RemoteContract.COLUMN_VALUE, value ? 1 : 0);
463 | return this;
464 | }
465 |
466 | @Override
467 | public Editor remove(String key) {
468 | checkKeyNotEmpty(key);
469 | createRemoveOp(key);
470 | return this;
471 | }
472 |
473 | @Override
474 | public Editor clear() {
475 | createRemoveOp("");
476 | return this;
477 | }
478 |
479 | @Override
480 | public boolean commit() {
481 | ContentValues[] values = mValues.toArray(new ContentValues[mValues.size()]);
482 | Uri uri = mBaseUri.buildUpon().appendPath("").build();
483 | return bulkInsert(uri, values);
484 | }
485 |
486 | @Override
487 | public void apply() {
488 | commit();
489 | }
490 | }
491 |
492 | /**
493 | * {@link ContentObserver} subclass used to monitor preference changes
494 | * in the remote preference provider. When a change is detected, this will notify
495 | * the corresponding {@link SharedPreferences.OnSharedPreferenceChangeListener}.
496 | */
497 | private class PreferenceContentObserver extends ContentObserver {
498 | private final WeakReference mListener;
499 |
500 | private PreferenceContentObserver(OnSharedPreferenceChangeListener listener) {
501 | super(mHandler);
502 | mListener = new WeakReference(listener);
503 | }
504 |
505 | @Override
506 | public boolean deliverSelfNotifications() {
507 | return true;
508 | }
509 |
510 | @Override
511 | public void onChange(boolean selfChange, Uri uri) {
512 | RemotePreferencePath path = mUriParser.parse(uri);
513 |
514 | // We use a weak reference to mimic the behavior of SharedPreferences.
515 | // The code which registered the listener is responsible for holding a
516 | // reference to it. If at any point we find that the listener has been
517 | // garbage collected, we unregister the observer.
518 | OnSharedPreferenceChangeListener listener = mListener.get();
519 | if (listener == null) {
520 | mContext.getContentResolver().unregisterContentObserver(this);
521 | } else {
522 | listener.onSharedPreferenceChanged(RemotePreferences.this, path.key);
523 | }
524 | }
525 | }
526 | }
527 |
--------------------------------------------------------------------------------
/library/src/main/java/com/crossbowffs/remotepreferences/RemoteUtils.java:
--------------------------------------------------------------------------------
1 | package com.crossbowffs.remotepreferences;
2 |
3 | import java.util.HashSet;
4 | import java.util.Set;
5 |
6 | /**
7 | * Common utilities used to serialize and deserialize
8 | * preferences between the preference provider and caller.
9 | */
10 | /* package */ final class RemoteUtils {
11 | private RemoteUtils() {}
12 |
13 | /**
14 | * Casts the parameter to a string set. Useful to avoid the unchecked
15 | * warning that would normally come with the cast. The value must
16 | * already be a string set; this does not deserialize it.
17 | *
18 | * @param value The value, as type {@link Object}.
19 | * @return The value, as type {@link Set}.
20 | */
21 | @SuppressWarnings("unchecked")
22 | public static Set castStringSet(Object value) {
23 | return (Set)value;
24 | }
25 |
26 | /**
27 | * Returns the {@code TYPE_*} constant corresponding to the given
28 | * object's type.
29 | *
30 | * @param value The original object.
31 | * @return One of the {@link RemoteContract}{@code .TYPE_*} constants.
32 | */
33 | public static int getPreferenceType(Object value) {
34 | if (value == null) return RemoteContract.TYPE_NULL;
35 | if (value instanceof String) return RemoteContract.TYPE_STRING;
36 | if (value instanceof Set>) return RemoteContract.TYPE_STRING_SET;
37 | if (value instanceof Integer) return RemoteContract.TYPE_INT;
38 | if (value instanceof Long) return RemoteContract.TYPE_LONG;
39 | if (value instanceof Float) return RemoteContract.TYPE_FLOAT;
40 | if (value instanceof Boolean) return RemoteContract.TYPE_BOOLEAN;
41 | throw new AssertionError("Unknown preference type: " + value.getClass());
42 | }
43 |
44 | /**
45 | * Serializes the specified object to a format that is safe to use
46 | * with {@link android.content.ContentValues}. To recover the original
47 | * object, use {@link #deserializeInput(Object, int)}.
48 | *
49 | * @param value The object to serialize.
50 | * @return The serialized object.
51 | */
52 | public static Object serializeOutput(Object value) {
53 | if (value instanceof Boolean) {
54 | return serializeBoolean((Boolean)value);
55 | } else if (value instanceof Set>) {
56 | return serializeStringSet(castStringSet(value));
57 | } else {
58 | return value;
59 | }
60 | }
61 |
62 | /**
63 | * Deserializes an object that was serialized using
64 | * {@link #serializeOutput(Object)}. If the expected type does
65 | * not match the actual type of the object, a {@link ClassCastException}
66 | * will be thrown.
67 | *
68 | * @param value The object to deserialize.
69 | * @param expectedType The expected type of the deserialized object.
70 | * @return The deserialized object.
71 | */
72 | public static Object deserializeInput(Object value, int expectedType) {
73 | if (expectedType == RemoteContract.TYPE_NULL) {
74 | if (value != null) {
75 | throw new IllegalArgumentException("Expected null, got non-null value");
76 | } else {
77 | return null;
78 | }
79 | }
80 | try {
81 | switch (expectedType) {
82 | case RemoteContract.TYPE_STRING:
83 | return (String)value;
84 | case RemoteContract.TYPE_STRING_SET:
85 | return deserializeStringSet((String)value);
86 | case RemoteContract.TYPE_INT:
87 | return (Integer)value;
88 | case RemoteContract.TYPE_LONG:
89 | return (Long)value;
90 | case RemoteContract.TYPE_FLOAT:
91 | return (Float)value;
92 | case RemoteContract.TYPE_BOOLEAN:
93 | return deserializeBoolean(value);
94 | }
95 | } catch (ClassCastException e) {
96 | throw new IllegalArgumentException("Expected type " + expectedType + ", got " + value.getClass(), e);
97 | }
98 | throw new IllegalArgumentException("Unknown type: " + expectedType);
99 | }
100 |
101 | /**
102 | * Serializes a {@link Boolean} to a format that is safe to use
103 | * with {@link android.content.ContentValues}.
104 | *
105 | * @param value The {@link Boolean} to serialize.
106 | * @return 1 if {@code value} is {@code true}, 0 if {@code value} is {@code false}.
107 | */
108 | private static Integer serializeBoolean(Boolean value) {
109 | if (value == null) {
110 | return null;
111 | } else {
112 | return value ? 1 : 0;
113 | }
114 | }
115 |
116 | /**
117 | * Deserializes a {@link Boolean} that was serialized using
118 | * {@link #serializeBoolean(Boolean)}.
119 | *
120 | * @param value The {@link Boolean} to deserialize.
121 | * @return {@code true} if {@code value} is 1, {@code false} if {@code value} is 0.
122 | */
123 | private static Boolean deserializeBoolean(Object value) {
124 | if (value == null) {
125 | return null;
126 | } else if (value instanceof Boolean) {
127 | return (Boolean)value;
128 | } else {
129 | return (Integer)value != 0;
130 | }
131 | }
132 |
133 | /**
134 | * Serializes a {@link Set} to a format that is safe to use
135 | * with {@link android.content.ContentValues}.
136 | *
137 | * @param stringSet The {@link Set} to serialize.
138 | * @return The serialized string set.
139 | */
140 | public static String serializeStringSet(Set stringSet) {
141 | if (stringSet == null) {
142 | return null;
143 | }
144 | StringBuilder sb = new StringBuilder();
145 | for (String s : stringSet) {
146 | sb.append(s.replace("\\", "\\\\").replace(";", "\\;"));
147 | sb.append(';');
148 | }
149 | return sb.toString();
150 | }
151 |
152 | /**
153 | * Deserializes a {@link Set} that was serialized using
154 | * {@link #serializeStringSet(Set)}.
155 | *
156 | * @param serializedString The {@link Set} to deserialize.
157 | * @return The deserialized string set.
158 | */
159 | public static Set deserializeStringSet(String serializedString) {
160 | if (serializedString == null) {
161 | return null;
162 | }
163 | HashSet stringSet = new HashSet();
164 | StringBuilder sb = new StringBuilder();
165 | for (int i = 0; i < serializedString.length(); ++i) {
166 | char c = serializedString.charAt(i);
167 | if (c == '\\') {
168 | char next = serializedString.charAt(++i);
169 | sb.append(next);
170 | } else if (c == ';') {
171 | stringSet.add(sb.toString());
172 | sb.delete(0, sb.length());
173 | } else {
174 | sb.append(c);
175 | }
176 | }
177 |
178 | // We require that the serialized string ends with a ; per element
179 | // since that's how we distinguish empty sets from sets containing
180 | // an empty string. Assume caller is doing unsafe string joins
181 | // instead of using the serializeStringSet API, and fail fast.
182 | if (sb.length() != 0) {
183 | throw new IllegalArgumentException("Serialized string set contains trailing chars");
184 | }
185 |
186 | return stringSet;
187 | }
188 | }
189 |
--------------------------------------------------------------------------------
/settings.gradle.kts:
--------------------------------------------------------------------------------
1 | include(":library")
2 | include(":testapp")
3 |
--------------------------------------------------------------------------------
/testapp/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | id("com.android.application")
3 | }
4 |
5 | android {
6 | namespace = "com.crossbowffs.remotepreferences.testapp"
7 | compileSdk = 34
8 |
9 | defaultConfig {
10 | minSdk = 14
11 | targetSdk = 34
12 | versionCode = 1
13 | versionName = "1.0"
14 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
15 | }
16 | }
17 |
18 | dependencies {
19 | implementation(project(":library"))
20 |
21 | androidTestImplementation("junit:junit:4.13.2")
22 | androidTestImplementation("androidx.test:core:1.5.0")
23 | androidTestImplementation("androidx.test:runner:1.5.2")
24 | androidTestImplementation("androidx.test:rules:1.5.0")
25 | androidTestImplementation("androidx.test.ext:junit:1.1.5")
26 | }
27 |
--------------------------------------------------------------------------------
/testapp/src/androidTest/java/com/crossbowffs/remotepreferences/RemotePreferenceProviderTest.java:
--------------------------------------------------------------------------------
1 | package com.crossbowffs.remotepreferences;
2 |
3 | import android.content.ContentResolver;
4 | import android.content.ContentValues;
5 | import android.content.Context;
6 | import android.content.SharedPreferences;
7 | import android.database.Cursor;
8 | import android.net.Uri;
9 |
10 | import androidx.test.ext.junit.runners.AndroidJUnit4;
11 | import androidx.test.platform.app.InstrumentationRegistry;
12 |
13 | import com.crossbowffs.remotepreferences.testapp.TestConstants;
14 |
15 | import org.junit.Assert;
16 | import org.junit.Before;
17 | import org.junit.Test;
18 | import org.junit.runner.RunWith;
19 |
20 | import java.util.HashSet;
21 |
22 | @RunWith(AndroidJUnit4.class)
23 | public class RemotePreferenceProviderTest {
24 | private Context getLocalContext() {
25 | return InstrumentationRegistry.getInstrumentation().getContext();
26 | }
27 |
28 | private Context getRemoteContext() {
29 | return InstrumentationRegistry.getInstrumentation().getTargetContext();
30 | }
31 |
32 | private SharedPreferences getSharedPreferences() {
33 | Context context = getRemoteContext();
34 | return context.getSharedPreferences(TestConstants.PREF_FILE, Context.MODE_PRIVATE);
35 | }
36 |
37 | private Uri getQueryUri(String key) {
38 | String uri = "content://" + TestConstants.AUTHORITY + "/" + TestConstants.PREF_FILE;
39 | if (key != null) {
40 | uri += "/" + key;
41 | }
42 | return Uri.parse(uri);
43 | }
44 |
45 | @Before
46 | public void resetPreferences() {
47 | getSharedPreferences().edit().clear().commit();
48 | }
49 |
50 | @Test
51 | public void testQueryAllPrefs() {
52 | getSharedPreferences()
53 | .edit()
54 | .putString("string", "foobar")
55 | .putInt("int", 1337)
56 | .apply();
57 |
58 | ContentResolver resolver = getLocalContext().getContentResolver();
59 | Cursor q = resolver.query(getQueryUri(null), null, null, null, null);
60 | Assert.assertEquals(2, q.getCount());
61 |
62 | int key = q.getColumnIndex(RemoteContract.COLUMN_KEY);
63 | int type = q.getColumnIndex(RemoteContract.COLUMN_TYPE);
64 | int value = q.getColumnIndex(RemoteContract.COLUMN_VALUE);
65 |
66 | while (q.moveToNext()) {
67 | if (q.getString(key).equals("string")) {
68 | Assert.assertEquals(RemoteContract.TYPE_STRING, q.getInt(type));
69 | Assert.assertEquals("foobar", q.getString(value));
70 | } else if (q.getString(key).equals("int")) {
71 | Assert.assertEquals(RemoteContract.TYPE_INT, q.getInt(type));
72 | Assert.assertEquals(1337, q.getInt(value));
73 | } else {
74 | Assert.fail();
75 | }
76 | }
77 | }
78 |
79 | @Test
80 | public void testQuerySinglePref() {
81 | getSharedPreferences()
82 | .edit()
83 | .putString("string", "foobar")
84 | .putInt("int", 1337)
85 | .apply();
86 |
87 | ContentResolver resolver = getLocalContext().getContentResolver();
88 | Cursor q = resolver.query(getQueryUri("string"), null, null, null, null);
89 | Assert.assertEquals(1, q.getCount());
90 |
91 | int key = q.getColumnIndex(RemoteContract.COLUMN_KEY);
92 | int type = q.getColumnIndex(RemoteContract.COLUMN_TYPE);
93 | int value = q.getColumnIndex(RemoteContract.COLUMN_VALUE);
94 |
95 | q.moveToFirst();
96 | Assert.assertEquals("string", q.getString(key));
97 | Assert.assertEquals(RemoteContract.TYPE_STRING, q.getInt(type));
98 | Assert.assertEquals("foobar", q.getString(value));
99 | }
100 |
101 | @Test
102 | public void testQueryFailPermissionCheck() {
103 | getSharedPreferences()
104 | .edit()
105 | .putString(TestConstants.UNREADABLE_PREF_KEY, "foobar")
106 | .apply();
107 | ContentResolver resolver = getLocalContext().getContentResolver();
108 | try {
109 | resolver.query(getQueryUri(TestConstants.UNREADABLE_PREF_KEY), null, null, null, null);
110 | Assert.fail();
111 | } catch (SecurityException e) {
112 | // Expected
113 | }
114 | }
115 |
116 | @Test
117 | public void testInsertPref() {
118 | ContentValues values = new ContentValues();
119 | values.put(RemoteContract.COLUMN_KEY, "string");
120 | values.put(RemoteContract.COLUMN_TYPE, RemoteContract.TYPE_STRING);
121 | values.put(RemoteContract.COLUMN_VALUE, "foobar");
122 |
123 | ContentResolver resolver = getLocalContext().getContentResolver();
124 | Uri uri = resolver.insert(getQueryUri(null), values);
125 | Assert.assertEquals(getQueryUri("string"), uri);
126 |
127 | SharedPreferences prefs = getSharedPreferences();
128 | Assert.assertEquals("foobar", prefs.getString("string", null));
129 | }
130 |
131 | @Test
132 | public void testInsertOverridePref() {
133 | SharedPreferences prefs = getSharedPreferences();
134 | prefs
135 | .edit()
136 | .putString("string", "nyaa")
137 | .putInt("int", 1337)
138 | .apply();
139 |
140 | ContentValues values = new ContentValues();
141 | values.put(RemoteContract.COLUMN_KEY, "string");
142 | values.put(RemoteContract.COLUMN_TYPE, RemoteContract.TYPE_STRING);
143 | values.put(RemoteContract.COLUMN_VALUE, "foobar");
144 |
145 | ContentResolver resolver = getLocalContext().getContentResolver();
146 | Uri uri = resolver.insert(getQueryUri(null), values);
147 | Assert.assertEquals(getQueryUri("string"), uri);
148 |
149 | Assert.assertEquals("foobar", prefs.getString("string", null));
150 | Assert.assertEquals(1337, prefs.getInt("int", 0));
151 | }
152 |
153 | @Test
154 | public void testInsertPrefKeyInUri() {
155 | ContentValues values = new ContentValues();
156 | values.put(RemoteContract.COLUMN_TYPE, RemoteContract.TYPE_STRING);
157 | values.put(RemoteContract.COLUMN_VALUE, "foobar");
158 |
159 | ContentResolver resolver = getLocalContext().getContentResolver();
160 | Uri uri = resolver.insert(getQueryUri("string"), values);
161 | Assert.assertEquals(getQueryUri("string"), uri);
162 |
163 | SharedPreferences prefs = getSharedPreferences();
164 | Assert.assertEquals("foobar", prefs.getString("string", null));
165 | }
166 |
167 | @Test
168 | public void testInsertPrefKeyInUriAndValues() {
169 | ContentValues values = new ContentValues();
170 | values.put(RemoteContract.COLUMN_KEY, "string");
171 | values.put(RemoteContract.COLUMN_TYPE, RemoteContract.TYPE_STRING);
172 | values.put(RemoteContract.COLUMN_VALUE, "foobar");
173 |
174 | ContentResolver resolver = getLocalContext().getContentResolver();
175 | Uri uri = resolver.insert(getQueryUri("string"), values);
176 | Assert.assertEquals(getQueryUri("string"), uri);
177 |
178 | SharedPreferences prefs = getSharedPreferences();
179 | Assert.assertEquals("foobar", prefs.getString("string", null));
180 | }
181 |
182 | @Test
183 | public void testInsertPrefFailKeyInUriAndValuesMismatch() {
184 | ContentValues values = new ContentValues();
185 | values.put(RemoteContract.COLUMN_KEY, "string");
186 | values.put(RemoteContract.COLUMN_TYPE, RemoteContract.TYPE_STRING);
187 | values.put(RemoteContract.COLUMN_VALUE, "foobar");
188 |
189 | ContentResolver resolver = getLocalContext().getContentResolver();
190 | try {
191 | resolver.insert(getQueryUri("string2"), values);
192 | Assert.fail();
193 | } catch (IllegalArgumentException e) {
194 | // Expected
195 | }
196 |
197 | SharedPreferences prefs = getSharedPreferences();
198 | Assert.assertEquals("default", prefs.getString("string", "default"));
199 | }
200 |
201 | @Test
202 | public void testInsertMultiplePrefs() {
203 | ContentValues[] values = new ContentValues[2];
204 | values[0] = new ContentValues();
205 | values[0].put(RemoteContract.COLUMN_KEY, "string");
206 | values[0].put(RemoteContract.COLUMN_TYPE, RemoteContract.TYPE_STRING);
207 | values[0].put(RemoteContract.COLUMN_VALUE, "foobar");
208 |
209 | values[1] = new ContentValues();
210 | values[1].put(RemoteContract.COLUMN_KEY, "int");
211 | values[1].put(RemoteContract.COLUMN_TYPE, RemoteContract.TYPE_INT);
212 | values[1].put(RemoteContract.COLUMN_VALUE, 1337);
213 |
214 | ContentResolver resolver = getLocalContext().getContentResolver();
215 | int ret = resolver.bulkInsert(getQueryUri(null), values);
216 | Assert.assertEquals(2, ret);
217 |
218 | SharedPreferences prefs = getSharedPreferences();
219 | Assert.assertEquals("foobar", prefs.getString("string", null));
220 | Assert.assertEquals(1337, prefs.getInt("int", 0));
221 | }
222 |
223 | @Test
224 | public void testInsertFailPermissionCheck() {
225 | ContentValues[] values = new ContentValues[2];
226 | values[0] = new ContentValues();
227 | values[0].put(RemoteContract.COLUMN_KEY, "string");
228 | values[0].put(RemoteContract.COLUMN_TYPE, RemoteContract.TYPE_STRING);
229 | values[0].put(RemoteContract.COLUMN_VALUE, "foobar");
230 |
231 | values[1] = new ContentValues();
232 | values[1].put(RemoteContract.COLUMN_KEY, TestConstants.UNWRITABLE_PREF_KEY);
233 | values[1].put(RemoteContract.COLUMN_TYPE, RemoteContract.TYPE_INT);
234 | values[1].put(RemoteContract.COLUMN_VALUE, 1337);
235 |
236 | ContentResolver resolver = getLocalContext().getContentResolver();
237 | try {
238 | resolver.bulkInsert(getQueryUri(null), values);
239 | Assert.fail();
240 | } catch (SecurityException e) {
241 | // Expected
242 | }
243 |
244 | SharedPreferences prefs = getSharedPreferences();
245 | Assert.assertEquals("default", prefs.getString("string", "default"));
246 | Assert.assertEquals(0, prefs.getInt(TestConstants.UNWRITABLE_PREF_KEY, 0));
247 | }
248 |
249 | @Test
250 | public void testInsertMultipleFailUriContainingKey() {
251 | ContentValues[] values = new ContentValues[1];
252 | values[0] = new ContentValues();
253 | values[0].put(RemoteContract.COLUMN_KEY, "string");
254 | values[0].put(RemoteContract.COLUMN_TYPE, RemoteContract.TYPE_STRING);
255 | values[0].put(RemoteContract.COLUMN_VALUE, "foobar");
256 |
257 | ContentResolver resolver = getLocalContext().getContentResolver();
258 | try {
259 | resolver.bulkInsert(getQueryUri("key"), values);
260 | Assert.fail();
261 | } catch (IllegalArgumentException e) {
262 | // Expected
263 | }
264 |
265 | SharedPreferences prefs = getSharedPreferences();
266 | Assert.assertEquals("default", prefs.getString("string", "default"));
267 | }
268 |
269 | @Test
270 | public void testDeletePref() {
271 | SharedPreferences prefs = getSharedPreferences();
272 | prefs
273 | .edit()
274 | .putString("string", "nyaa")
275 | .apply();
276 |
277 | ContentResolver resolver = getLocalContext().getContentResolver();
278 | resolver.delete(getQueryUri("string"), null, null);
279 |
280 | Assert.assertEquals("default", prefs.getString("string", "default"));
281 | }
282 |
283 | @Test
284 | public void testDeleteUnwritablePref() {
285 | SharedPreferences prefs = getSharedPreferences();
286 | prefs
287 | .edit()
288 | .putString(TestConstants.UNWRITABLE_PREF_KEY, "nyaa")
289 | .apply();
290 |
291 | ContentResolver resolver = getLocalContext().getContentResolver();
292 | try {
293 | resolver.delete(getQueryUri(TestConstants.UNWRITABLE_PREF_KEY), null, null);
294 | Assert.fail();
295 | } catch (SecurityException e) {
296 | // Expected
297 | }
298 |
299 | Assert.assertEquals("nyaa", prefs.getString(TestConstants.UNWRITABLE_PREF_KEY, "default"));
300 | }
301 |
302 | @Test
303 | public void testReadBoolean() {
304 | getSharedPreferences()
305 | .edit()
306 | .putBoolean("true", true)
307 | .putBoolean("false", false)
308 | .apply();
309 |
310 | ContentResolver resolver = getLocalContext().getContentResolver();
311 | Cursor q = resolver.query(getQueryUri(null), null, null, null, null);
312 | Assert.assertEquals(2, q.getCount());
313 |
314 | int key = q.getColumnIndex(RemoteContract.COLUMN_KEY);
315 | int type = q.getColumnIndex(RemoteContract.COLUMN_TYPE);
316 | int value = q.getColumnIndex(RemoteContract.COLUMN_VALUE);
317 |
318 | while (q.moveToNext()) {
319 | if (q.getString(key).equals("true")) {
320 | Assert.assertEquals(RemoteContract.TYPE_BOOLEAN, q.getInt(type));
321 | Assert.assertEquals(1, q.getInt(value));
322 | } else if (q.getString(key).equals("false")) {
323 | Assert.assertEquals(RemoteContract.TYPE_BOOLEAN, q.getInt(type));
324 | Assert.assertEquals(0, q.getInt(value));
325 | } else {
326 | Assert.fail();
327 | }
328 | }
329 | }
330 |
331 | @Test
332 | public void testReadStringSet() {
333 | HashSet set = new HashSet<>();
334 | set.add("foo");
335 | set.add("bar;");
336 | set.add("baz");
337 | set.add("");
338 |
339 | getSharedPreferences()
340 | .edit()
341 | .putStringSet("pref", set)
342 | .apply();
343 |
344 | ContentResolver resolver = getLocalContext().getContentResolver();
345 | Cursor q = resolver.query(getQueryUri("pref"), null, null, null, null);
346 | Assert.assertEquals(1, q.getCount());
347 |
348 | int key = q.getColumnIndex(RemoteContract.COLUMN_KEY);
349 | int type = q.getColumnIndex(RemoteContract.COLUMN_TYPE);
350 | int value = q.getColumnIndex(RemoteContract.COLUMN_VALUE);
351 |
352 | while (q.moveToNext()) {
353 | if (q.getString(key).equals("pref")) {
354 | Assert.assertEquals(RemoteContract.TYPE_STRING_SET, q.getInt(type));
355 | String serialized = q.getString(value);
356 | Assert.assertEquals(set, RemoteUtils.deserializeStringSet(serialized));
357 | } else {
358 | Assert.fail();
359 | }
360 | }
361 | }
362 |
363 | @Test
364 | public void testInsertStringSet() {
365 | HashSet set = new HashSet<>();
366 | set.add("foo");
367 | set.add("bar;");
368 | set.add("baz");
369 | set.add("");
370 |
371 | ContentValues values = new ContentValues();
372 | values.put(RemoteContract.COLUMN_KEY, "pref");
373 | values.put(RemoteContract.COLUMN_TYPE, RemoteContract.TYPE_STRING_SET);
374 | values.put(RemoteContract.COLUMN_VALUE, RemoteUtils.serializeStringSet(set));
375 |
376 | ContentResolver resolver = getLocalContext().getContentResolver();
377 | Uri uri = resolver.insert(getQueryUri(null), values);
378 | Assert.assertEquals(getQueryUri("pref"), uri);
379 |
380 | Assert.assertEquals(set, getSharedPreferences().getStringSet("pref", null));
381 | }
382 | }
383 |
--------------------------------------------------------------------------------
/testapp/src/androidTest/java/com/crossbowffs/remotepreferences/RemotePreferencesTest.java:
--------------------------------------------------------------------------------
1 | package com.crossbowffs.remotepreferences;
2 |
3 | import android.content.Context;
4 | import android.content.SharedPreferences;
5 | import android.os.Build;
6 | import android.os.Handler;
7 | import android.os.HandlerThread;
8 |
9 | import androidx.test.ext.junit.runners.AndroidJUnit4;
10 | import androidx.test.filters.SdkSuppress;
11 | import androidx.test.platform.app.InstrumentationRegistry;
12 |
13 | import com.crossbowffs.remotepreferences.testapp.TestConstants;
14 | import com.crossbowffs.remotepreferences.testapp.TestPreferenceListener;
15 |
16 | import org.junit.Assert;
17 | import org.junit.Before;
18 | import org.junit.Test;
19 | import org.junit.runner.RunWith;
20 |
21 | import java.util.HashSet;
22 | import java.util.Map;
23 |
24 | @RunWith(AndroidJUnit4.class)
25 | public class RemotePreferencesTest {
26 | private Context getLocalContext() {
27 | return InstrumentationRegistry.getInstrumentation().getContext();
28 | }
29 |
30 | private Context getRemoteContext() {
31 | return InstrumentationRegistry.getInstrumentation().getTargetContext();
32 | }
33 |
34 | private SharedPreferences getSharedPreferences() {
35 | Context context = getRemoteContext();
36 | return context.getSharedPreferences(TestConstants.PREF_FILE, Context.MODE_PRIVATE);
37 | }
38 |
39 | private RemotePreferences getRemotePreferences(boolean strictMode) {
40 | // This is not a typo! We are using the LOCAL context to initialize a REMOTE prefs
41 | // instance. This is the whole point of RemotePreferences!
42 | Context context = getLocalContext();
43 | return new RemotePreferences(context, TestConstants.AUTHORITY, TestConstants.PREF_FILE, strictMode);
44 | }
45 |
46 | private RemotePreferences getDisabledRemotePreferences(boolean strictMode) {
47 | Context context = getLocalContext();
48 | return new RemotePreferences(context, TestConstants.AUTHORITY_DISABLED, TestConstants.PREF_FILE, strictMode);
49 | }
50 |
51 | private RemotePreferences getRemotePreferencesWithHandler(Handler handler, boolean strictMode) {
52 | Context context = getLocalContext();
53 | return new RemotePreferences(context, handler, TestConstants.AUTHORITY, TestConstants.PREF_FILE, strictMode);
54 | }
55 |
56 | @Before
57 | public void resetPreferences() {
58 | getSharedPreferences().edit().clear().commit();
59 | }
60 |
61 | @Test
62 | public void testBasicRead() {
63 | getSharedPreferences()
64 | .edit()
65 | .putString("string", "foobar")
66 | .putInt("int", 0xeceb3026)
67 | .putFloat("float", 3.14f)
68 | .putBoolean("bool", true)
69 | .apply();
70 |
71 | RemotePreferences remotePrefs = getRemotePreferences(true);
72 | Assert.assertEquals("foobar", remotePrefs.getString("string", null));
73 | Assert.assertEquals(0xeceb3026, remotePrefs.getInt("int", 0));
74 | Assert.assertEquals(3.14f, remotePrefs.getFloat("float", 0f), 0.0);
75 | Assert.assertEquals(true, remotePrefs.getBoolean("bool", false));
76 | }
77 |
78 | @Test
79 | public void testBasicWrite() {
80 | getRemotePreferences(true)
81 | .edit()
82 | .putString("string", "foobar")
83 | .putInt("int", 0xeceb3026)
84 | .putFloat("float", 3.14f)
85 | .putBoolean("bool", true)
86 | .apply();
87 |
88 | SharedPreferences sharedPrefs = getSharedPreferences();
89 | Assert.assertEquals("foobar", sharedPrefs.getString("string", null));
90 | Assert.assertEquals(0xeceb3026, sharedPrefs.getInt("int", 0));
91 | Assert.assertEquals(3.14f, sharedPrefs.getFloat("float", 0f), 0.0);
92 | Assert.assertEquals(true, sharedPrefs.getBoolean("bool", false));
93 | }
94 |
95 | @Test
96 | public void testRemove() {
97 | getSharedPreferences()
98 | .edit()
99 | .putString("string", "foobar")
100 | .putInt("int", 0xeceb3026)
101 | .apply();
102 |
103 | RemotePreferences remotePrefs = getRemotePreferences(true);
104 | remotePrefs.edit().remove("string").apply();
105 |
106 | Assert.assertEquals("default", remotePrefs.getString("string", "default"));
107 | Assert.assertEquals(0xeceb3026, remotePrefs.getInt("int", 0));
108 | }
109 |
110 | @Test
111 | public void testClear() {
112 | SharedPreferences sharedPrefs = getSharedPreferences();
113 | getSharedPreferences()
114 | .edit()
115 | .putString("string", "foobar")
116 | .putInt("int", 0xeceb3026)
117 | .apply();
118 |
119 | RemotePreferences remotePrefs = getRemotePreferences(true);
120 | remotePrefs.edit().clear().apply();
121 |
122 | Assert.assertEquals(0, sharedPrefs.getAll().size());
123 | Assert.assertEquals("default", remotePrefs.getString("string", "default"));
124 | Assert.assertEquals(0, remotePrefs.getInt("int", 0));
125 | }
126 |
127 | @Test
128 | public void testGetAll() {
129 | getSharedPreferences()
130 | .edit()
131 | .putString("string", "foobar")
132 | .putInt("int", 0xeceb3026)
133 | .putFloat("float", 3.14f)
134 | .putBoolean("bool", true)
135 | .apply();
136 |
137 | RemotePreferences remotePrefs = getRemotePreferences(true);
138 | Map prefs = remotePrefs.getAll();
139 | Assert.assertEquals("foobar", prefs.get("string"));
140 | Assert.assertEquals(0xeceb3026, prefs.get("int"));
141 | Assert.assertEquals(3.14f, prefs.get("float"));
142 | Assert.assertEquals(true, prefs.get("bool"));
143 | }
144 |
145 | @Test
146 | public void testContains() {
147 | getSharedPreferences()
148 | .edit()
149 | .putString("string", "foobar")
150 | .putInt("int", 0xeceb3026)
151 | .putFloat("float", 3.14f)
152 | .putBoolean("bool", true)
153 | .apply();
154 |
155 | RemotePreferences remotePrefs = getRemotePreferences(true);
156 | Assert.assertTrue(remotePrefs.contains("string"));
157 | Assert.assertTrue(remotePrefs.contains("int"));
158 | Assert.assertFalse(remotePrefs.contains("nonexistent"));
159 | }
160 |
161 | @Test
162 | public void testReadNonexistentPref() {
163 | RemotePreferences remotePrefs = getRemotePreferences(true);
164 | Assert.assertEquals("default", remotePrefs.getString("nonexistent_string", "default"));
165 | Assert.assertEquals(1337, remotePrefs.getInt("nonexistent_int", 1337));
166 | }
167 |
168 | @Test
169 | public void testStringSetRead() {
170 | HashSet set = new HashSet<>();
171 | set.add("Chocola");
172 | set.add("Vanilla");
173 | set.add("Coconut");
174 | set.add("Azuki");
175 | set.add("Maple");
176 | set.add("Cinnamon");
177 |
178 | getSharedPreferences()
179 | .edit()
180 | .putStringSet("pref", set)
181 | .apply();
182 |
183 | RemotePreferences remotePrefs = getRemotePreferences(true);
184 | Assert.assertEquals(set, remotePrefs.getStringSet("pref", null));
185 | }
186 |
187 | @Test
188 | public void testStringSetWrite() {
189 | HashSet set = new HashSet<>();
190 | set.add("Chocola");
191 | set.add("Vanilla");
192 | set.add("Coconut");
193 | set.add("Azuki");
194 | set.add("Maple");
195 | set.add("Cinnamon");
196 |
197 | getRemotePreferences(true)
198 | .edit()
199 | .putStringSet("pref", set)
200 | .apply();
201 |
202 | SharedPreferences sharedPrefs = getSharedPreferences();
203 | Assert.assertEquals(set, sharedPrefs.getStringSet("pref", null));
204 | }
205 |
206 | @Test
207 | public void testEmptyStringSetRead() {
208 | HashSet set = new HashSet<>();
209 |
210 | getSharedPreferences()
211 | .edit()
212 | .putStringSet("pref", set)
213 | .apply();
214 |
215 | RemotePreferences remotePrefs = getRemotePreferences(true);
216 | Assert.assertEquals(set, remotePrefs.getStringSet("pref", null));
217 | }
218 |
219 | @Test
220 | public void testEmptyStringSetWrite() {
221 | HashSet set = new HashSet<>();
222 |
223 | getRemotePreferences(true)
224 | .edit()
225 | .putStringSet("pref", set)
226 | .apply();
227 |
228 | SharedPreferences sharedPrefs = getSharedPreferences();
229 | Assert.assertEquals(set, sharedPrefs.getStringSet("pref", null));
230 | }
231 |
232 | @Test
233 | public void testSetContainingEmptyStringRead() {
234 | HashSet set = new HashSet<>();
235 | set.add("");
236 |
237 | getSharedPreferences()
238 | .edit()
239 | .putStringSet("pref", set)
240 | .apply();
241 |
242 | RemotePreferences remotePrefs = getRemotePreferences(true);
243 | Assert.assertEquals(set, remotePrefs.getStringSet("pref", null));
244 | }
245 |
246 | @Test
247 | public void testSetContainingEmptyStringWrite() {
248 | HashSet set = new HashSet<>();
249 | set.add("");
250 |
251 | getRemotePreferences(true)
252 | .edit()
253 | .putStringSet("pref", set)
254 | .apply();
255 |
256 | SharedPreferences sharedPrefs = getSharedPreferences();
257 | Assert.assertEquals(set, sharedPrefs.getStringSet("pref", null));
258 | }
259 |
260 | @Test
261 | public void testReadStringAsStringSetFail() {
262 | getSharedPreferences()
263 | .edit()
264 | .putString("pref", "foo;bar;")
265 | .apply();
266 |
267 | RemotePreferences remotePrefs = getRemotePreferences(true);
268 | try {
269 | remotePrefs.getStringSet("pref", null);
270 | Assert.fail();
271 | } catch (ClassCastException e) {
272 | // Expected
273 | }
274 | }
275 |
276 | @Test
277 | public void testReadStringSetAsStringFail() {
278 | HashSet set = new HashSet<>();
279 | set.add("foo");
280 | set.add("bar");
281 |
282 | getSharedPreferences()
283 | .edit()
284 | .putStringSet("pref", set)
285 | .apply();
286 |
287 | RemotePreferences remotePrefs = getRemotePreferences(true);
288 | try {
289 | remotePrefs.getString("pref", null);
290 | Assert.fail();
291 | } catch (ClassCastException e) {
292 | // Expected
293 | }
294 | }
295 |
296 | @Test
297 | public void testReadBooleanAsIntFail() {
298 | getSharedPreferences()
299 | .edit()
300 | .putBoolean("pref", true)
301 | .apply();
302 |
303 | RemotePreferences remotePrefs = getRemotePreferences(true);
304 | try {
305 | remotePrefs.getInt("pref", 0);
306 | Assert.fail();
307 | } catch (ClassCastException e) {
308 | // Expected
309 | }
310 | }
311 |
312 | @Test
313 | public void testReadIntAsBooleanFail() {
314 | getSharedPreferences()
315 | .edit()
316 | .putInt("pref", 42)
317 | .apply();
318 |
319 | RemotePreferences remotePrefs = getRemotePreferences(true);
320 | try {
321 | remotePrefs.getBoolean("pref", false);
322 | Assert.fail();
323 | } catch (ClassCastException e) {
324 | // Expected
325 | }
326 | }
327 |
328 | @Test
329 | public void testInvalidAuthorityStrictMode() {
330 | Context context = getLocalContext();
331 | RemotePreferences remotePrefs = new RemotePreferences(context, "foo", "bar", true);
332 | try {
333 | remotePrefs.getString("pref", null);
334 | Assert.fail();
335 | } catch (RemotePreferenceAccessException e) {
336 | // Expected
337 | }
338 | }
339 |
340 | @Test
341 | public void testInvalidAuthorityNonStrictMode() {
342 | Context context = getLocalContext();
343 | RemotePreferences remotePrefs = new RemotePreferences(context, "foo", "bar", false);
344 | Assert.assertEquals("default", remotePrefs.getString("pref", "default"));
345 | }
346 |
347 | @Test
348 | public void testDisabledProviderStrictMode() {
349 | RemotePreferences remotePrefs = getDisabledRemotePreferences(true);
350 | try {
351 | remotePrefs.getString("pref", null);
352 | Assert.fail();
353 | } catch (RemotePreferenceAccessException e) {
354 | // Expected
355 | }
356 | }
357 |
358 | @Test
359 | public void testDisabledProviderNonStrictMode() {
360 | RemotePreferences remotePrefs = getDisabledRemotePreferences(false);
361 | Assert.assertEquals("default", remotePrefs.getString("pref", "default"));
362 | }
363 |
364 | @Test
365 | public void testUnreadablePrefStrictMode() {
366 | RemotePreferences remotePrefs = getRemotePreferences(true);
367 | try {
368 | remotePrefs.getString(TestConstants.UNREADABLE_PREF_KEY, null);
369 | Assert.fail();
370 | } catch (RemotePreferenceAccessException e) {
371 | // Expected
372 | }
373 | }
374 |
375 | @Test
376 | public void testUnreadablePrefNonStrictMode() {
377 | RemotePreferences remotePrefs = getRemotePreferences(false);
378 | Assert.assertEquals("default", remotePrefs.getString(TestConstants.UNREADABLE_PREF_KEY, "default"));
379 | }
380 |
381 | @Test
382 | public void testUnwritablePrefStrictMode() {
383 | RemotePreferences remotePrefs = getRemotePreferences(true);
384 | try {
385 | remotePrefs.edit().putString(TestConstants.UNWRITABLE_PREF_KEY, "foobar").commit();
386 | Assert.fail();
387 | } catch (RemotePreferenceAccessException e) {
388 | // Expected
389 | }
390 | }
391 |
392 | @Test
393 | public void testUnwritablePrefNonStrictMode() {
394 | RemotePreferences remotePrefs = getRemotePreferences(false);
395 | Assert.assertFalse(
396 | remotePrefs
397 | .edit()
398 | .putString(TestConstants.UNWRITABLE_PREF_KEY, "foobar")
399 | .commit()
400 | );
401 | }
402 |
403 | @Test
404 | public void testRemoveUnwritablePrefStrictMode() {
405 | getSharedPreferences()
406 | .edit()
407 | .putString(TestConstants.UNWRITABLE_PREF_KEY, "foobar")
408 | .apply();
409 |
410 | RemotePreferences remotePrefs = getRemotePreferences(true);
411 | try {
412 | remotePrefs.edit().remove(TestConstants.UNWRITABLE_PREF_KEY).commit();
413 | Assert.fail();
414 | } catch (RemotePreferenceAccessException e) {
415 | // Expected
416 | }
417 |
418 | Assert.assertEquals("foobar", remotePrefs.getString(TestConstants.UNWRITABLE_PREF_KEY, "default"));
419 | }
420 |
421 | @Test
422 | public void testRemoveUnwritablePrefNonStrictMode() {
423 | getSharedPreferences()
424 | .edit()
425 | .putString(TestConstants.UNWRITABLE_PREF_KEY, "foobar")
426 | .apply();
427 |
428 | RemotePreferences remotePrefs = getRemotePreferences(false);
429 | Assert.assertFalse(remotePrefs.edit().remove(TestConstants.UNWRITABLE_PREF_KEY).commit());
430 |
431 | Assert.assertEquals("foobar", remotePrefs.getString(TestConstants.UNWRITABLE_PREF_KEY, "default"));
432 | }
433 |
434 | @Test
435 | public void testPreferenceChangeListener() {
436 | HandlerThread ht = new HandlerThread(getClass().getName());
437 | try {
438 | ht.start();
439 | Handler handler = new Handler(ht.getLooper());
440 |
441 | RemotePreferences remotePrefs = getRemotePreferencesWithHandler(handler, true);
442 | TestPreferenceListener listener = new TestPreferenceListener();
443 |
444 | try {
445 | remotePrefs.registerOnSharedPreferenceChangeListener(listener);
446 |
447 | getSharedPreferences()
448 | .edit()
449 | .putInt("foobar", 1337)
450 | .apply();
451 |
452 | Assert.assertTrue(listener.waitForChange(1));
453 | Assert.assertEquals("foobar", listener.getKey());
454 | } finally {
455 | remotePrefs.unregisterOnSharedPreferenceChangeListener(listener);
456 | }
457 | } finally {
458 | ht.quit();
459 | }
460 | }
461 |
462 | @Test
463 | @SdkSuppress(minSdkVersion = Build.VERSION_CODES.R)
464 | public void testPreferenceChangeListenerClear() {
465 | HandlerThread ht = new HandlerThread(getClass().getName());
466 | try {
467 | ht.start();
468 | Handler handler = new Handler(ht.getLooper());
469 |
470 | RemotePreferences remotePrefs = getRemotePreferencesWithHandler(handler, true);
471 | TestPreferenceListener listener = new TestPreferenceListener();
472 |
473 | try {
474 | remotePrefs.registerOnSharedPreferenceChangeListener(listener);
475 |
476 | getSharedPreferences()
477 | .edit()
478 | .clear()
479 | .apply();
480 |
481 | Assert.assertTrue(listener.waitForChange(1));
482 | Assert.assertNull(listener.getKey());
483 | } finally {
484 | remotePrefs.unregisterOnSharedPreferenceChangeListener(listener);
485 | }
486 | } finally {
487 | ht.quit();
488 | }
489 | }
490 |
491 | @Test
492 | public void testUnregisterPreferenceChangeListener() {
493 | HandlerThread ht = new HandlerThread(getClass().getName());
494 | try {
495 | ht.start();
496 | Handler handler = new Handler(ht.getLooper());
497 |
498 | RemotePreferences remotePrefs = getRemotePreferencesWithHandler(handler, true);
499 | TestPreferenceListener listener = new TestPreferenceListener();
500 |
501 | try {
502 | remotePrefs.registerOnSharedPreferenceChangeListener(listener);
503 | remotePrefs.unregisterOnSharedPreferenceChangeListener(listener);
504 |
505 | getSharedPreferences()
506 | .edit()
507 | .putInt("foobar", 1337)
508 | .apply();
509 |
510 | Assert.assertFalse(listener.waitForChange(1));
511 | } finally {
512 | remotePrefs.unregisterOnSharedPreferenceChangeListener(listener);
513 | }
514 | } finally {
515 | ht.quit();
516 | }
517 | }
518 | }
519 |
--------------------------------------------------------------------------------
/testapp/src/androidTest/java/com/crossbowffs/remotepreferences/RemoteUtilsTest.java:
--------------------------------------------------------------------------------
1 | package com.crossbowffs.remotepreferences;
2 |
3 | import androidx.test.ext.junit.runners.AndroidJUnit4;
4 |
5 | import org.junit.Assert;
6 | import org.junit.Test;
7 | import org.junit.runner.RunWith;
8 |
9 | import java.util.HashSet;
10 | import java.util.LinkedHashSet;
11 | import java.util.Set;
12 |
13 | @RunWith(AndroidJUnit4.class)
14 | public class RemoteUtilsTest {
15 | @Test
16 | public void testSerializeStringSet() {
17 | Set set = new LinkedHashSet();
18 | set.add("foo");
19 | set.add("bar;");
20 | set.add("baz");
21 | set.add("");
22 |
23 | String serialized = RemoteUtils.serializeStringSet(set);
24 | Assert.assertEquals("foo;bar\\;;baz;;", serialized);
25 | }
26 |
27 | @Test
28 | public void testDeserializeStringSet() {
29 | Set set = new LinkedHashSet();
30 | set.add("foo");
31 | set.add("bar;");
32 | set.add("baz");
33 | set.add("");
34 |
35 | String serialized = RemoteUtils.serializeStringSet(set);
36 | Set deserialized = RemoteUtils.deserializeStringSet(serialized);
37 | Assert.assertEquals(set, deserialized);
38 | }
39 |
40 | @Test
41 | public void testSerializeEmptyStringSet() {
42 | Assert.assertEquals("", RemoteUtils.serializeStringSet(new HashSet()));
43 | }
44 |
45 | @Test
46 | public void testDeserializeEmptyStringSet() {
47 | Assert.assertEquals(new HashSet(), RemoteUtils.deserializeStringSet(""));
48 | }
49 |
50 | @Test
51 | public void testDeserializeInvalidStringSet() {
52 | try {
53 | RemoteUtils.deserializeStringSet("foo;bar");
54 | Assert.fail();
55 | } catch (IllegalArgumentException e) {
56 | // Expected
57 | }
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/testapp/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
3 |
4 |
8 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/testapp/src/main/java/com/crossbowffs/remotepreferences/testapp/TestConstants.java:
--------------------------------------------------------------------------------
1 | package com.crossbowffs.remotepreferences.testapp;
2 |
3 | public final class TestConstants {
4 | private TestConstants() {}
5 |
6 | public static final String AUTHORITY = BuildConfig.APPLICATION_ID + ".preferences";
7 | public static final String AUTHORITY_DISABLED = BuildConfig.APPLICATION_ID + ".preferences.disabled";
8 | public static final String PREF_FILE = "main_prefs";
9 | public static final String UNREADABLE_PREF_KEY = "cannot_read_me";
10 | public static final String UNWRITABLE_PREF_KEY = "cannot_write_me";
11 | }
12 |
--------------------------------------------------------------------------------
/testapp/src/main/java/com/crossbowffs/remotepreferences/testapp/TestPreferenceListener.java:
--------------------------------------------------------------------------------
1 | package com.crossbowffs.remotepreferences.testapp;
2 |
3 | import android.content.SharedPreferences;
4 |
5 | import java.util.concurrent.CountDownLatch;
6 | import java.util.concurrent.TimeUnit;
7 |
8 | public class TestPreferenceListener implements SharedPreferences.OnSharedPreferenceChangeListener {
9 | private boolean mIsCalled;
10 | private String mKey;
11 | private final CountDownLatch mLatch;
12 |
13 | public TestPreferenceListener() {
14 | mIsCalled = false;
15 | mKey = null;
16 | mLatch = new CountDownLatch(1);
17 | }
18 |
19 | public boolean isCalled() {
20 | return mIsCalled;
21 | }
22 |
23 | public String getKey() {
24 | if (!mIsCalled) {
25 | throw new IllegalStateException("Listener was not called");
26 | }
27 | return mKey;
28 | }
29 |
30 | public boolean waitForChange(long seconds) {
31 | try {
32 | return mLatch.await(seconds, TimeUnit.SECONDS);
33 | } catch (InterruptedException e) {
34 | throw new IllegalStateException("Listener wait was interrupted");
35 | }
36 | }
37 |
38 | @Override
39 | public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) {
40 | mIsCalled = true;
41 | mKey = key;
42 | mLatch.countDown();
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/testapp/src/main/java/com/crossbowffs/remotepreferences/testapp/TestPreferenceProvider.java:
--------------------------------------------------------------------------------
1 | package com.crossbowffs.remotepreferences.testapp;
2 |
3 | import com.crossbowffs.remotepreferences.RemotePreferenceProvider;
4 |
5 | public class TestPreferenceProvider extends RemotePreferenceProvider {
6 | public TestPreferenceProvider() {
7 | super(TestConstants.AUTHORITY, new String[] {TestConstants.PREF_FILE});
8 | }
9 |
10 | @Override
11 | protected boolean checkAccess(String prefName, String prefKey, boolean write) {
12 | if (prefKey.equals(TestConstants.UNREADABLE_PREF_KEY) && !write) return false;
13 | if (prefKey.equals(TestConstants.UNWRITABLE_PREF_KEY) && write) return false;
14 | return true;
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/testapp/src/main/java/com/crossbowffs/remotepreferences/testapp/TestPreferenceProviderDisabled.java:
--------------------------------------------------------------------------------
1 | package com.crossbowffs.remotepreferences.testapp;
2 |
3 | import com.crossbowffs.remotepreferences.RemotePreferenceProvider;
4 |
5 | public class TestPreferenceProviderDisabled extends RemotePreferenceProvider {
6 | public TestPreferenceProviderDisabled() {
7 | super(TestConstants.AUTHORITY_DISABLED, new String[] {TestConstants.PREF_FILE});
8 | }
9 | }
10 |
--------------------------------------------------------------------------------