Repository: apsun/RemotePreferences Branch: master Commit: c3e3e59d0b30 Files: 29 Total size: 116.0 KB Directory structure: gitextract_286r2t3v/ ├── .gitignore ├── LICENSE.txt ├── README.md ├── build.gradle.kts ├── gradle/ │ └── wrapper/ │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradle.properties ├── gradlew ├── gradlew.bat ├── library/ │ ├── build.gradle.kts │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ └── java/ │ └── com/ │ └── crossbowffs/ │ └── remotepreferences/ │ ├── RemoteContract.java │ ├── RemotePreferenceAccessException.java │ ├── RemotePreferenceFile.java │ ├── RemotePreferencePath.java │ ├── RemotePreferenceProvider.java │ ├── RemotePreferenceUriParser.java │ ├── RemotePreferences.java │ └── RemoteUtils.java ├── settings.gradle.kts └── testapp/ ├── build.gradle.kts └── src/ ├── androidTest/ │ └── java/ │ └── com/ │ └── crossbowffs/ │ └── remotepreferences/ │ ├── RemotePreferenceProviderTest.java │ ├── RemotePreferencesTest.java │ └── RemoteUtilsTest.java └── main/ ├── AndroidManifest.xml └── java/ └── com/ └── crossbowffs/ └── remotepreferences/ └── testapp/ ├── TestConstants.java ├── TestPreferenceListener.java ├── TestPreferenceProvider.java └── TestPreferenceProviderDisabled.java ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ # Gradle files .gradle/ build/ # Local configuration file (sdk path, etc) local.properties # Log/OS Files *.log # Android Studio generated files and folders captures/ .externalNativeBuild/ .cxx/ *.apk output.json # IntelliJ *.iml .idea/ misc.xml deploymentTargetDropDown.xml render.experimental.xml # Keystore files *.jks *.keystore # Google Services (e.g. APIs or Firebase) google-services.json # Android Profiling *.hprof ================================================ FILE: LICENSE.txt ================================================ The MIT License (MIT) Copyright (c) 2016 Andrew Sun (@crossbowffs) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # RemotePreferences A drop-in solution for inter-app access to `SharedPreferences`. ## Installation 1\. Add the dependency to your `build.gradle` file: ``` repositories { mavenCentral() } dependencies { implementation 'com.crossbowffs.remotepreferences:remotepreferences:0.8' } ``` 2\. Subclass `RemotePreferenceProvider` and implement a 0-argument constructor which calls the super constructor with an authority (e.g. `"com.example.app.preferences"`) and an array of preference files to expose: ```Java public class MyPreferenceProvider extends RemotePreferenceProvider { public MyPreferenceProvider() { super("com.example.app.preferences", new String[] {"main_prefs"}); } } ``` 3\. Add the corresponding entry to `AndroidManifest.xml`, with `android:authorities` equal to the authority you picked in the last step, and `android:exported` set to `true`: ```XML ``` 4\. You're all set! To access your preferences, create a new instance of `RemotePreferences` with the same authority and the name of the preference file: ```Java SharedPreferences prefs = new RemotePreferences(context, "com.example.app.preferences", "main_prefs"); int value = prefs.getInt("my_int_pref", 0); ``` **WARNING**: **DO NOT** use `RemotePreferences` from within `IXposedHookZygoteInit.initZygote`, since app providers have not been initialized at this point. Instead, defer preference loading to `IXposedHookLoadPackage.handleLoadPackage`. Note that you should still use `context.getSharedPreferences("main_prefs", MODE_PRIVATE)` if your code is executing within the app that owns the preferences. Only use `RemotePreferences` when accessing preferences from the context of another app. Also note that your preference keys cannot be `null` or `""` (empty string). ## Security By default, all preferences have global read/write access. If this is what you want, then no additional configuration is required. However, chances are you'll want to prevent 3rd party apps from reading or writing your preferences. There are two ways to accomplish this: 1. Use the Android permissions system built into `ContentProvider` 2. Override the `checkAccess` method in `RemotePreferenceProvider` Option 1 is the simplest to implement - just add `android:readPermission` and/or `android:writePermission` to your preference provider in `AndroidManifest.xml`. Unfortunately, this does not work very well if you are hooking apps that you do not control (e.g. Xposed), since you cannot modify their permissions. Option 2 requires a bit of code, but is extremely powerful since you can control exactly which preferences can be accessed. To do this, override the `checkAccess` method in your preference provider class: ```Java @Override protected boolean checkAccess(String prefFileName, String prefKey, boolean write) { // Only allow read access if (write) { return false; } // Only allow access to certain preference keys if (!"my_pref_key".equals(prefKey)) { return false; } // Only allow access from certain apps if (!"com.example.otherapp".equals(getCallingPackage())) { return false; } return true; } ``` Warning: when checking an operation such as `getAll()` or `clear()`, `prefKey` will be an empty string. If you are blacklisting certain keys, make sure to also blacklist the `""` key as well! ## Device encrypted preferences By default, devices with Android N+ come with file-based encryption, which prevents RemotePreferences from accessing them before the first unlock after reboot. If preferences need to be accessed before the first unlock, the following modifications are needed. 1\. Modify the provider constructor to mark the preference file as device protected: ```Java public class MyPreferenceProvider extends RemotePreferenceProvider { public MyPreferenceProvider() { super("com.example.app.preferences", new RemotePreferenceFile[] { new RemotePreferenceFile("main_prefs", /* isDeviceProtected */ true) }); } } ``` This will cause the provider to use `context.createDeviceProtectedStorageContext()` to access the preferences. 2\. Add support for direct boot in your manifest: ```XML ``` 3\. Update your app to access shared preferences from device protected storage. If you are using `PreferenceManager`, call `setStorageDeviceProtected()`. If you are using `SharedPreferences`, use `createDeviceProtectedStorageContext()` to create the preferences. For example: ```Java Context prefContext = context.createDeviceProtectedStorageContext(); SharedPreferences prefs = prefContext.getSharedPreferences("main_prefs", MODE_PRIVATE); ``` ## Strict mode To maintain API compatibility with `SharedPreferences`, by default any errors encountered while accessing the preference provider will be ignored, resulting in default values being returned from the getter methods and `apply()` silently failing (we advise using `commit()` and checking the return value, at least). This can be caused by bugs in your code, or the user disabling your app/provider component. To detect and handle this scenario, you may opt-in to *strict mode* by passing an extra parameter to the `RemotePreferences` constructor: ```Java SharedPreferences prefs = new RemotePreferences(context, authority, prefFileName, true); ``` Now, if the preference provider cannot be accessed, a `RemotePreferenceAccessException` will be thrown. You can handle this by wrapping your preference accesses in a try-catch block: ```Java try { int value = prefs.getInt("my_int_pref", 0); prefs.edit().putInt("my_int_pref", value + 1).apply(); } catch (RemotePreferenceAccessException e) { // Handle the error } ``` ## Why would I need this? This library was developed to simplify Xposed module preference access. `XSharedPreferences` [has been known to silently fail on some devices](https://github.com/rovo89/XposedBridge/issues/74), and does not support remote write access or value changed listeners. Thus, RemotePreferences was born. Of course, feel free to use this library anywhere you like; it's not limited to Xposed at all! :-) ## How does it work? To achieve true inter-process `SharedPreferences` access, all requests are proxied through a `ContentProvider`. Preference change callbacks are implemented using `ContentObserver`. This solution does **not** use `MODE_WORLD_WRITEABLE` (which was deprecated in Android 4.2) or any other file permission hacks. ## Running tests Connect your Android device and run: ``` ./gradlew :testapp:connectedAndroidTest ``` ## License Distributed under the [MIT License](http://opensource.org/licenses/MIT). ## Changelog 0.8 - RemotePreferences is now hosted on `mavenCentral()` - Fixed `onSharedPreferenceChanged` getting the wrong `key` when calling `clear()` 0.7 - Added support for preferences located in device protected storage (thanks to Rijul-A) 0.6 - Improved error checking - Fixed case where strict mode was not applying when editing multiple preferences - Added more documentation for library internals - Updated project to modern Android Studio layout 0.5 - Ensure edits are atomic - either all or no edits succeed when committing - Minor performance improvement when adding/removing multiple keys 0.4 - Fixed `IllegalArgumentException` being thrown instead of `RemotePreferenceAccessException` 0.3 - Values can now be `null` again - Improved error checking if you are using the ContentProvider interface directly 0.2 - Fixed catastrophic security bug allowing anyone to write to preferences - Added strict mode to distinguish between "cannot access provider" vs. "key doesn't exist" - Keys can no longer be `null` or `""`, values can no longer be `null` 0.1 - Initial release. ================================================ FILE: build.gradle.kts ================================================ buildscript { repositories { google() mavenCentral() } dependencies { classpath("com.android.tools.build:gradle:8.1.2") } } allprojects { repositories { google() mavenCentral() } } ================================================ FILE: gradle/wrapper/gradle-wrapper.properties ================================================ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip networkTimeout=10000 zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists ================================================ FILE: gradle.properties ================================================ android.useAndroidX=true android.defaults.buildfeatures.buildconfig=true ================================================ FILE: gradlew ================================================ #!/bin/sh # # Copyright © 2015-2021 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # https://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # ############################################################################## # # Gradle start up script for POSIX generated by Gradle. # # Important for running: # # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is # noncompliant, but you have some other compliant shell such as ksh or # bash, then to run this script, type that shell name before the whole # command line, like: # # ksh Gradle # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: # * functions; # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», # «${var#prefix}», «${var%suffix}», and «$( cmd )»; # * compound commands having a testable exit status, especially «case»; # * various built-in commands including «command», «set», and «ulimit». # # Important for patching: # # (2) This script targets any POSIX shell, so it avoids extensions provided # by Bash, Ksh, etc; in particular arrays are avoided. # # The "traditional" practice of packing multiple parameters into a # space-separated string is a well documented source of bugs and security # problems, so this is (mostly) avoided, by progressively accumulating # options in "$@", and eventually passing that to Java. # # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; # see the in-line comments for details. # # There are tweaks for specific operating systems such as AIX, CygWin, # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template # https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. # ############################################################################## # Attempt to set APP_HOME # Resolve links: $0 may be a link app_path=$0 # Need this for daisy-chained symlinks. while APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path [ -h "$app_path" ] do ls=$( ls -ld "$app_path" ) link=${ls#*' -> '} case $link in #( /*) app_path=$link ;; #( *) app_path=$APP_HOME$link ;; esac done # This is normally unused # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum warn () { echo "$*" } >&2 die () { echo echo "$*" echo exit 1 } >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false case "$( uname )" in #( CYGWIN* ) cygwin=true ;; #( Darwin* ) darwin=true ;; #( MSYS* | MINGW* ) msys=true ;; #( NONSTOP* ) nonstop=true ;; esac CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables JAVACMD=$JAVA_HOME/jre/sh/java else JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else JAVACMD=java which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi # Increase the maximum file descriptors if we can. if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. # shellcheck disable=SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac case $MAX_FD in #( '' | soft) :;; #( *) # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. # shellcheck disable=SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac fi # Collect all arguments for the java command, stacking in reverse order: # * args from the command line # * the main class name # * -classpath # * -D...appname settings # * --module-path (only if needed) # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) # Now convert the arguments - kludge to limit ourselves to /bin/sh for arg do if case $arg in #( -*) false ;; # don't mess with options #( /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath [ -e "$t" ] ;; #( *) false ;; esac then arg=$( cygpath --path --ignore --mixed "$arg" ) fi # Roll the args list around exactly as many times as the number of # args, so each arg winds up back in the position where it started, but # possibly modified. # # NB: a `for` loop captures its iteration list before it begins, so # changing the positional parameters here affects neither the number of # iterations, nor the values presented in `arg`. shift # remove old arg set -- "$@" "$arg" # push replacement arg done fi # Collect all arguments for the java command; # * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of # shell script including quotes and variable substitutions, so put them in # double quotes to make sure that they get re-expanded; and # * put everything else in single quotes, so that it's not re-expanded. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ -classpath "$CLASSPATH" \ org.gradle.wrapper.GradleWrapperMain \ "$@" # Stop when "xargs" is not available. if ! command -v xargs >/dev/null 2>&1 then die "xargs is not available" fi # Use "xargs" to parse quoted args. # # With -n1 it outputs one arg per line, with the quotes and backslashes removed. # # In Bash we could simply go: # # readarray ARGS < <( xargs -n1 <<<"$var" ) && # set -- "${ARGS[@]}" "$@" # # but POSIX shell has neither arrays nor command substitution, so instead we # post-process each arg (as a line of input to sed) to backslash-escape any # character that might be a shell metacharacter, then use eval to reverse # that process (while maintaining the separation between arguments), and wrap # the whole thing up as a single "set" statement. # # This will of course break if any of these variables contains a newline or # an unmatched quote. # eval "set -- $( printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | xargs -n1 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | tr '\n' ' ' )" '"$@"' exec "$JAVACMD" "$@" ================================================ FILE: gradlew.bat ================================================ @rem @rem Copyright 2015 the original author or authors. @rem @rem Licensed under the Apache License, Version 2.0 (the "License"); @rem you may not use this file except in compliance with the License. @rem You may obtain a copy of the License at @rem @rem https://www.apache.org/licenses/LICENSE-2.0 @rem @rem Unless required by applicable law or agreed to in writing, software @rem distributed under the License is distributed on an "AS IS" BASIS, @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem @if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @rem @rem ########################################################################## @rem Set local scope for the variables with windows NT shell if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 if "%DIRNAME%"=="" set DIRNAME=. @rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @rem Resolve any "." and ".." in APP_HOME to make it shorter. for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" @rem Find java.exe if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if %ERRORLEVEL% equ 0 goto execute echo. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. echo. echo Please set the JAVA_HOME variable in your environment to match the echo location of your Java installation. goto fail :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute echo. echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% echo. echo Please set the JAVA_HOME variable in your environment to match the echo location of your Java installation. goto fail :execute @rem Setup the command line set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar @rem Execute Gradle "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* :end @rem End local scope for the variables with windows NT shell if %ERRORLEVEL% equ 0 goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! set EXIT_CODE=%ERRORLEVEL% if %EXIT_CODE% equ 0 set EXIT_CODE=1 if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal :omega ================================================ FILE: library/build.gradle.kts ================================================ plugins { id("com.android.library") id("maven-publish") id("signing") } android { namespace = "com.crossbowffs.remotepreferences" compileSdk = 34 defaultConfig { minSdk = 1 } publishing { singleVariant("release") { withSourcesJar() withJavadocJar() } } } publishing { publications { afterEvaluate { create("release") { from(components["release"]) groupId = "com.crossbowffs.remotepreferences" artifactId = "remotepreferences" version = "0.8" pom { packaging = "aar" name.set("RemotePreferences") description.set("A drop-in solution for inter-app access to SharedPreferences on Android.") url.set("https://github.com/apsun/RemotePreferences") licenses { license { name.set("MIT") url.set("https://opensource.org/licenses/MIT") } } developers { developer { name.set("Andrew Sun") email.set("andrew@crossbowffs.com") } } scm { url.set(pom.url.get()) connection.set("scm:git:${url.get()}.git") developerConnection.set("scm:git:${url.get()}.git") } } } } } repositories { maven { name = "OSSRH" url = uri("https://oss.sonatype.org/service/local/staging/deploy/maven2") credentials { username = project.findProperty("ossrhUsername") as String? password = project.findProperty("ossrhPassword") as String? } } } } signing { useGpgCmd() afterEvaluate { sign(publishing.publications["release"]) } } ================================================ FILE: library/src/main/AndroidManifest.xml ================================================ ================================================ FILE: library/src/main/java/com/crossbowffs/remotepreferences/RemoteContract.java ================================================ package com.crossbowffs.remotepreferences; /** * Constants used for communicating with the preference provider. */ /* package */ final class RemoteContract { public static final String COLUMN_KEY = "key"; public static final String COLUMN_TYPE = "type"; public static final String COLUMN_VALUE = "value"; public static final String[] COLUMN_ALL = { RemoteContract.COLUMN_KEY, RemoteContract.COLUMN_TYPE, RemoteContract.COLUMN_VALUE }; public static final int TYPE_NULL = 0; public static final int TYPE_STRING = 1; public static final int TYPE_STRING_SET = 2; public static final int TYPE_INT = 3; public static final int TYPE_LONG = 4; public static final int TYPE_FLOAT = 5; public static final int TYPE_BOOLEAN = 6; private RemoteContract() {} } ================================================ FILE: library/src/main/java/com/crossbowffs/remotepreferences/RemotePreferenceAccessException.java ================================================ package com.crossbowffs.remotepreferences; /** * Thrown if the preference provider could not be accessed. * This is commonly thrown under these conditions: *
    *
  • Preference provider component is disabled
  • *
  • Preference provider denied access via {@link RemotePreferenceProvider#checkAccess(String, String, boolean)}
  • *
  • Insufficient permissions to access provider (via {@code AndroidManifest.xml})
  • *
  • Incorrect provider authority/file name passed to constructor
  • *
*/ public class RemotePreferenceAccessException extends RuntimeException { public RemotePreferenceAccessException() { } public RemotePreferenceAccessException(String detailMessage) { super(detailMessage); } public RemotePreferenceAccessException(String detailMessage, Throwable throwable) { super(detailMessage, throwable); } public RemotePreferenceAccessException(Throwable throwable) { super(throwable); } } ================================================ FILE: library/src/main/java/com/crossbowffs/remotepreferences/RemotePreferenceFile.java ================================================ package com.crossbowffs.remotepreferences; /** * Represents a single preference file and the information needed to * access that preference file. */ public class RemotePreferenceFile { private final String mFileName; private final boolean mIsDeviceProtected; /** * Initializes the preference file information. If you are targeting Android * N or above and the preference needs to be accessed before the first unlock, * set {@code isDeviceProtected} to {@code true}. * * @param fileName Name of the preference file. * @param isDeviceProtected {@code true} if the preference file is device protected, * {@code false} if it is credential protected. */ public RemotePreferenceFile(String fileName, boolean isDeviceProtected) { mFileName = fileName; mIsDeviceProtected = isDeviceProtected; } /** * Initializes the preference file information. Assumes the preferences are * located in credential protected storage. * * @param fileName Name of the preference file. */ public RemotePreferenceFile(String fileName) { this(fileName, false); } /** * Returns the name of the preference file. * * @return The name of the preference file. */ public String getFileName() { return mFileName; } /** * Returns whether the preferences are located in device protected storage. * * @return {@code true} if the preference file is device protected, * {@code false} if it is credential protected. */ public boolean isDeviceProtected() { return mIsDeviceProtected; } /** * Converts an array of preference file names to {@link RemotePreferenceFile} * objects. Assumes all preference files are NOT in device protected storage. * * @param prefFileNames The names of the preference files to expose. * @return An array of {@link RemotePreferenceFile} objects. */ public static RemotePreferenceFile[] fromFileNames(String[] prefFileNames) { RemotePreferenceFile[] prefFiles = new RemotePreferenceFile[prefFileNames.length]; for (int i = 0; i < prefFileNames.length; i++) { prefFiles[i] = new RemotePreferenceFile(prefFileNames[i]); } return prefFiles; } } ================================================ FILE: library/src/main/java/com/crossbowffs/remotepreferences/RemotePreferencePath.java ================================================ package com.crossbowffs.remotepreferences; /** * A path consists of a preference file name and optionally a key within * the preference file. The key will be set for operations that involve * a single preference (e.g. {@code getInt}), and {@code null} for operations * on an entire preference file (e.g. {@code getAll}). */ /* package */ class RemotePreferencePath { public final String fileName; public final String key; public RemotePreferencePath(String prefFileName, String prefKey) { this.fileName = prefFileName; this.key = prefKey; } public RemotePreferencePath withKey(String prefKey) { if (this.key != null) { throw new IllegalArgumentException("Path already has a key"); } return new RemotePreferencePath(this.fileName, prefKey); } @Override public String toString() { String ret = "file:" + this.fileName; if (this.key != null) { ret += "/key:" + this.key; } return ret; } } ================================================ FILE: library/src/main/java/com/crossbowffs/remotepreferences/RemotePreferenceProvider.java ================================================ package com.crossbowffs.remotepreferences; import android.content.ContentProvider; import android.content.ContentResolver; import android.content.ContentValues; import android.content.Context; import android.content.SharedPreferences; import android.database.ContentObserver; import android.database.Cursor; import android.database.MatrixCursor; import android.net.Uri; import android.os.Build; import java.util.HashMap; import java.util.Map; /** *

* Exposes {@link SharedPreferences} to other apps running on the device. *

* *

* You must extend this class and declare a 0-argument constructor which * calls the super constructor with the appropriate authority and * preference file name parameters. Remember to add your provider to * your {@code AndroidManifest.xml} file and set the {@code android:exported} * property to true. *

* *

* For granular access control, override {@link #checkAccess(String, String, boolean)} * and return {@code false} to deny the operation. *

* *

* To access the data from a remote process, use {@link RemotePreferences} * initialized with the same authority and the desired preference file name. * You may also manually query the provider; here are some example queries * and their equivalent {@link SharedPreferences} API calls: *

* *
 * query(uri = content://authority/foo/bar)
 * = getSharedPreferences("foo").get("bar")
 *
 * query(uri = content://authority/foo)
 * = getSharedPreferences("foo").getAll()
 *
 * insert(uri = content://authority/foo/bar, values = [{type = TYPE_STRING, value = "baz"}])
 * = getSharedPreferences("foo").edit().putString("bar", "baz").commit()
 *
 * insert(uri = content://authority/foo, values = [{key = "bar", type = TYPE_STRING, value = "baz"}])
 * = getSharedPreferences("foo").edit().putString("bar", "baz").commit()
 *
 * delete(uri = content://authority/foo/bar)
 * = getSharedPreferences("foo").edit().remove("bar").commit()
 *
 * delete(uri = content://authority/foo)
 * = getSharedPreferences("foo").edit().clear().commit()
 * 
* *

* Also note that if you are querying string sets, they will be returned * in a serialized form: {@code ["foo;bar", "baz"]} is converted to * {@code "foo\\;bar;baz;"} (note the trailing semicolon). Booleans are * converted into integers: 1 for true, 0 for false. This is only applicable * if you are using raw queries; all of these subtleties are transparently * handled by {@link RemotePreferences}. *

*/ public abstract class RemotePreferenceProvider extends ContentProvider implements SharedPreferences.OnSharedPreferenceChangeListener { private final Uri mBaseUri; private final RemotePreferenceFile[] mPrefFiles; private final Map mPreferences; private final RemotePreferenceUriParser mUriParser; /** * Initializes the remote preference provider with the specified * authority and preference file names. The authority must match the * {@code android:authorities} property defined in your manifest * file. Only the specified preference files will be accessible * through the provider. This constructor assumes all preferences * are located in credential protected storage; if you are using * device protected storage, use * {@link #RemotePreferenceProvider(String, RemotePreferenceFile[])}. * * @param authority The authority of the provider. * @param prefFileNames The names of the preference files to expose. */ public RemotePreferenceProvider(String authority, String[] prefFileNames) { this(authority, RemotePreferenceFile.fromFileNames(prefFileNames)); } /** * Initializes the remote preference provider with the specified * authority and preference files. The authority must match the * {@code android:authorities} property defined in your manifest * file. Only the specified preference files will be accessible * through the provider. * * @param authority The authority of the provider. * @param prefFiles The preference files to expose. */ public RemotePreferenceProvider(String authority, RemotePreferenceFile[] prefFiles) { mBaseUri = Uri.parse("content://" + authority); mPrefFiles = prefFiles; mPreferences = new HashMap(prefFiles.length); mUriParser = new RemotePreferenceUriParser(authority); } /** * Checks whether the specified preference is accessible by callers. * The default implementation returns {@code true} for all accesses. * You may override this method to control which preferences can be * read or written. Note that {@code prefKey} will be {@code ""} when * accessing an entire file, so a whitelist is strongly recommended * over a blacklist (your default case should be {@code return false}, * not {@code return true}). * * @param prefFileName The name of the preference file. * @param prefKey The preference key. This is an empty string when handling the * {@link SharedPreferences#getAll()} and * {@link SharedPreferences.Editor#clear()} operations. * @param write {@code true} for put/remove/clear operations; {@code false} for get operations. * @return {@code true} if the access is allowed; {@code false} otherwise. */ protected boolean checkAccess(String prefFileName, String prefKey, boolean write) { return true; } /** * Called at application startup to register preference change listeners. * * @return Always returns {@code true}. */ @Override public boolean onCreate() { // We register the shared preference listeners whenever the provider // is created. This method is called before almost all other code in // the app, which ensures that we never miss a preference change. for (RemotePreferenceFile file : mPrefFiles) { Context context = getContext(); if (file.isDeviceProtected() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { context = context.createDeviceProtectedStorageContext(); } SharedPreferences prefs = getSharedPreferences(context, file.getFileName()); prefs.registerOnSharedPreferenceChangeListener(this); mPreferences.put(file.getFileName(), prefs); } return true; } /** * Generate {@link SharedPreferences} to store the key-value data. * Override this method to provide a custom implementation of {@link SharedPreferences}. * * @param context The context that should be used to get the preferences object. * @param prefFileName The name of the preference file. * @return An object implementing the {@link SharedPreferences} interface. */ protected SharedPreferences getSharedPreferences(Context context, String prefFileName) { return context.getSharedPreferences(prefFileName, Context.MODE_PRIVATE); } /** * Returns a cursor for the specified preference(s). If {@code uri} * is in the form {@code content://authority/prefFileName/prefKey}, the * cursor will contain a single row containing the queried preference. * If {@code uri} is in the form {@code content://authority/prefFileName}, * the cursor will contain one row for each preference in the specified * file. * * @param uri Specifies the preference file and key (optional) to query. * @param projection Specifies which fields should be returned in the cursor. * @param selection Ignored. * @param selectionArgs Ignored. * @param sortOrder Ignored. * @return A cursor used to access the queried preference data. */ @Override public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) { RemotePreferencePath prefPath = mUriParser.parse(uri); SharedPreferences prefs = getSharedPreferencesOrThrow(prefPath, false); Map prefMap = prefs.getAll(); // If no projection is specified, we return all columns. if (projection == null) { projection = RemoteContract.COLUMN_ALL; } // Fill out the cursor with the preference data. If the caller // didn't ask for a particular preference, we return all of them. MatrixCursor cursor = new MatrixCursor(projection); if (isSingleKey(prefPath.key)) { Object prefValue = prefMap.get(prefPath.key); cursor.addRow(buildRow(projection, prefPath.key, prefValue)); } else { for (Map.Entry entry : prefMap.entrySet()) { String prefKey = entry.getKey(); Object prefValue = entry.getValue(); cursor.addRow(buildRow(projection, prefKey, prefValue)); } } return cursor; } /** * Not used in RemotePreferences. Always returns {@code null}. * * @param uri Ignored. * @return Always returns {@code null}. */ @Override public String getType(Uri uri) { return null; } /** * Writes the value of the specified preference(s). If no key is specified, * {@link RemoteContract#COLUMN_TYPE} must be equal to {@link RemoteContract#TYPE_NULL}, * representing the {@link SharedPreferences.Editor#clear()} operation. * * @param uri Specifies the preference file and key (optional) to write. * @param values Specifies the key (optional), type and value of the preference to write. * @return A URI representing the preference written, or {@code null} on failure. */ @Override public Uri insert(Uri uri, ContentValues values) { if (values == null) { return null; } RemotePreferencePath prefPath = mUriParser.parse(uri); String prefKey = getKeyFromUriOrValues(prefPath, values); SharedPreferences prefs = getSharedPreferencesOrThrow(prefPath, true); SharedPreferences.Editor editor = prefs.edit(); putPreference(editor, prefKey, values); if (editor.commit()) { return getPreferenceUri(prefPath.fileName, prefKey); } else { return null; } } /** * Writes multiple preference values at once. {@code uri} must * be in the form {@code content://authority/prefFileName}. See * {@link #insert(Uri, ContentValues)} for more information. * * @param uri Specifies the preference file to write to. * @param values See {@link #insert(Uri, ContentValues)}. * @return The number of preferences written, or 0 on failure. */ @Override public int bulkInsert(Uri uri, ContentValues[] values) { RemotePreferencePath prefPath = mUriParser.parse(uri); if (isSingleKey(prefPath.key)) { throw new IllegalArgumentException("Cannot bulk insert with single key URI"); } SharedPreferences prefs = getSharedPreferencesByName(prefPath.fileName); SharedPreferences.Editor editor = prefs.edit(); for (ContentValues value : values) { String prefKey = getKeyFromValues(value); checkAccessOrThrow(prefPath.withKey(prefKey), true); putPreference(editor, prefKey, value); } if (editor.commit()) { return values.length; } else { return 0; } } /** * Deletes the specified preference(s). If {@code uri} is in the form * {@code content://authority/prefFileName/prefKey}, this will only delete * the one preference specified in the URI; if {@code uri} is in the form * {@code content://authority/prefFileName}, clears all preferences. * * @param uri Specifies the preference file and key (optional) to delete. * @param selection Ignored. * @param selectionArgs Ignored. * @return 1 if the preferences committed successfully, or 0 on failure. */ @Override public int delete(Uri uri, String selection, String[] selectionArgs) { RemotePreferencePath prefPath = mUriParser.parse(uri); SharedPreferences prefs = getSharedPreferencesOrThrow(prefPath, true); SharedPreferences.Editor editor = prefs.edit(); if (isSingleKey(prefPath.key)) { editor.remove(prefPath.key); } else { editor.clear(); } // There's no reliable method of getting the actual number of // preference values changed, so callers should not rely on this // value. A return value of 1 means success, 0 means failure. if (editor.commit()) { return 1; } else { return 0; } } /** * Updates the value of the specified preference(s). This is a wrapper * around {@link #insert(Uri, ContentValues)} if {@code values} is not * {@code null}, or {@link #delete(Uri, String, String[])} if {@code values} * is {@code null}. * * @param uri Specifies the preference file and key (optional) to update. * @param values {@code null} to delete the preference, * @param selection Ignored. * @param selectionArgs Ignored. * @return 1 if the preferences committed successfully, or 0 on failure. */ @Override public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) { if (values == null) { return delete(uri, selection, selectionArgs); } else { return insert(uri, values) != null ? 1 : 0; } } /** * Listener for preference value changes in the local application. * Re-raises the event through the * {@link ContentResolver#notifyChange(Uri, ContentObserver)} API * to any registered {@link ContentObserver} objects. Note that this * is NOT called for {@link SharedPreferences.Editor#clear()}. * * @param prefs The preference file that changed. * @param prefKey The preference key that changed. */ @Override public void onSharedPreferenceChanged(SharedPreferences prefs, String prefKey) { RemotePreferenceFile prefFile = getSharedPreferencesFile(prefs); Uri uri = getPreferenceUri(prefFile.getFileName(), prefKey); Context context = getContext(); if (prefFile.isDeviceProtected() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { context = context.createDeviceProtectedStorageContext(); } ContentResolver resolver = context.getContentResolver(); resolver.notifyChange(uri, null); } /** * Writes the value of the specified preference(s). If {@code prefKey} * is empty, {@code values} must contain {@link RemoteContract#TYPE_NULL} * for the type, representing the {@link SharedPreferences.Editor#clear()} * operation. * * @param editor The preference file to modify. * @param prefKey The preference key to modify, or {@code null} for the entire file. * @param values The values to write. */ private void putPreference(SharedPreferences.Editor editor, String prefKey, ContentValues values) { // Get the new value type. Note that we manually check // for null, then unbox the Integer so we don't cause a NPE. Integer type = values.getAsInteger(RemoteContract.COLUMN_TYPE); if (type == null) { throw new IllegalArgumentException("Invalid or no preference type specified"); } // deserializeInput makes sure the actual object type matches // the expected type, so we must perform this step before actually // performing any actions. Object rawValue = values.get(RemoteContract.COLUMN_VALUE); Object value = RemoteUtils.deserializeInput(rawValue, type); // If we are writing to the "directory" and the type is null, // then we should clear the preferences. if (!isSingleKey(prefKey)) { if (type == RemoteContract.TYPE_NULL) { editor.clear(); return; } else { throw new IllegalArgumentException("Attempting to insert preference with null or empty key"); } } switch (type) { case RemoteContract.TYPE_NULL: editor.remove(prefKey); break; case RemoteContract.TYPE_STRING: editor.putString(prefKey, (String)value); break; case RemoteContract.TYPE_STRING_SET: if (Build.VERSION.SDK_INT >= 11) { editor.putStringSet(prefKey, RemoteUtils.castStringSet(value)); } else { throw new IllegalArgumentException("String set preferences not supported on API < 11"); } break; case RemoteContract.TYPE_INT: editor.putInt(prefKey, (Integer)value); break; case RemoteContract.TYPE_LONG: editor.putLong(prefKey, (Long)value); break; case RemoteContract.TYPE_FLOAT: editor.putFloat(prefKey, (Float)value); break; case RemoteContract.TYPE_BOOLEAN: editor.putBoolean(prefKey, (Boolean)value); break; default: throw new IllegalArgumentException("Cannot set preference with type " + type); } } /** * Used to project a preference value to the schema requested by the caller. * * @param projection The projection requested by the caller. * @param key The preference key. * @param value The preference value. * @return A row representing the preference using the given schema. */ private Object[] buildRow(String[] projection, String key, Object value) { Object[] row = new Object[projection.length]; for (int i = 0; i < row.length; ++i) { String col = projection[i]; if (RemoteContract.COLUMN_KEY.equals(col)) { row[i] = key; } else if (RemoteContract.COLUMN_TYPE.equals(col)) { row[i] = RemoteUtils.getPreferenceType(value); } else if (RemoteContract.COLUMN_VALUE.equals(col)) { row[i] = RemoteUtils.serializeOutput(value); } else { throw new IllegalArgumentException("Invalid column name: " + col); } } return row; } /** * Returns whether the specified key represents a single preference key * (as opposed to the entire preference file). * * @param prefKey The preference key to check. * @return Whether the key refers to a single preference. */ private static boolean isSingleKey(String prefKey) { return prefKey != null; } /** * Parses the preference key from {@code values}. If the key is not * specified in the values, {@code null} is returned. * * @param values The query values to parse. * @return The parsed key, or {@code null} if no key was found. */ private static String getKeyFromValues(ContentValues values) { String key = values.getAsString(RemoteContract.COLUMN_KEY); if (key != null && key.length() == 0) { key = null; } return key; } /** * Parses the preference key from the specified sources. Since there * are two ways to specify the key (from the URI or from the query values), * the only allowed combinations are: * * uri.key == values.key * uri.key != null and values.key == null = URI key is used * uri.key == null and values.key != null = values key is used * uri.key == null and values.key == null = no key * * If none of these conditions are met, an exception is thrown. * * @param prefPath Parsed URI key from {@code mUriParser.parse(uri)}. * @param values Query values provided by the caller. * @return The parsed key, or {@code null} if the key refers to a preference file. */ private static String getKeyFromUriOrValues(RemotePreferencePath prefPath, ContentValues values) { String uriKey = prefPath.key; String valuesKey = getKeyFromValues(values); if (isSingleKey(uriKey) && isSingleKey(valuesKey)) { // If a key is specified in both the URI and // ContentValues, they must match if (!uriKey.equals(valuesKey)) { throw new IllegalArgumentException("Conflicting keys specified in URI and ContentValues"); } return uriKey; } else if (isSingleKey(uriKey)) { return uriKey; } else if (isSingleKey(valuesKey)) { return valuesKey; } else { return null; } } /** * Checks that the caller has permissions to access the specified preference. * Throws an exception if permission is denied. * * @param prefPath The preference file and key to be accessed. * @param write Whether the operation will modify the preference. */ private void checkAccessOrThrow(RemotePreferencePath prefPath, boolean write) { // For backwards compatibility, checkAccess takes an empty string when // referring to the whole file. String prefKey = prefPath.key; if (!isSingleKey(prefKey)) { prefKey = ""; } if (!checkAccess(prefPath.fileName, prefKey, write)) { throw new SecurityException("Insufficient permissions to access: " + prefPath); } } /** * Returns the {@link SharedPreferences} instance with the specified name. * This is essentially equivalent to {@link Context#getSharedPreferences(String, int)}, * except that it will used the internally cached version, and throws an * exception if the provider was not configured to access that preference file. * * @param prefFileName The name of the preference file to access. * @return The {@link SharedPreferences} instance with the specified file name. */ private SharedPreferences getSharedPreferencesByName(String prefFileName) { SharedPreferences prefs = mPreferences.get(prefFileName); if (prefs == null) { throw new IllegalArgumentException("Unknown preference file name: " + prefFileName); } return prefs; } /** * Returns the file name for a {@link SharedPreferences} instance. * Throws an exception if the provider was not configured to access * the specified preferences. * * @param prefs The shared preferences object. * @return The name of the preference file. */ private String getSharedPreferencesFileName(SharedPreferences prefs) { for (Map.Entry entry : mPreferences.entrySet()) { if (entry.getValue() == prefs) { return entry.getKey(); } } throw new IllegalArgumentException("Unknown preference file"); } /** * Get the corresponding {@link RemotePreferenceFile} object for a * {@link SharedPreferences} instance. Throws an exception if the * provider was not configured to access the specified preferences. * * @param prefs The shared preferences object. * @return The corresponding {@link RemotePreferenceFile} object. */ private RemotePreferenceFile getSharedPreferencesFile(SharedPreferences prefs) { String prefFileName = getSharedPreferencesFileName(prefs); for (RemotePreferenceFile file : mPrefFiles) { if (file.getFileName().equals(prefFileName)) { return file; } } throw new IllegalArgumentException("Unknown preference file"); } /** * Returns the {@link SharedPreferences} instance with the specified name, * checking that the caller has permissions to access the specified key within * that file. If not, an exception will be thrown. * * @param prefPath The preference file and key to be accessed. * @param write Whether the operation will modify the preference. * @return The {@link SharedPreferences} instance with the specified file name. */ private SharedPreferences getSharedPreferencesOrThrow(RemotePreferencePath prefPath, boolean write) { checkAccessOrThrow(prefPath, write); return getSharedPreferencesByName(prefPath.fileName); } /** * Builds a URI for the specified preference file and key that can be used * to later query the same preference. * * @param prefFileName The preference file. * @param prefKey The preference key. * @return A URI representing the specified preference. */ private Uri getPreferenceUri(String prefFileName, String prefKey) { Uri.Builder builder = mBaseUri.buildUpon().appendPath(prefFileName); if (isSingleKey(prefKey)) { builder.appendPath(prefKey); } return builder.build(); } } ================================================ FILE: library/src/main/java/com/crossbowffs/remotepreferences/RemotePreferenceUriParser.java ================================================ package com.crossbowffs.remotepreferences; import android.content.UriMatcher; import android.net.Uri; import java.util.List; /** * Decodes URIs passed between {@link RemotePreferences} and {@link RemotePreferenceProvider}. */ /* package */ class RemotePreferenceUriParser { private static final int PREFERENCES_ID = 1; private static final int PREFERENCE_ID = 2; private final UriMatcher mUriMatcher; public RemotePreferenceUriParser(String authority) { mUriMatcher = new UriMatcher(UriMatcher.NO_MATCH); mUriMatcher.addURI(authority, "*/", PREFERENCES_ID); mUriMatcher.addURI(authority, "*/*", PREFERENCE_ID); } /** * Parses the preference file and key from a query URI. If the key * is not specified, the returned path will contain {@code null} as the key. * * @param uri The URI to parse. * @return A path object containing the preference file name and key. */ public RemotePreferencePath parse(Uri uri) { int match = mUriMatcher.match(uri); if (match != PREFERENCE_ID && match != PREFERENCES_ID) { throw new IllegalArgumentException("Invalid URI: " + uri); } // The URI must fall under one of these patterns: // // content://authority/prefFileName/prefKey // content://authority/prefFileName/ // content://authority/prefFileName // // The match ID will be PREFERENCE_ID under the first case, // and PREFERENCES_ID under the second and third cases // (UriMatcher ignores trailing slashes). List pathSegments = uri.getPathSegments(); String prefFileName = pathSegments.get(0); String prefKey = null; if (match == PREFERENCE_ID) { prefKey = pathSegments.get(1); } return new RemotePreferencePath(prefFileName, prefKey); } } ================================================ FILE: library/src/main/java/com/crossbowffs/remotepreferences/RemotePreferences.java ================================================ package com.crossbowffs.remotepreferences; import android.annotation.TargetApi; import android.content.ContentValues; import android.content.Context; import android.content.SharedPreferences; import android.database.ContentObserver; import android.database.Cursor; import android.net.Uri; import android.os.Build; import android.os.Handler; import java.lang.ref.WeakReference; import java.util.ArrayList; import java.util.HashMap; import java.util.Map; import java.util.Set; import java.util.WeakHashMap; /** *

* Provides a {@link SharedPreferences} compatible API to * {@link RemotePreferenceProvider}. See {@link RemotePreferenceProvider} * for more information. *

* *

* If you are reading preferences from the same context as the * provider, you should not use this class; just access the * {@link SharedPreferences} API as you would normally. *

*/ public class RemotePreferences implements SharedPreferences { private final Context mContext; private final Handler mHandler; private final Uri mBaseUri; private final boolean mStrictMode; private final WeakHashMap mListeners; private final RemotePreferenceUriParser mUriParser; /** * Initializes a new remote preferences object, with strict * mode disabled. * * @param context Used to access the preference provider. * @param authority The authority of the preference provider. * @param prefFileName The name of the preference file to access. */ public RemotePreferences(Context context, String authority, String prefFileName) { this(context, authority, prefFileName, false); } /** * Initializes a new remote preferences object. If {@code strictMode} * is {@code true} and the remote preference provider cannot be accessed, * read/write operations on this object will throw a * {@link RemotePreferenceAccessException}. Otherwise, default values * will be returned. * * @param context Used to access the preference provider. * @param authority The authority of the preference provider. * @param prefFileName The name of the preference file to access. * @param strictMode Whether strict mode is enabled. */ public RemotePreferences(Context context, String authority, String prefFileName, boolean strictMode) { this(context, new Handler(context.getMainLooper()), authority, prefFileName, strictMode); } /** * Initializes a new remote preferences object. If {@code strictMode} * is {@code true} and the remote preference provider cannot be accessed, * read/write operations on this object will throw a * {@link RemotePreferenceAccessException}. Otherwise, default values * will be returned. * * @param context Used to access the preference provider. * @param handler Used to receive preference change events. * @param authority The authority of the preference provider. * @param prefFileName The name of the preference file to access. * @param strictMode Whether strict mode is enabled. */ /* package */ RemotePreferences(Context context, Handler handler, String authority, String prefFileName, boolean strictMode) { checkNotNull("context", context); checkNotNull("handler", handler); checkNotNull("authority", authority); checkNotNull("prefFileName", prefFileName); mContext = context; mHandler = handler; mBaseUri = Uri.parse("content://" + authority).buildUpon().appendPath(prefFileName).build(); mStrictMode = strictMode; mListeners = new WeakHashMap(); mUriParser = new RemotePreferenceUriParser(authority); } @Override public Map getAll() { return queryAll(); } @Override public String getString(String key, String defValue) { return (String)querySingle(key, defValue, RemoteContract.TYPE_STRING); } @Override @TargetApi(11) public Set getStringSet(String key, Set defValues) { if (Build.VERSION.SDK_INT < 11) { throw new UnsupportedOperationException("String sets only supported on API 11 and above"); } return RemoteUtils.castStringSet(querySingle(key, defValues, RemoteContract.TYPE_STRING_SET)); } @Override public int getInt(String key, int defValue) { return (Integer)querySingle(key, defValue, RemoteContract.TYPE_INT); } @Override public long getLong(String key, long defValue) { return (Long)querySingle(key, defValue, RemoteContract.TYPE_LONG); } @Override public float getFloat(String key, float defValue) { return (Float)querySingle(key, defValue, RemoteContract.TYPE_FLOAT); } @Override public boolean getBoolean(String key, boolean defValue) { return (Boolean)querySingle(key, defValue, RemoteContract.TYPE_BOOLEAN); } @Override public boolean contains(String key) { return containsKey(key); } @Override public Editor edit() { return new RemotePreferencesEditor(); } @Override public void registerOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) { checkNotNull("listener", listener); if (mListeners.containsKey(listener)) return; PreferenceContentObserver observer = new PreferenceContentObserver(listener); mListeners.put(listener, observer); mContext.getContentResolver().registerContentObserver(mBaseUri, true, observer); } @Override public void unregisterOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) { checkNotNull("listener", listener); PreferenceContentObserver observer = mListeners.remove(listener); if (observer != null) { mContext.getContentResolver().unregisterContentObserver(observer); } } /** * If {@code object} is {@code null}, throws an exception. * * @param name The name of the object, for use in the exception message. * @param object The object to check. */ private static void checkNotNull(String name, Object object) { if (object == null) { throw new IllegalArgumentException(name + " is null"); } } /** * If {@code key} is {@code null} or {@code ""}, throws an exception. * * @param key The object to check. */ private static void checkKeyNotEmpty(String key) { if (key == null || key.length() == 0) { throw new IllegalArgumentException("Key is null or empty"); } } /** * If strict mode is enabled, wraps and throws the given exception. * Otherwise, does nothing. * * @param e The exception to wrap. */ private void wrapException(Exception e) { if (mStrictMode) { throw new RemotePreferenceAccessException(e); } } /** * Queries the specified URI. If the query fails and strict mode is * enabled, an exception will be thrown; otherwise {@code null} will * be returned. * * @param uri The URI to query. * @param columns The columns to include in the returned cursor. * @return A cursor used to access the queried preference data. */ private Cursor query(Uri uri, String[] columns) { Cursor cursor = null; try { cursor = mContext.getContentResolver().query(uri, columns, null, null, null); } catch (Exception e) { wrapException(e); } if (cursor == null && mStrictMode) { throw new RemotePreferenceAccessException("query() failed or returned null cursor"); } return cursor; } /** * Writes multiple preferences at once to the preference provider. * If the operation fails and strict mode is enabled, an exception * will be thrown; otherwise {@code false} will be returned. * * @param uri The URI to modify. * @param values The values to write. * @return Whether the operation succeeded. */ private boolean bulkInsert(Uri uri, ContentValues[] values) { int count; try { count = mContext.getContentResolver().bulkInsert(uri, values); } catch (Exception e) { wrapException(e); return false; } if (count != values.length && mStrictMode) { throw new RemotePreferenceAccessException("bulkInsert() failed"); } return count == values.length; } /** * Reads a single preference from the preference provider. This may * throw a {@link ClassCastException} even if strict mode is disabled * if the provider returns an incompatible type. If strict mode is * disabled and the preference cannot be read, the default value is returned. * * @param key The preference key to read. * @param defValue The default value, if there is no existing value. * @param expectedType The expected type of the value. * @return The value of the preference, or {@code defValue} if no value exists. */ private Object querySingle(String key, Object defValue, int expectedType) { checkKeyNotEmpty(key); Uri uri = mBaseUri.buildUpon().appendPath(key).build(); String[] columns = {RemoteContract.COLUMN_TYPE, RemoteContract.COLUMN_VALUE}; Cursor cursor = query(uri, columns); try { if (cursor == null || !cursor.moveToFirst()) { return defValue; } int typeCol = cursor.getColumnIndexOrThrow(RemoteContract.COLUMN_TYPE); int type = cursor.getInt(typeCol); if (type == RemoteContract.TYPE_NULL) { return defValue; } else if (type != expectedType) { throw new ClassCastException("Preference type mismatch"); } int valueCol = cursor.getColumnIndexOrThrow(RemoteContract.COLUMN_VALUE); return getValue(cursor, typeCol, valueCol); } finally { if (cursor != null) { cursor.close(); } } } /** * Reads all preferences from the preference provider. If strict * mode is disabled and the preferences cannot be read, an empty * map is returned. * * @return A map containing all preferences. */ private Map queryAll() { Uri uri = mBaseUri.buildUpon().appendPath("").build(); String[] columns = {RemoteContract.COLUMN_KEY, RemoteContract.COLUMN_TYPE, RemoteContract.COLUMN_VALUE}; Cursor cursor = query(uri, columns); try { HashMap map = new HashMap(); if (cursor == null) { return map; } int keyCol = cursor.getColumnIndexOrThrow(RemoteContract.COLUMN_KEY); int typeCol = cursor.getColumnIndexOrThrow(RemoteContract.COLUMN_TYPE); int valueCol = cursor.getColumnIndexOrThrow(RemoteContract.COLUMN_VALUE); while (cursor.moveToNext()) { String key = cursor.getString(keyCol); map.put(key, getValue(cursor, typeCol, valueCol)); } return map; } finally { if (cursor != null) { cursor.close(); } } } /** * Checks whether the preference exists. If strict mode is * disabled and the preferences cannot be read, {@code false} * is returned. * * @param key The key to check existence for. * @return Whether the preference exists. */ private boolean containsKey(String key) { checkKeyNotEmpty(key); Uri uri = mBaseUri.buildUpon().appendPath(key).build(); String[] columns = {RemoteContract.COLUMN_TYPE}; Cursor cursor = query(uri, columns); try { if (cursor == null || !cursor.moveToFirst()) { return false; } int typeCol = cursor.getColumnIndexOrThrow(RemoteContract.COLUMN_TYPE); return cursor.getInt(typeCol) != RemoteContract.TYPE_NULL; } finally { if (cursor != null) { cursor.close(); } } } /** * Extracts a preference value from a cursor. Performs deserialization * of the value if necessary. * * @param cursor The cursor containing the preference value. * @param typeCol The index containing the {@link RemoteContract#COLUMN_TYPE} column. * @param valueCol The index containing the {@link RemoteContract#COLUMN_VALUE} column. * @return The value from the cursor. */ private Object getValue(Cursor cursor, int typeCol, int valueCol) { int expectedType = cursor.getInt(typeCol); switch (expectedType) { case RemoteContract.TYPE_STRING: return cursor.getString(valueCol); case RemoteContract.TYPE_STRING_SET: return RemoteUtils.deserializeStringSet(cursor.getString(valueCol)); case RemoteContract.TYPE_INT: return cursor.getInt(valueCol); case RemoteContract.TYPE_LONG: return cursor.getLong(valueCol); case RemoteContract.TYPE_FLOAT: return cursor.getFloat(valueCol); case RemoteContract.TYPE_BOOLEAN: return cursor.getInt(valueCol) != 0; default: throw new AssertionError("Invalid expected type: " + expectedType); } } /** * Implementation of the {@link SharedPreferences.Editor} interface * for use with RemotePreferences. */ private class RemotePreferencesEditor implements Editor { private final ArrayList mValues = new ArrayList(); /** * Creates a new {@link ContentValues} with the specified key and * type columns pre-filled. The {@link RemoteContract#COLUMN_VALUE} * field is NOT filled in. * * @param key The preference key. * @param type The preference type. * @return The pre-filled values. */ private ContentValues createContentValues(String key, int type) { ContentValues values = new ContentValues(4); values.put(RemoteContract.COLUMN_KEY, key); values.put(RemoteContract.COLUMN_TYPE, type); return values; } /** * Creates an operation to add/set a new preference. Again, the * {@link RemoteContract#COLUMN_VALUE} field is NOT filled in. * This will also add the values to the operation queue. * * @param key The preference key to add. * @param type The preference type to add. * @return The pre-filled values. */ private ContentValues createAddOp(String key, int type) { checkKeyNotEmpty(key); ContentValues values = createContentValues(key, type); mValues.add(values); return values; } /** * Creates an operation to delete a preference. All fields * are pre-filled. This will also add the values to the * operation queue. * * @param key The preference key to delete. * @return The pre-filled values. */ private ContentValues createRemoveOp(String key) { // Note: Remove operations are inserted at the beginning // of the list (this preserves the SharedPreferences behavior // that all removes are performed before any adds) ContentValues values = createContentValues(key, RemoteContract.TYPE_NULL); values.putNull(RemoteContract.COLUMN_VALUE); mValues.add(0, values); return values; } @Override public Editor putString(String key, String value) { createAddOp(key, RemoteContract.TYPE_STRING).put(RemoteContract.COLUMN_VALUE, value); return this; } @Override @TargetApi(11) public Editor putStringSet(String key, Set value) { if (Build.VERSION.SDK_INT < 11) { throw new UnsupportedOperationException("String sets only supported on API 11 and above"); } String serializedSet = RemoteUtils.serializeStringSet(value); createAddOp(key, RemoteContract.TYPE_STRING_SET).put(RemoteContract.COLUMN_VALUE, serializedSet); return this; } @Override public Editor putInt(String key, int value) { createAddOp(key, RemoteContract.TYPE_INT).put(RemoteContract.COLUMN_VALUE, value); return this; } @Override public Editor putLong(String key, long value) { createAddOp(key, RemoteContract.TYPE_LONG).put(RemoteContract.COLUMN_VALUE, value); return this; } @Override public Editor putFloat(String key, float value) { createAddOp(key, RemoteContract.TYPE_FLOAT).put(RemoteContract.COLUMN_VALUE, value); return this; } @Override public Editor putBoolean(String key, boolean value) { createAddOp(key, RemoteContract.TYPE_BOOLEAN).put(RemoteContract.COLUMN_VALUE, value ? 1 : 0); return this; } @Override public Editor remove(String key) { checkKeyNotEmpty(key); createRemoveOp(key); return this; } @Override public Editor clear() { createRemoveOp(""); return this; } @Override public boolean commit() { ContentValues[] values = mValues.toArray(new ContentValues[mValues.size()]); Uri uri = mBaseUri.buildUpon().appendPath("").build(); return bulkInsert(uri, values); } @Override public void apply() { commit(); } } /** * {@link ContentObserver} subclass used to monitor preference changes * in the remote preference provider. When a change is detected, this will notify * the corresponding {@link SharedPreferences.OnSharedPreferenceChangeListener}. */ private class PreferenceContentObserver extends ContentObserver { private final WeakReference mListener; private PreferenceContentObserver(OnSharedPreferenceChangeListener listener) { super(mHandler); mListener = new WeakReference(listener); } @Override public boolean deliverSelfNotifications() { return true; } @Override public void onChange(boolean selfChange, Uri uri) { RemotePreferencePath path = mUriParser.parse(uri); // We use a weak reference to mimic the behavior of SharedPreferences. // The code which registered the listener is responsible for holding a // reference to it. If at any point we find that the listener has been // garbage collected, we unregister the observer. OnSharedPreferenceChangeListener listener = mListener.get(); if (listener == null) { mContext.getContentResolver().unregisterContentObserver(this); } else { listener.onSharedPreferenceChanged(RemotePreferences.this, path.key); } } } } ================================================ FILE: library/src/main/java/com/crossbowffs/remotepreferences/RemoteUtils.java ================================================ package com.crossbowffs.remotepreferences; import java.util.HashSet; import java.util.Set; /** * Common utilities used to serialize and deserialize * preferences between the preference provider and caller. */ /* package */ final class RemoteUtils { private RemoteUtils() {} /** * Casts the parameter to a string set. Useful to avoid the unchecked * warning that would normally come with the cast. The value must * already be a string set; this does not deserialize it. * * @param value The value, as type {@link Object}. * @return The value, as type {@link Set}. */ @SuppressWarnings("unchecked") public static Set castStringSet(Object value) { return (Set)value; } /** * Returns the {@code TYPE_*} constant corresponding to the given * object's type. * * @param value The original object. * @return One of the {@link RemoteContract}{@code .TYPE_*} constants. */ public static int getPreferenceType(Object value) { if (value == null) return RemoteContract.TYPE_NULL; if (value instanceof String) return RemoteContract.TYPE_STRING; if (value instanceof Set) return RemoteContract.TYPE_STRING_SET; if (value instanceof Integer) return RemoteContract.TYPE_INT; if (value instanceof Long) return RemoteContract.TYPE_LONG; if (value instanceof Float) return RemoteContract.TYPE_FLOAT; if (value instanceof Boolean) return RemoteContract.TYPE_BOOLEAN; throw new AssertionError("Unknown preference type: " + value.getClass()); } /** * Serializes the specified object to a format that is safe to use * with {@link android.content.ContentValues}. To recover the original * object, use {@link #deserializeInput(Object, int)}. * * @param value The object to serialize. * @return The serialized object. */ public static Object serializeOutput(Object value) { if (value instanceof Boolean) { return serializeBoolean((Boolean)value); } else if (value instanceof Set) { return serializeStringSet(castStringSet(value)); } else { return value; } } /** * Deserializes an object that was serialized using * {@link #serializeOutput(Object)}. If the expected type does * not match the actual type of the object, a {@link ClassCastException} * will be thrown. * * @param value The object to deserialize. * @param expectedType The expected type of the deserialized object. * @return The deserialized object. */ public static Object deserializeInput(Object value, int expectedType) { if (expectedType == RemoteContract.TYPE_NULL) { if (value != null) { throw new IllegalArgumentException("Expected null, got non-null value"); } else { return null; } } try { switch (expectedType) { case RemoteContract.TYPE_STRING: return (String)value; case RemoteContract.TYPE_STRING_SET: return deserializeStringSet((String)value); case RemoteContract.TYPE_INT: return (Integer)value; case RemoteContract.TYPE_LONG: return (Long)value; case RemoteContract.TYPE_FLOAT: return (Float)value; case RemoteContract.TYPE_BOOLEAN: return deserializeBoolean(value); } } catch (ClassCastException e) { throw new IllegalArgumentException("Expected type " + expectedType + ", got " + value.getClass(), e); } throw new IllegalArgumentException("Unknown type: " + expectedType); } /** * Serializes a {@link Boolean} to a format that is safe to use * with {@link android.content.ContentValues}. * * @param value The {@link Boolean} to serialize. * @return 1 if {@code value} is {@code true}, 0 if {@code value} is {@code false}. */ private static Integer serializeBoolean(Boolean value) { if (value == null) { return null; } else { return value ? 1 : 0; } } /** * Deserializes a {@link Boolean} that was serialized using * {@link #serializeBoolean(Boolean)}. * * @param value The {@link Boolean} to deserialize. * @return {@code true} if {@code value} is 1, {@code false} if {@code value} is 0. */ private static Boolean deserializeBoolean(Object value) { if (value == null) { return null; } else if (value instanceof Boolean) { return (Boolean)value; } else { return (Integer)value != 0; } } /** * Serializes a {@link Set} to a format that is safe to use * with {@link android.content.ContentValues}. * * @param stringSet The {@link Set} to serialize. * @return The serialized string set. */ public static String serializeStringSet(Set stringSet) { if (stringSet == null) { return null; } StringBuilder sb = new StringBuilder(); for (String s : stringSet) { sb.append(s.replace("\\", "\\\\").replace(";", "\\;")); sb.append(';'); } return sb.toString(); } /** * Deserializes a {@link Set} that was serialized using * {@link #serializeStringSet(Set)}. * * @param serializedString The {@link Set} to deserialize. * @return The deserialized string set. */ public static Set deserializeStringSet(String serializedString) { if (serializedString == null) { return null; } HashSet stringSet = new HashSet(); StringBuilder sb = new StringBuilder(); for (int i = 0; i < serializedString.length(); ++i) { char c = serializedString.charAt(i); if (c == '\\') { char next = serializedString.charAt(++i); sb.append(next); } else if (c == ';') { stringSet.add(sb.toString()); sb.delete(0, sb.length()); } else { sb.append(c); } } // We require that the serialized string ends with a ; per element // since that's how we distinguish empty sets from sets containing // an empty string. Assume caller is doing unsafe string joins // instead of using the serializeStringSet API, and fail fast. if (sb.length() != 0) { throw new IllegalArgumentException("Serialized string set contains trailing chars"); } return stringSet; } } ================================================ FILE: settings.gradle.kts ================================================ include(":library") include(":testapp") ================================================ FILE: testapp/build.gradle.kts ================================================ plugins { id("com.android.application") } android { namespace = "com.crossbowffs.remotepreferences.testapp" compileSdk = 34 defaultConfig { minSdk = 14 targetSdk = 34 versionCode = 1 versionName = "1.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } } dependencies { implementation(project(":library")) androidTestImplementation("junit:junit:4.13.2") androidTestImplementation("androidx.test:core:1.5.0") androidTestImplementation("androidx.test:runner:1.5.2") androidTestImplementation("androidx.test:rules:1.5.0") androidTestImplementation("androidx.test.ext:junit:1.1.5") } ================================================ FILE: testapp/src/androidTest/java/com/crossbowffs/remotepreferences/RemotePreferenceProviderTest.java ================================================ package com.crossbowffs.remotepreferences; import android.content.ContentResolver; import android.content.ContentValues; import android.content.Context; import android.content.SharedPreferences; import android.database.Cursor; import android.net.Uri; import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.platform.app.InstrumentationRegistry; import com.crossbowffs.remotepreferences.testapp.TestConstants; import org.junit.Assert; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import java.util.HashSet; @RunWith(AndroidJUnit4.class) public class RemotePreferenceProviderTest { private Context getLocalContext() { return InstrumentationRegistry.getInstrumentation().getContext(); } private Context getRemoteContext() { return InstrumentationRegistry.getInstrumentation().getTargetContext(); } private SharedPreferences getSharedPreferences() { Context context = getRemoteContext(); return context.getSharedPreferences(TestConstants.PREF_FILE, Context.MODE_PRIVATE); } private Uri getQueryUri(String key) { String uri = "content://" + TestConstants.AUTHORITY + "/" + TestConstants.PREF_FILE; if (key != null) { uri += "/" + key; } return Uri.parse(uri); } @Before public void resetPreferences() { getSharedPreferences().edit().clear().commit(); } @Test public void testQueryAllPrefs() { getSharedPreferences() .edit() .putString("string", "foobar") .putInt("int", 1337) .apply(); ContentResolver resolver = getLocalContext().getContentResolver(); Cursor q = resolver.query(getQueryUri(null), null, null, null, null); Assert.assertEquals(2, q.getCount()); int key = q.getColumnIndex(RemoteContract.COLUMN_KEY); int type = q.getColumnIndex(RemoteContract.COLUMN_TYPE); int value = q.getColumnIndex(RemoteContract.COLUMN_VALUE); while (q.moveToNext()) { if (q.getString(key).equals("string")) { Assert.assertEquals(RemoteContract.TYPE_STRING, q.getInt(type)); Assert.assertEquals("foobar", q.getString(value)); } else if (q.getString(key).equals("int")) { Assert.assertEquals(RemoteContract.TYPE_INT, q.getInt(type)); Assert.assertEquals(1337, q.getInt(value)); } else { Assert.fail(); } } } @Test public void testQuerySinglePref() { getSharedPreferences() .edit() .putString("string", "foobar") .putInt("int", 1337) .apply(); ContentResolver resolver = getLocalContext().getContentResolver(); Cursor q = resolver.query(getQueryUri("string"), null, null, null, null); Assert.assertEquals(1, q.getCount()); int key = q.getColumnIndex(RemoteContract.COLUMN_KEY); int type = q.getColumnIndex(RemoteContract.COLUMN_TYPE); int value = q.getColumnIndex(RemoteContract.COLUMN_VALUE); q.moveToFirst(); Assert.assertEquals("string", q.getString(key)); Assert.assertEquals(RemoteContract.TYPE_STRING, q.getInt(type)); Assert.assertEquals("foobar", q.getString(value)); } @Test public void testQueryFailPermissionCheck() { getSharedPreferences() .edit() .putString(TestConstants.UNREADABLE_PREF_KEY, "foobar") .apply(); ContentResolver resolver = getLocalContext().getContentResolver(); try { resolver.query(getQueryUri(TestConstants.UNREADABLE_PREF_KEY), null, null, null, null); Assert.fail(); } catch (SecurityException e) { // Expected } } @Test public void testInsertPref() { ContentValues values = new ContentValues(); values.put(RemoteContract.COLUMN_KEY, "string"); values.put(RemoteContract.COLUMN_TYPE, RemoteContract.TYPE_STRING); values.put(RemoteContract.COLUMN_VALUE, "foobar"); ContentResolver resolver = getLocalContext().getContentResolver(); Uri uri = resolver.insert(getQueryUri(null), values); Assert.assertEquals(getQueryUri("string"), uri); SharedPreferences prefs = getSharedPreferences(); Assert.assertEquals("foobar", prefs.getString("string", null)); } @Test public void testInsertOverridePref() { SharedPreferences prefs = getSharedPreferences(); prefs .edit() .putString("string", "nyaa") .putInt("int", 1337) .apply(); ContentValues values = new ContentValues(); values.put(RemoteContract.COLUMN_KEY, "string"); values.put(RemoteContract.COLUMN_TYPE, RemoteContract.TYPE_STRING); values.put(RemoteContract.COLUMN_VALUE, "foobar"); ContentResolver resolver = getLocalContext().getContentResolver(); Uri uri = resolver.insert(getQueryUri(null), values); Assert.assertEquals(getQueryUri("string"), uri); Assert.assertEquals("foobar", prefs.getString("string", null)); Assert.assertEquals(1337, prefs.getInt("int", 0)); } @Test public void testInsertPrefKeyInUri() { ContentValues values = new ContentValues(); values.put(RemoteContract.COLUMN_TYPE, RemoteContract.TYPE_STRING); values.put(RemoteContract.COLUMN_VALUE, "foobar"); ContentResolver resolver = getLocalContext().getContentResolver(); Uri uri = resolver.insert(getQueryUri("string"), values); Assert.assertEquals(getQueryUri("string"), uri); SharedPreferences prefs = getSharedPreferences(); Assert.assertEquals("foobar", prefs.getString("string", null)); } @Test public void testInsertPrefKeyInUriAndValues() { ContentValues values = new ContentValues(); values.put(RemoteContract.COLUMN_KEY, "string"); values.put(RemoteContract.COLUMN_TYPE, RemoteContract.TYPE_STRING); values.put(RemoteContract.COLUMN_VALUE, "foobar"); ContentResolver resolver = getLocalContext().getContentResolver(); Uri uri = resolver.insert(getQueryUri("string"), values); Assert.assertEquals(getQueryUri("string"), uri); SharedPreferences prefs = getSharedPreferences(); Assert.assertEquals("foobar", prefs.getString("string", null)); } @Test public void testInsertPrefFailKeyInUriAndValuesMismatch() { ContentValues values = new ContentValues(); values.put(RemoteContract.COLUMN_KEY, "string"); values.put(RemoteContract.COLUMN_TYPE, RemoteContract.TYPE_STRING); values.put(RemoteContract.COLUMN_VALUE, "foobar"); ContentResolver resolver = getLocalContext().getContentResolver(); try { resolver.insert(getQueryUri("string2"), values); Assert.fail(); } catch (IllegalArgumentException e) { // Expected } SharedPreferences prefs = getSharedPreferences(); Assert.assertEquals("default", prefs.getString("string", "default")); } @Test public void testInsertMultiplePrefs() { ContentValues[] values = new ContentValues[2]; values[0] = new ContentValues(); values[0].put(RemoteContract.COLUMN_KEY, "string"); values[0].put(RemoteContract.COLUMN_TYPE, RemoteContract.TYPE_STRING); values[0].put(RemoteContract.COLUMN_VALUE, "foobar"); values[1] = new ContentValues(); values[1].put(RemoteContract.COLUMN_KEY, "int"); values[1].put(RemoteContract.COLUMN_TYPE, RemoteContract.TYPE_INT); values[1].put(RemoteContract.COLUMN_VALUE, 1337); ContentResolver resolver = getLocalContext().getContentResolver(); int ret = resolver.bulkInsert(getQueryUri(null), values); Assert.assertEquals(2, ret); SharedPreferences prefs = getSharedPreferences(); Assert.assertEquals("foobar", prefs.getString("string", null)); Assert.assertEquals(1337, prefs.getInt("int", 0)); } @Test public void testInsertFailPermissionCheck() { ContentValues[] values = new ContentValues[2]; values[0] = new ContentValues(); values[0].put(RemoteContract.COLUMN_KEY, "string"); values[0].put(RemoteContract.COLUMN_TYPE, RemoteContract.TYPE_STRING); values[0].put(RemoteContract.COLUMN_VALUE, "foobar"); values[1] = new ContentValues(); values[1].put(RemoteContract.COLUMN_KEY, TestConstants.UNWRITABLE_PREF_KEY); values[1].put(RemoteContract.COLUMN_TYPE, RemoteContract.TYPE_INT); values[1].put(RemoteContract.COLUMN_VALUE, 1337); ContentResolver resolver = getLocalContext().getContentResolver(); try { resolver.bulkInsert(getQueryUri(null), values); Assert.fail(); } catch (SecurityException e) { // Expected } SharedPreferences prefs = getSharedPreferences(); Assert.assertEquals("default", prefs.getString("string", "default")); Assert.assertEquals(0, prefs.getInt(TestConstants.UNWRITABLE_PREF_KEY, 0)); } @Test public void testInsertMultipleFailUriContainingKey() { ContentValues[] values = new ContentValues[1]; values[0] = new ContentValues(); values[0].put(RemoteContract.COLUMN_KEY, "string"); values[0].put(RemoteContract.COLUMN_TYPE, RemoteContract.TYPE_STRING); values[0].put(RemoteContract.COLUMN_VALUE, "foobar"); ContentResolver resolver = getLocalContext().getContentResolver(); try { resolver.bulkInsert(getQueryUri("key"), values); Assert.fail(); } catch (IllegalArgumentException e) { // Expected } SharedPreferences prefs = getSharedPreferences(); Assert.assertEquals("default", prefs.getString("string", "default")); } @Test public void testDeletePref() { SharedPreferences prefs = getSharedPreferences(); prefs .edit() .putString("string", "nyaa") .apply(); ContentResolver resolver = getLocalContext().getContentResolver(); resolver.delete(getQueryUri("string"), null, null); Assert.assertEquals("default", prefs.getString("string", "default")); } @Test public void testDeleteUnwritablePref() { SharedPreferences prefs = getSharedPreferences(); prefs .edit() .putString(TestConstants.UNWRITABLE_PREF_KEY, "nyaa") .apply(); ContentResolver resolver = getLocalContext().getContentResolver(); try { resolver.delete(getQueryUri(TestConstants.UNWRITABLE_PREF_KEY), null, null); Assert.fail(); } catch (SecurityException e) { // Expected } Assert.assertEquals("nyaa", prefs.getString(TestConstants.UNWRITABLE_PREF_KEY, "default")); } @Test public void testReadBoolean() { getSharedPreferences() .edit() .putBoolean("true", true) .putBoolean("false", false) .apply(); ContentResolver resolver = getLocalContext().getContentResolver(); Cursor q = resolver.query(getQueryUri(null), null, null, null, null); Assert.assertEquals(2, q.getCount()); int key = q.getColumnIndex(RemoteContract.COLUMN_KEY); int type = q.getColumnIndex(RemoteContract.COLUMN_TYPE); int value = q.getColumnIndex(RemoteContract.COLUMN_VALUE); while (q.moveToNext()) { if (q.getString(key).equals("true")) { Assert.assertEquals(RemoteContract.TYPE_BOOLEAN, q.getInt(type)); Assert.assertEquals(1, q.getInt(value)); } else if (q.getString(key).equals("false")) { Assert.assertEquals(RemoteContract.TYPE_BOOLEAN, q.getInt(type)); Assert.assertEquals(0, q.getInt(value)); } else { Assert.fail(); } } } @Test public void testReadStringSet() { HashSet set = new HashSet<>(); set.add("foo"); set.add("bar;"); set.add("baz"); set.add(""); getSharedPreferences() .edit() .putStringSet("pref", set) .apply(); ContentResolver resolver = getLocalContext().getContentResolver(); Cursor q = resolver.query(getQueryUri("pref"), null, null, null, null); Assert.assertEquals(1, q.getCount()); int key = q.getColumnIndex(RemoteContract.COLUMN_KEY); int type = q.getColumnIndex(RemoteContract.COLUMN_TYPE); int value = q.getColumnIndex(RemoteContract.COLUMN_VALUE); while (q.moveToNext()) { if (q.getString(key).equals("pref")) { Assert.assertEquals(RemoteContract.TYPE_STRING_SET, q.getInt(type)); String serialized = q.getString(value); Assert.assertEquals(set, RemoteUtils.deserializeStringSet(serialized)); } else { Assert.fail(); } } } @Test public void testInsertStringSet() { HashSet set = new HashSet<>(); set.add("foo"); set.add("bar;"); set.add("baz"); set.add(""); ContentValues values = new ContentValues(); values.put(RemoteContract.COLUMN_KEY, "pref"); values.put(RemoteContract.COLUMN_TYPE, RemoteContract.TYPE_STRING_SET); values.put(RemoteContract.COLUMN_VALUE, RemoteUtils.serializeStringSet(set)); ContentResolver resolver = getLocalContext().getContentResolver(); Uri uri = resolver.insert(getQueryUri(null), values); Assert.assertEquals(getQueryUri("pref"), uri); Assert.assertEquals(set, getSharedPreferences().getStringSet("pref", null)); } } ================================================ FILE: testapp/src/androidTest/java/com/crossbowffs/remotepreferences/RemotePreferencesTest.java ================================================ package com.crossbowffs.remotepreferences; import android.content.Context; import android.content.SharedPreferences; import android.os.Build; import android.os.Handler; import android.os.HandlerThread; import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.filters.SdkSuppress; import androidx.test.platform.app.InstrumentationRegistry; import com.crossbowffs.remotepreferences.testapp.TestConstants; import com.crossbowffs.remotepreferences.testapp.TestPreferenceListener; import org.junit.Assert; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import java.util.HashSet; import java.util.Map; @RunWith(AndroidJUnit4.class) public class RemotePreferencesTest { private Context getLocalContext() { return InstrumentationRegistry.getInstrumentation().getContext(); } private Context getRemoteContext() { return InstrumentationRegistry.getInstrumentation().getTargetContext(); } private SharedPreferences getSharedPreferences() { Context context = getRemoteContext(); return context.getSharedPreferences(TestConstants.PREF_FILE, Context.MODE_PRIVATE); } private RemotePreferences getRemotePreferences(boolean strictMode) { // This is not a typo! We are using the LOCAL context to initialize a REMOTE prefs // instance. This is the whole point of RemotePreferences! Context context = getLocalContext(); return new RemotePreferences(context, TestConstants.AUTHORITY, TestConstants.PREF_FILE, strictMode); } private RemotePreferences getDisabledRemotePreferences(boolean strictMode) { Context context = getLocalContext(); return new RemotePreferences(context, TestConstants.AUTHORITY_DISABLED, TestConstants.PREF_FILE, strictMode); } private RemotePreferences getRemotePreferencesWithHandler(Handler handler, boolean strictMode) { Context context = getLocalContext(); return new RemotePreferences(context, handler, TestConstants.AUTHORITY, TestConstants.PREF_FILE, strictMode); } @Before public void resetPreferences() { getSharedPreferences().edit().clear().commit(); } @Test public void testBasicRead() { getSharedPreferences() .edit() .putString("string", "foobar") .putInt("int", 0xeceb3026) .putFloat("float", 3.14f) .putBoolean("bool", true) .apply(); RemotePreferences remotePrefs = getRemotePreferences(true); Assert.assertEquals("foobar", remotePrefs.getString("string", null)); Assert.assertEquals(0xeceb3026, remotePrefs.getInt("int", 0)); Assert.assertEquals(3.14f, remotePrefs.getFloat("float", 0f), 0.0); Assert.assertEquals(true, remotePrefs.getBoolean("bool", false)); } @Test public void testBasicWrite() { getRemotePreferences(true) .edit() .putString("string", "foobar") .putInt("int", 0xeceb3026) .putFloat("float", 3.14f) .putBoolean("bool", true) .apply(); SharedPreferences sharedPrefs = getSharedPreferences(); Assert.assertEquals("foobar", sharedPrefs.getString("string", null)); Assert.assertEquals(0xeceb3026, sharedPrefs.getInt("int", 0)); Assert.assertEquals(3.14f, sharedPrefs.getFloat("float", 0f), 0.0); Assert.assertEquals(true, sharedPrefs.getBoolean("bool", false)); } @Test public void testRemove() { getSharedPreferences() .edit() .putString("string", "foobar") .putInt("int", 0xeceb3026) .apply(); RemotePreferences remotePrefs = getRemotePreferences(true); remotePrefs.edit().remove("string").apply(); Assert.assertEquals("default", remotePrefs.getString("string", "default")); Assert.assertEquals(0xeceb3026, remotePrefs.getInt("int", 0)); } @Test public void testClear() { SharedPreferences sharedPrefs = getSharedPreferences(); getSharedPreferences() .edit() .putString("string", "foobar") .putInt("int", 0xeceb3026) .apply(); RemotePreferences remotePrefs = getRemotePreferences(true); remotePrefs.edit().clear().apply(); Assert.assertEquals(0, sharedPrefs.getAll().size()); Assert.assertEquals("default", remotePrefs.getString("string", "default")); Assert.assertEquals(0, remotePrefs.getInt("int", 0)); } @Test public void testGetAll() { getSharedPreferences() .edit() .putString("string", "foobar") .putInt("int", 0xeceb3026) .putFloat("float", 3.14f) .putBoolean("bool", true) .apply(); RemotePreferences remotePrefs = getRemotePreferences(true); Map prefs = remotePrefs.getAll(); Assert.assertEquals("foobar", prefs.get("string")); Assert.assertEquals(0xeceb3026, prefs.get("int")); Assert.assertEquals(3.14f, prefs.get("float")); Assert.assertEquals(true, prefs.get("bool")); } @Test public void testContains() { getSharedPreferences() .edit() .putString("string", "foobar") .putInt("int", 0xeceb3026) .putFloat("float", 3.14f) .putBoolean("bool", true) .apply(); RemotePreferences remotePrefs = getRemotePreferences(true); Assert.assertTrue(remotePrefs.contains("string")); Assert.assertTrue(remotePrefs.contains("int")); Assert.assertFalse(remotePrefs.contains("nonexistent")); } @Test public void testReadNonexistentPref() { RemotePreferences remotePrefs = getRemotePreferences(true); Assert.assertEquals("default", remotePrefs.getString("nonexistent_string", "default")); Assert.assertEquals(1337, remotePrefs.getInt("nonexistent_int", 1337)); } @Test public void testStringSetRead() { HashSet set = new HashSet<>(); set.add("Chocola"); set.add("Vanilla"); set.add("Coconut"); set.add("Azuki"); set.add("Maple"); set.add("Cinnamon"); getSharedPreferences() .edit() .putStringSet("pref", set) .apply(); RemotePreferences remotePrefs = getRemotePreferences(true); Assert.assertEquals(set, remotePrefs.getStringSet("pref", null)); } @Test public void testStringSetWrite() { HashSet set = new HashSet<>(); set.add("Chocola"); set.add("Vanilla"); set.add("Coconut"); set.add("Azuki"); set.add("Maple"); set.add("Cinnamon"); getRemotePreferences(true) .edit() .putStringSet("pref", set) .apply(); SharedPreferences sharedPrefs = getSharedPreferences(); Assert.assertEquals(set, sharedPrefs.getStringSet("pref", null)); } @Test public void testEmptyStringSetRead() { HashSet set = new HashSet<>(); getSharedPreferences() .edit() .putStringSet("pref", set) .apply(); RemotePreferences remotePrefs = getRemotePreferences(true); Assert.assertEquals(set, remotePrefs.getStringSet("pref", null)); } @Test public void testEmptyStringSetWrite() { HashSet set = new HashSet<>(); getRemotePreferences(true) .edit() .putStringSet("pref", set) .apply(); SharedPreferences sharedPrefs = getSharedPreferences(); Assert.assertEquals(set, sharedPrefs.getStringSet("pref", null)); } @Test public void testSetContainingEmptyStringRead() { HashSet set = new HashSet<>(); set.add(""); getSharedPreferences() .edit() .putStringSet("pref", set) .apply(); RemotePreferences remotePrefs = getRemotePreferences(true); Assert.assertEquals(set, remotePrefs.getStringSet("pref", null)); } @Test public void testSetContainingEmptyStringWrite() { HashSet set = new HashSet<>(); set.add(""); getRemotePreferences(true) .edit() .putStringSet("pref", set) .apply(); SharedPreferences sharedPrefs = getSharedPreferences(); Assert.assertEquals(set, sharedPrefs.getStringSet("pref", null)); } @Test public void testReadStringAsStringSetFail() { getSharedPreferences() .edit() .putString("pref", "foo;bar;") .apply(); RemotePreferences remotePrefs = getRemotePreferences(true); try { remotePrefs.getStringSet("pref", null); Assert.fail(); } catch (ClassCastException e) { // Expected } } @Test public void testReadStringSetAsStringFail() { HashSet set = new HashSet<>(); set.add("foo"); set.add("bar"); getSharedPreferences() .edit() .putStringSet("pref", set) .apply(); RemotePreferences remotePrefs = getRemotePreferences(true); try { remotePrefs.getString("pref", null); Assert.fail(); } catch (ClassCastException e) { // Expected } } @Test public void testReadBooleanAsIntFail() { getSharedPreferences() .edit() .putBoolean("pref", true) .apply(); RemotePreferences remotePrefs = getRemotePreferences(true); try { remotePrefs.getInt("pref", 0); Assert.fail(); } catch (ClassCastException e) { // Expected } } @Test public void testReadIntAsBooleanFail() { getSharedPreferences() .edit() .putInt("pref", 42) .apply(); RemotePreferences remotePrefs = getRemotePreferences(true); try { remotePrefs.getBoolean("pref", false); Assert.fail(); } catch (ClassCastException e) { // Expected } } @Test public void testInvalidAuthorityStrictMode() { Context context = getLocalContext(); RemotePreferences remotePrefs = new RemotePreferences(context, "foo", "bar", true); try { remotePrefs.getString("pref", null); Assert.fail(); } catch (RemotePreferenceAccessException e) { // Expected } } @Test public void testInvalidAuthorityNonStrictMode() { Context context = getLocalContext(); RemotePreferences remotePrefs = new RemotePreferences(context, "foo", "bar", false); Assert.assertEquals("default", remotePrefs.getString("pref", "default")); } @Test public void testDisabledProviderStrictMode() { RemotePreferences remotePrefs = getDisabledRemotePreferences(true); try { remotePrefs.getString("pref", null); Assert.fail(); } catch (RemotePreferenceAccessException e) { // Expected } } @Test public void testDisabledProviderNonStrictMode() { RemotePreferences remotePrefs = getDisabledRemotePreferences(false); Assert.assertEquals("default", remotePrefs.getString("pref", "default")); } @Test public void testUnreadablePrefStrictMode() { RemotePreferences remotePrefs = getRemotePreferences(true); try { remotePrefs.getString(TestConstants.UNREADABLE_PREF_KEY, null); Assert.fail(); } catch (RemotePreferenceAccessException e) { // Expected } } @Test public void testUnreadablePrefNonStrictMode() { RemotePreferences remotePrefs = getRemotePreferences(false); Assert.assertEquals("default", remotePrefs.getString(TestConstants.UNREADABLE_PREF_KEY, "default")); } @Test public void testUnwritablePrefStrictMode() { RemotePreferences remotePrefs = getRemotePreferences(true); try { remotePrefs.edit().putString(TestConstants.UNWRITABLE_PREF_KEY, "foobar").commit(); Assert.fail(); } catch (RemotePreferenceAccessException e) { // Expected } } @Test public void testUnwritablePrefNonStrictMode() { RemotePreferences remotePrefs = getRemotePreferences(false); Assert.assertFalse( remotePrefs .edit() .putString(TestConstants.UNWRITABLE_PREF_KEY, "foobar") .commit() ); } @Test public void testRemoveUnwritablePrefStrictMode() { getSharedPreferences() .edit() .putString(TestConstants.UNWRITABLE_PREF_KEY, "foobar") .apply(); RemotePreferences remotePrefs = getRemotePreferences(true); try { remotePrefs.edit().remove(TestConstants.UNWRITABLE_PREF_KEY).commit(); Assert.fail(); } catch (RemotePreferenceAccessException e) { // Expected } Assert.assertEquals("foobar", remotePrefs.getString(TestConstants.UNWRITABLE_PREF_KEY, "default")); } @Test public void testRemoveUnwritablePrefNonStrictMode() { getSharedPreferences() .edit() .putString(TestConstants.UNWRITABLE_PREF_KEY, "foobar") .apply(); RemotePreferences remotePrefs = getRemotePreferences(false); Assert.assertFalse(remotePrefs.edit().remove(TestConstants.UNWRITABLE_PREF_KEY).commit()); Assert.assertEquals("foobar", remotePrefs.getString(TestConstants.UNWRITABLE_PREF_KEY, "default")); } @Test public void testPreferenceChangeListener() { HandlerThread ht = new HandlerThread(getClass().getName()); try { ht.start(); Handler handler = new Handler(ht.getLooper()); RemotePreferences remotePrefs = getRemotePreferencesWithHandler(handler, true); TestPreferenceListener listener = new TestPreferenceListener(); try { remotePrefs.registerOnSharedPreferenceChangeListener(listener); getSharedPreferences() .edit() .putInt("foobar", 1337) .apply(); Assert.assertTrue(listener.waitForChange(1)); Assert.assertEquals("foobar", listener.getKey()); } finally { remotePrefs.unregisterOnSharedPreferenceChangeListener(listener); } } finally { ht.quit(); } } @Test @SdkSuppress(minSdkVersion = Build.VERSION_CODES.R) public void testPreferenceChangeListenerClear() { HandlerThread ht = new HandlerThread(getClass().getName()); try { ht.start(); Handler handler = new Handler(ht.getLooper()); RemotePreferences remotePrefs = getRemotePreferencesWithHandler(handler, true); TestPreferenceListener listener = new TestPreferenceListener(); try { remotePrefs.registerOnSharedPreferenceChangeListener(listener); getSharedPreferences() .edit() .clear() .apply(); Assert.assertTrue(listener.waitForChange(1)); Assert.assertNull(listener.getKey()); } finally { remotePrefs.unregisterOnSharedPreferenceChangeListener(listener); } } finally { ht.quit(); } } @Test public void testUnregisterPreferenceChangeListener() { HandlerThread ht = new HandlerThread(getClass().getName()); try { ht.start(); Handler handler = new Handler(ht.getLooper()); RemotePreferences remotePrefs = getRemotePreferencesWithHandler(handler, true); TestPreferenceListener listener = new TestPreferenceListener(); try { remotePrefs.registerOnSharedPreferenceChangeListener(listener); remotePrefs.unregisterOnSharedPreferenceChangeListener(listener); getSharedPreferences() .edit() .putInt("foobar", 1337) .apply(); Assert.assertFalse(listener.waitForChange(1)); } finally { remotePrefs.unregisterOnSharedPreferenceChangeListener(listener); } } finally { ht.quit(); } } } ================================================ FILE: testapp/src/androidTest/java/com/crossbowffs/remotepreferences/RemoteUtilsTest.java ================================================ package com.crossbowffs.remotepreferences; import androidx.test.ext.junit.runners.AndroidJUnit4; import org.junit.Assert; import org.junit.Test; import org.junit.runner.RunWith; import java.util.HashSet; import java.util.LinkedHashSet; import java.util.Set; @RunWith(AndroidJUnit4.class) public class RemoteUtilsTest { @Test public void testSerializeStringSet() { Set set = new LinkedHashSet(); set.add("foo"); set.add("bar;"); set.add("baz"); set.add(""); String serialized = RemoteUtils.serializeStringSet(set); Assert.assertEquals("foo;bar\\;;baz;;", serialized); } @Test public void testDeserializeStringSet() { Set set = new LinkedHashSet(); set.add("foo"); set.add("bar;"); set.add("baz"); set.add(""); String serialized = RemoteUtils.serializeStringSet(set); Set deserialized = RemoteUtils.deserializeStringSet(serialized); Assert.assertEquals(set, deserialized); } @Test public void testSerializeEmptyStringSet() { Assert.assertEquals("", RemoteUtils.serializeStringSet(new HashSet())); } @Test public void testDeserializeEmptyStringSet() { Assert.assertEquals(new HashSet(), RemoteUtils.deserializeStringSet("")); } @Test public void testDeserializeInvalidStringSet() { try { RemoteUtils.deserializeStringSet("foo;bar"); Assert.fail(); } catch (IllegalArgumentException e) { // Expected } } } ================================================ FILE: testapp/src/main/AndroidManifest.xml ================================================ ================================================ FILE: testapp/src/main/java/com/crossbowffs/remotepreferences/testapp/TestConstants.java ================================================ package com.crossbowffs.remotepreferences.testapp; public final class TestConstants { private TestConstants() {} public static final String AUTHORITY = BuildConfig.APPLICATION_ID + ".preferences"; public static final String AUTHORITY_DISABLED = BuildConfig.APPLICATION_ID + ".preferences.disabled"; public static final String PREF_FILE = "main_prefs"; public static final String UNREADABLE_PREF_KEY = "cannot_read_me"; public static final String UNWRITABLE_PREF_KEY = "cannot_write_me"; } ================================================ FILE: testapp/src/main/java/com/crossbowffs/remotepreferences/testapp/TestPreferenceListener.java ================================================ package com.crossbowffs.remotepreferences.testapp; import android.content.SharedPreferences; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; public class TestPreferenceListener implements SharedPreferences.OnSharedPreferenceChangeListener { private boolean mIsCalled; private String mKey; private final CountDownLatch mLatch; public TestPreferenceListener() { mIsCalled = false; mKey = null; mLatch = new CountDownLatch(1); } public boolean isCalled() { return mIsCalled; } public String getKey() { if (!mIsCalled) { throw new IllegalStateException("Listener was not called"); } return mKey; } public boolean waitForChange(long seconds) { try { return mLatch.await(seconds, TimeUnit.SECONDS); } catch (InterruptedException e) { throw new IllegalStateException("Listener wait was interrupted"); } } @Override public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) { mIsCalled = true; mKey = key; mLatch.countDown(); } } ================================================ FILE: testapp/src/main/java/com/crossbowffs/remotepreferences/testapp/TestPreferenceProvider.java ================================================ package com.crossbowffs.remotepreferences.testapp; import com.crossbowffs.remotepreferences.RemotePreferenceProvider; public class TestPreferenceProvider extends RemotePreferenceProvider { public TestPreferenceProvider() { super(TestConstants.AUTHORITY, new String[] {TestConstants.PREF_FILE}); } @Override protected boolean checkAccess(String prefName, String prefKey, boolean write) { if (prefKey.equals(TestConstants.UNREADABLE_PREF_KEY) && !write) return false; if (prefKey.equals(TestConstants.UNWRITABLE_PREF_KEY) && write) return false; return true; } } ================================================ FILE: testapp/src/main/java/com/crossbowffs/remotepreferences/testapp/TestPreferenceProviderDisabled.java ================================================ package com.crossbowffs.remotepreferences.testapp; import com.crossbowffs.remotepreferences.RemotePreferenceProvider; public class TestPreferenceProviderDisabled extends RemotePreferenceProvider { public TestPreferenceProviderDisabled() { super(TestConstants.AUTHORITY_DISABLED, new String[] {TestConstants.PREF_FILE}); } }