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

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

21 | * 22 | *

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

29 | * 30 | *

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

34 | * 35 | *

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

41 | * 42 | *
 43 |  * query(uri = content://authority/foo/bar)
 44 |  * = getSharedPreferences("foo").get("bar")
 45 |  *
 46 |  * query(uri = content://authority/foo)
 47 |  * = getSharedPreferences("foo").getAll()
 48 |  *
 49 |  * insert(uri = content://authority/foo/bar, values = [{type = TYPE_STRING, value = "baz"}])
 50 |  * = getSharedPreferences("foo").edit().putString("bar", "baz").commit()
 51 |  *
 52 |  * insert(uri = content://authority/foo, values = [{key = "bar", type = TYPE_STRING, value = "baz"}])
 53 |  * = getSharedPreferences("foo").edit().putString("bar", "baz").commit()
 54 |  *
 55 |  * delete(uri = content://authority/foo/bar)
 56 |  * = getSharedPreferences("foo").edit().remove("bar").commit()
 57 |  *
 58 |  * delete(uri = content://authority/foo)
 59 |  * = getSharedPreferences("foo").edit().clear().commit()
 60 |  * 
61 | * 62 | *

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

70 | */ 71 | public abstract class RemotePreferenceProvider extends ContentProvider implements SharedPreferences.OnSharedPreferenceChangeListener { 72 | private final Uri mBaseUri; 73 | private final RemotePreferenceFile[] mPrefFiles; 74 | private final Map mPreferences; 75 | private final RemotePreferenceUriParser mUriParser; 76 | 77 | /** 78 | * Initializes the remote preference provider with the specified 79 | * authority and preference file names. The authority must match the 80 | * {@code android:authorities} property defined in your manifest 81 | * file. Only the specified preference files will be accessible 82 | * through the provider. This constructor assumes all preferences 83 | * are located in credential protected storage; if you are using 84 | * device protected storage, use 85 | * {@link #RemotePreferenceProvider(String, RemotePreferenceFile[])}. 86 | * 87 | * @param authority The authority of the provider. 88 | * @param prefFileNames The names of the preference files to expose. 89 | */ 90 | public RemotePreferenceProvider(String authority, String[] prefFileNames) { 91 | this(authority, RemotePreferenceFile.fromFileNames(prefFileNames)); 92 | } 93 | 94 | /** 95 | * Initializes the remote preference provider with the specified 96 | * authority and preference files. The authority must match the 97 | * {@code android:authorities} property defined in your manifest 98 | * file. Only the specified preference files will be accessible 99 | * through the provider. 100 | * 101 | * @param authority The authority of the provider. 102 | * @param prefFiles The preference files to expose. 103 | */ 104 | public RemotePreferenceProvider(String authority, RemotePreferenceFile[] prefFiles) { 105 | mBaseUri = Uri.parse("content://" + authority); 106 | mPrefFiles = prefFiles; 107 | mPreferences = new HashMap(prefFiles.length); 108 | mUriParser = new RemotePreferenceUriParser(authority); 109 | } 110 | 111 | /** 112 | * Checks whether the specified preference is accessible by callers. 113 | * The default implementation returns {@code true} for all accesses. 114 | * You may override this method to control which preferences can be 115 | * read or written. Note that {@code prefKey} will be {@code ""} when 116 | * accessing an entire file, so a whitelist is strongly recommended 117 | * over a blacklist (your default case should be {@code return false}, 118 | * not {@code return true}). 119 | * 120 | * @param prefFileName The name of the preference file. 121 | * @param prefKey The preference key. This is an empty string when handling the 122 | * {@link SharedPreferences#getAll()} and 123 | * {@link SharedPreferences.Editor#clear()} operations. 124 | * @param write {@code true} for put/remove/clear operations; {@code false} for get operations. 125 | * @return {@code true} if the access is allowed; {@code false} otherwise. 126 | */ 127 | protected boolean checkAccess(String prefFileName, String prefKey, boolean write) { 128 | return true; 129 | } 130 | 131 | /** 132 | * Called at application startup to register preference change listeners. 133 | * 134 | * @return Always returns {@code true}. 135 | */ 136 | @Override 137 | public boolean onCreate() { 138 | // We register the shared preference listeners whenever the provider 139 | // is created. This method is called before almost all other code in 140 | // the app, which ensures that we never miss a preference change. 141 | for (RemotePreferenceFile file : mPrefFiles) { 142 | Context context = getContext(); 143 | if (file.isDeviceProtected() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { 144 | context = context.createDeviceProtectedStorageContext(); 145 | } 146 | SharedPreferences prefs = getSharedPreferences(context, file.getFileName()); 147 | prefs.registerOnSharedPreferenceChangeListener(this); 148 | mPreferences.put(file.getFileName(), prefs); 149 | } 150 | return true; 151 | } 152 | 153 | /** 154 | * Generate {@link SharedPreferences} to store the key-value data. 155 | * Override this method to provide a custom implementation of {@link SharedPreferences}. 156 | * 157 | * @param context The context that should be used to get the preferences object. 158 | * @param prefFileName The name of the preference file. 159 | * @return An object implementing the {@link SharedPreferences} interface. 160 | */ 161 | protected SharedPreferences getSharedPreferences(Context context, String prefFileName) { 162 | return context.getSharedPreferences(prefFileName, Context.MODE_PRIVATE); 163 | } 164 | 165 | /** 166 | * Returns a cursor for the specified preference(s). If {@code uri} 167 | * is in the form {@code content://authority/prefFileName/prefKey}, the 168 | * cursor will contain a single row containing the queried preference. 169 | * If {@code uri} is in the form {@code content://authority/prefFileName}, 170 | * the cursor will contain one row for each preference in the specified 171 | * file. 172 | * 173 | * @param uri Specifies the preference file and key (optional) to query. 174 | * @param projection Specifies which fields should be returned in the cursor. 175 | * @param selection Ignored. 176 | * @param selectionArgs Ignored. 177 | * @param sortOrder Ignored. 178 | * @return A cursor used to access the queried preference data. 179 | */ 180 | @Override 181 | public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) { 182 | RemotePreferencePath prefPath = mUriParser.parse(uri); 183 | 184 | SharedPreferences prefs = getSharedPreferencesOrThrow(prefPath, false); 185 | Map prefMap = prefs.getAll(); 186 | 187 | // If no projection is specified, we return all columns. 188 | if (projection == null) { 189 | projection = RemoteContract.COLUMN_ALL; 190 | } 191 | 192 | // Fill out the cursor with the preference data. If the caller 193 | // didn't ask for a particular preference, we return all of them. 194 | MatrixCursor cursor = new MatrixCursor(projection); 195 | if (isSingleKey(prefPath.key)) { 196 | Object prefValue = prefMap.get(prefPath.key); 197 | cursor.addRow(buildRow(projection, prefPath.key, prefValue)); 198 | } else { 199 | for (Map.Entry entry : prefMap.entrySet()) { 200 | String prefKey = entry.getKey(); 201 | Object prefValue = entry.getValue(); 202 | cursor.addRow(buildRow(projection, prefKey, prefValue)); 203 | } 204 | } 205 | 206 | return cursor; 207 | } 208 | 209 | /** 210 | * Not used in RemotePreferences. Always returns {@code null}. 211 | * 212 | * @param uri Ignored. 213 | * @return Always returns {@code null}. 214 | */ 215 | @Override 216 | public String getType(Uri uri) { 217 | return null; 218 | } 219 | 220 | /** 221 | * Writes the value of the specified preference(s). If no key is specified, 222 | * {@link RemoteContract#COLUMN_TYPE} must be equal to {@link RemoteContract#TYPE_NULL}, 223 | * representing the {@link SharedPreferences.Editor#clear()} operation. 224 | * 225 | * @param uri Specifies the preference file and key (optional) to write. 226 | * @param values Specifies the key (optional), type and value of the preference to write. 227 | * @return A URI representing the preference written, or {@code null} on failure. 228 | */ 229 | @Override 230 | public Uri insert(Uri uri, ContentValues values) { 231 | if (values == null) { 232 | return null; 233 | } 234 | 235 | RemotePreferencePath prefPath = mUriParser.parse(uri); 236 | String prefKey = getKeyFromUriOrValues(prefPath, values); 237 | 238 | SharedPreferences prefs = getSharedPreferencesOrThrow(prefPath, true); 239 | SharedPreferences.Editor editor = prefs.edit(); 240 | 241 | putPreference(editor, prefKey, values); 242 | 243 | if (editor.commit()) { 244 | return getPreferenceUri(prefPath.fileName, prefKey); 245 | } else { 246 | return null; 247 | } 248 | } 249 | 250 | /** 251 | * Writes multiple preference values at once. {@code uri} must 252 | * be in the form {@code content://authority/prefFileName}. See 253 | * {@link #insert(Uri, ContentValues)} for more information. 254 | * 255 | * @param uri Specifies the preference file to write to. 256 | * @param values See {@link #insert(Uri, ContentValues)}. 257 | * @return The number of preferences written, or 0 on failure. 258 | */ 259 | @Override 260 | public int bulkInsert(Uri uri, ContentValues[] values) { 261 | RemotePreferencePath prefPath = mUriParser.parse(uri); 262 | 263 | if (isSingleKey(prefPath.key)) { 264 | throw new IllegalArgumentException("Cannot bulk insert with single key URI"); 265 | } 266 | 267 | SharedPreferences prefs = getSharedPreferencesByName(prefPath.fileName); 268 | SharedPreferences.Editor editor = prefs.edit(); 269 | 270 | for (ContentValues value : values) { 271 | String prefKey = getKeyFromValues(value); 272 | checkAccessOrThrow(prefPath.withKey(prefKey), true); 273 | putPreference(editor, prefKey, value); 274 | } 275 | 276 | if (editor.commit()) { 277 | return values.length; 278 | } else { 279 | return 0; 280 | } 281 | } 282 | 283 | /** 284 | * Deletes the specified preference(s). If {@code uri} is in the form 285 | * {@code content://authority/prefFileName/prefKey}, this will only delete 286 | * the one preference specified in the URI; if {@code uri} is in the form 287 | * {@code content://authority/prefFileName}, clears all preferences. 288 | * 289 | * @param uri Specifies the preference file and key (optional) to delete. 290 | * @param selection Ignored. 291 | * @param selectionArgs Ignored. 292 | * @return 1 if the preferences committed successfully, or 0 on failure. 293 | */ 294 | @Override 295 | public int delete(Uri uri, String selection, String[] selectionArgs) { 296 | RemotePreferencePath prefPath = mUriParser.parse(uri); 297 | 298 | SharedPreferences prefs = getSharedPreferencesOrThrow(prefPath, true); 299 | SharedPreferences.Editor editor = prefs.edit(); 300 | 301 | if (isSingleKey(prefPath.key)) { 302 | editor.remove(prefPath.key); 303 | } else { 304 | editor.clear(); 305 | } 306 | 307 | // There's no reliable method of getting the actual number of 308 | // preference values changed, so callers should not rely on this 309 | // value. A return value of 1 means success, 0 means failure. 310 | if (editor.commit()) { 311 | return 1; 312 | } else { 313 | return 0; 314 | } 315 | } 316 | 317 | /** 318 | * Updates the value of the specified preference(s). This is a wrapper 319 | * around {@link #insert(Uri, ContentValues)} if {@code values} is not 320 | * {@code null}, or {@link #delete(Uri, String, String[])} if {@code values} 321 | * is {@code null}. 322 | * 323 | * @param uri Specifies the preference file and key (optional) to update. 324 | * @param values {@code null} to delete the preference, 325 | * @param selection Ignored. 326 | * @param selectionArgs Ignored. 327 | * @return 1 if the preferences committed successfully, or 0 on failure. 328 | */ 329 | @Override 330 | public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) { 331 | if (values == null) { 332 | return delete(uri, selection, selectionArgs); 333 | } else { 334 | return insert(uri, values) != null ? 1 : 0; 335 | } 336 | } 337 | 338 | /** 339 | * Listener for preference value changes in the local application. 340 | * Re-raises the event through the 341 | * {@link ContentResolver#notifyChange(Uri, ContentObserver)} API 342 | * to any registered {@link ContentObserver} objects. Note that this 343 | * is NOT called for {@link SharedPreferences.Editor#clear()}. 344 | * 345 | * @param prefs The preference file that changed. 346 | * @param prefKey The preference key that changed. 347 | */ 348 | @Override 349 | public void onSharedPreferenceChanged(SharedPreferences prefs, String prefKey) { 350 | RemotePreferenceFile prefFile = getSharedPreferencesFile(prefs); 351 | Uri uri = getPreferenceUri(prefFile.getFileName(), prefKey); 352 | Context context = getContext(); 353 | if (prefFile.isDeviceProtected() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { 354 | context = context.createDeviceProtectedStorageContext(); 355 | } 356 | ContentResolver resolver = context.getContentResolver(); 357 | resolver.notifyChange(uri, null); 358 | } 359 | 360 | /** 361 | * Writes the value of the specified preference(s). If {@code prefKey} 362 | * is empty, {@code values} must contain {@link RemoteContract#TYPE_NULL} 363 | * for the type, representing the {@link SharedPreferences.Editor#clear()} 364 | * operation. 365 | * 366 | * @param editor The preference file to modify. 367 | * @param prefKey The preference key to modify, or {@code null} for the entire file. 368 | * @param values The values to write. 369 | */ 370 | private void putPreference(SharedPreferences.Editor editor, String prefKey, ContentValues values) { 371 | // Get the new value type. Note that we manually check 372 | // for null, then unbox the Integer so we don't cause a NPE. 373 | Integer type = values.getAsInteger(RemoteContract.COLUMN_TYPE); 374 | if (type == null) { 375 | throw new IllegalArgumentException("Invalid or no preference type specified"); 376 | } 377 | 378 | // deserializeInput makes sure the actual object type matches 379 | // the expected type, so we must perform this step before actually 380 | // performing any actions. 381 | Object rawValue = values.get(RemoteContract.COLUMN_VALUE); 382 | Object value = RemoteUtils.deserializeInput(rawValue, type); 383 | 384 | // If we are writing to the "directory" and the type is null, 385 | // then we should clear the preferences. 386 | if (!isSingleKey(prefKey)) { 387 | if (type == RemoteContract.TYPE_NULL) { 388 | editor.clear(); 389 | return; 390 | } else { 391 | throw new IllegalArgumentException("Attempting to insert preference with null or empty key"); 392 | } 393 | } 394 | 395 | switch (type) { 396 | case RemoteContract.TYPE_NULL: 397 | editor.remove(prefKey); 398 | break; 399 | case RemoteContract.TYPE_STRING: 400 | editor.putString(prefKey, (String)value); 401 | break; 402 | case RemoteContract.TYPE_STRING_SET: 403 | if (Build.VERSION.SDK_INT >= 11) { 404 | editor.putStringSet(prefKey, RemoteUtils.castStringSet(value)); 405 | } else { 406 | throw new IllegalArgumentException("String set preferences not supported on API < 11"); 407 | } 408 | break; 409 | case RemoteContract.TYPE_INT: 410 | editor.putInt(prefKey, (Integer)value); 411 | break; 412 | case RemoteContract.TYPE_LONG: 413 | editor.putLong(prefKey, (Long)value); 414 | break; 415 | case RemoteContract.TYPE_FLOAT: 416 | editor.putFloat(prefKey, (Float)value); 417 | break; 418 | case RemoteContract.TYPE_BOOLEAN: 419 | editor.putBoolean(prefKey, (Boolean)value); 420 | break; 421 | default: 422 | throw new IllegalArgumentException("Cannot set preference with type " + type); 423 | } 424 | } 425 | 426 | /** 427 | * Used to project a preference value to the schema requested by the caller. 428 | * 429 | * @param projection The projection requested by the caller. 430 | * @param key The preference key. 431 | * @param value The preference value. 432 | * @return A row representing the preference using the given schema. 433 | */ 434 | private Object[] buildRow(String[] projection, String key, Object value) { 435 | Object[] row = new Object[projection.length]; 436 | for (int i = 0; i < row.length; ++i) { 437 | String col = projection[i]; 438 | if (RemoteContract.COLUMN_KEY.equals(col)) { 439 | row[i] = key; 440 | } else if (RemoteContract.COLUMN_TYPE.equals(col)) { 441 | row[i] = RemoteUtils.getPreferenceType(value); 442 | } else if (RemoteContract.COLUMN_VALUE.equals(col)) { 443 | row[i] = RemoteUtils.serializeOutput(value); 444 | } else { 445 | throw new IllegalArgumentException("Invalid column name: " + col); 446 | } 447 | } 448 | return row; 449 | } 450 | 451 | /** 452 | * Returns whether the specified key represents a single preference key 453 | * (as opposed to the entire preference file). 454 | * 455 | * @param prefKey The preference key to check. 456 | * @return Whether the key refers to a single preference. 457 | */ 458 | private static boolean isSingleKey(String prefKey) { 459 | return prefKey != null; 460 | } 461 | 462 | /** 463 | * Parses the preference key from {@code values}. If the key is not 464 | * specified in the values, {@code null} is returned. 465 | * 466 | * @param values The query values to parse. 467 | * @return The parsed key, or {@code null} if no key was found. 468 | */ 469 | private static String getKeyFromValues(ContentValues values) { 470 | String key = values.getAsString(RemoteContract.COLUMN_KEY); 471 | if (key != null && key.length() == 0) { 472 | key = null; 473 | } 474 | return key; 475 | } 476 | 477 | /** 478 | * Parses the preference key from the specified sources. Since there 479 | * are two ways to specify the key (from the URI or from the query values), 480 | * the only allowed combinations are: 481 | * 482 | * uri.key == values.key 483 | * uri.key != null and values.key == null = URI key is used 484 | * uri.key == null and values.key != null = values key is used 485 | * uri.key == null and values.key == null = no key 486 | * 487 | * If none of these conditions are met, an exception is thrown. 488 | * 489 | * @param prefPath Parsed URI key from {@code mUriParser.parse(uri)}. 490 | * @param values Query values provided by the caller. 491 | * @return The parsed key, or {@code null} if the key refers to a preference file. 492 | */ 493 | private static String getKeyFromUriOrValues(RemotePreferencePath prefPath, ContentValues values) { 494 | String uriKey = prefPath.key; 495 | String valuesKey = getKeyFromValues(values); 496 | if (isSingleKey(uriKey) && isSingleKey(valuesKey)) { 497 | // If a key is specified in both the URI and 498 | // ContentValues, they must match 499 | if (!uriKey.equals(valuesKey)) { 500 | throw new IllegalArgumentException("Conflicting keys specified in URI and ContentValues"); 501 | } 502 | return uriKey; 503 | } else if (isSingleKey(uriKey)) { 504 | return uriKey; 505 | } else if (isSingleKey(valuesKey)) { 506 | return valuesKey; 507 | } else { 508 | return null; 509 | } 510 | } 511 | 512 | /** 513 | * Checks that the caller has permissions to access the specified preference. 514 | * Throws an exception if permission is denied. 515 | * 516 | * @param prefPath The preference file and key to be accessed. 517 | * @param write Whether the operation will modify the preference. 518 | */ 519 | private void checkAccessOrThrow(RemotePreferencePath prefPath, boolean write) { 520 | // For backwards compatibility, checkAccess takes an empty string when 521 | // referring to the whole file. 522 | String prefKey = prefPath.key; 523 | if (!isSingleKey(prefKey)) { 524 | prefKey = ""; 525 | } 526 | 527 | if (!checkAccess(prefPath.fileName, prefKey, write)) { 528 | throw new SecurityException("Insufficient permissions to access: " + prefPath); 529 | } 530 | } 531 | 532 | /** 533 | * Returns the {@link SharedPreferences} instance with the specified name. 534 | * This is essentially equivalent to {@link Context#getSharedPreferences(String, int)}, 535 | * except that it will used the internally cached version, and throws an 536 | * exception if the provider was not configured to access that preference file. 537 | * 538 | * @param prefFileName The name of the preference file to access. 539 | * @return The {@link SharedPreferences} instance with the specified file name. 540 | */ 541 | private SharedPreferences getSharedPreferencesByName(String prefFileName) { 542 | SharedPreferences prefs = mPreferences.get(prefFileName); 543 | if (prefs == null) { 544 | throw new IllegalArgumentException("Unknown preference file name: " + prefFileName); 545 | } 546 | return prefs; 547 | } 548 | 549 | /** 550 | * Returns the file name for a {@link SharedPreferences} instance. 551 | * Throws an exception if the provider was not configured to access 552 | * the specified preferences. 553 | * 554 | * @param prefs The shared preferences object. 555 | * @return The name of the preference file. 556 | */ 557 | private String getSharedPreferencesFileName(SharedPreferences prefs) { 558 | for (Map.Entry entry : mPreferences.entrySet()) { 559 | if (entry.getValue() == prefs) { 560 | return entry.getKey(); 561 | } 562 | } 563 | throw new IllegalArgumentException("Unknown preference file"); 564 | } 565 | 566 | /** 567 | * Get the corresponding {@link RemotePreferenceFile} object for a 568 | * {@link SharedPreferences} instance. Throws an exception if the 569 | * provider was not configured to access the specified preferences. 570 | * 571 | * @param prefs The shared preferences object. 572 | * @return The corresponding {@link RemotePreferenceFile} object. 573 | */ 574 | private RemotePreferenceFile getSharedPreferencesFile(SharedPreferences prefs) { 575 | String prefFileName = getSharedPreferencesFileName(prefs); 576 | for (RemotePreferenceFile file : mPrefFiles) { 577 | if (file.getFileName().equals(prefFileName)) { 578 | return file; 579 | } 580 | } 581 | throw new IllegalArgumentException("Unknown preference file"); 582 | } 583 | 584 | /** 585 | * Returns the {@link SharedPreferences} instance with the specified name, 586 | * checking that the caller has permissions to access the specified key within 587 | * that file. If not, an exception will be thrown. 588 | * 589 | * @param prefPath The preference file and key to be accessed. 590 | * @param write Whether the operation will modify the preference. 591 | * @return The {@link SharedPreferences} instance with the specified file name. 592 | */ 593 | private SharedPreferences getSharedPreferencesOrThrow(RemotePreferencePath prefPath, boolean write) { 594 | checkAccessOrThrow(prefPath, write); 595 | return getSharedPreferencesByName(prefPath.fileName); 596 | } 597 | 598 | /** 599 | * Builds a URI for the specified preference file and key that can be used 600 | * to later query the same preference. 601 | * 602 | * @param prefFileName The preference file. 603 | * @param prefKey The preference key. 604 | * @return A URI representing the specified preference. 605 | */ 606 | private Uri getPreferenceUri(String prefFileName, String prefKey) { 607 | Uri.Builder builder = mBaseUri.buildUpon().appendPath(prefFileName); 608 | if (isSingleKey(prefKey)) { 609 | builder.appendPath(prefKey); 610 | } 611 | return builder.build(); 612 | } 613 | } 614 | -------------------------------------------------------------------------------- /library/src/main/java/com/crossbowffs/remotepreferences/RemotePreferenceUriParser.java: -------------------------------------------------------------------------------- 1 | package com.crossbowffs.remotepreferences; 2 | 3 | import android.content.UriMatcher; 4 | import android.net.Uri; 5 | 6 | import java.util.List; 7 | 8 | /** 9 | * Decodes URIs passed between {@link RemotePreferences} and {@link RemotePreferenceProvider}. 10 | */ 11 | /* package */ class RemotePreferenceUriParser { 12 | private static final int PREFERENCES_ID = 1; 13 | private static final int PREFERENCE_ID = 2; 14 | 15 | private final UriMatcher mUriMatcher; 16 | 17 | public RemotePreferenceUriParser(String authority) { 18 | mUriMatcher = new UriMatcher(UriMatcher.NO_MATCH); 19 | mUriMatcher.addURI(authority, "*/", PREFERENCES_ID); 20 | mUriMatcher.addURI(authority, "*/*", PREFERENCE_ID); 21 | } 22 | 23 | /** 24 | * Parses the preference file and key from a query URI. If the key 25 | * is not specified, the returned path will contain {@code null} as the key. 26 | * 27 | * @param uri The URI to parse. 28 | * @return A path object containing the preference file name and key. 29 | */ 30 | public RemotePreferencePath parse(Uri uri) { 31 | int match = mUriMatcher.match(uri); 32 | if (match != PREFERENCE_ID && match != PREFERENCES_ID) { 33 | throw new IllegalArgumentException("Invalid URI: " + uri); 34 | } 35 | 36 | // The URI must fall under one of these patterns: 37 | // 38 | // content://authority/prefFileName/prefKey 39 | // content://authority/prefFileName/ 40 | // content://authority/prefFileName 41 | // 42 | // The match ID will be PREFERENCE_ID under the first case, 43 | // and PREFERENCES_ID under the second and third cases 44 | // (UriMatcher ignores trailing slashes). 45 | List pathSegments = uri.getPathSegments(); 46 | String prefFileName = pathSegments.get(0); 47 | String prefKey = null; 48 | if (match == PREFERENCE_ID) { 49 | prefKey = pathSegments.get(1); 50 | } 51 | return new RemotePreferencePath(prefFileName, prefKey); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /library/src/main/java/com/crossbowffs/remotepreferences/RemotePreferences.java: -------------------------------------------------------------------------------- 1 | package com.crossbowffs.remotepreferences; 2 | 3 | import android.annotation.TargetApi; 4 | import android.content.ContentValues; 5 | import android.content.Context; 6 | import android.content.SharedPreferences; 7 | import android.database.ContentObserver; 8 | import android.database.Cursor; 9 | import android.net.Uri; 10 | import android.os.Build; 11 | import android.os.Handler; 12 | 13 | import java.lang.ref.WeakReference; 14 | import java.util.ArrayList; 15 | import java.util.HashMap; 16 | import java.util.Map; 17 | import java.util.Set; 18 | import java.util.WeakHashMap; 19 | 20 | /** 21 | *

22 | * Provides a {@link SharedPreferences} compatible API to 23 | * {@link RemotePreferenceProvider}. See {@link RemotePreferenceProvider} 24 | * for more information. 25 | *

26 | * 27 | *

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

32 | */ 33 | public class RemotePreferences implements SharedPreferences { 34 | private final Context mContext; 35 | private final Handler mHandler; 36 | private final Uri mBaseUri; 37 | private final boolean mStrictMode; 38 | private final WeakHashMap mListeners; 39 | private final RemotePreferenceUriParser mUriParser; 40 | 41 | /** 42 | * Initializes a new remote preferences object, with strict 43 | * mode disabled. 44 | * 45 | * @param context Used to access the preference provider. 46 | * @param authority The authority of the preference provider. 47 | * @param prefFileName The name of the preference file to access. 48 | */ 49 | public RemotePreferences(Context context, String authority, String prefFileName) { 50 | this(context, authority, prefFileName, false); 51 | } 52 | 53 | /** 54 | * Initializes a new remote preferences object. If {@code strictMode} 55 | * is {@code true} and the remote preference provider cannot be accessed, 56 | * read/write operations on this object will throw a 57 | * {@link RemotePreferenceAccessException}. Otherwise, default values 58 | * will be returned. 59 | * 60 | * @param context Used to access the preference provider. 61 | * @param authority The authority of the preference provider. 62 | * @param prefFileName The name of the preference file to access. 63 | * @param strictMode Whether strict mode is enabled. 64 | */ 65 | public RemotePreferences(Context context, String authority, String prefFileName, boolean strictMode) { 66 | this(context, new Handler(context.getMainLooper()), authority, prefFileName, strictMode); 67 | } 68 | 69 | /** 70 | * Initializes a new remote preferences object. If {@code strictMode} 71 | * is {@code true} and the remote preference provider cannot be accessed, 72 | * read/write operations on this object will throw a 73 | * {@link RemotePreferenceAccessException}. Otherwise, default values 74 | * will be returned. 75 | * 76 | * @param context Used to access the preference provider. 77 | * @param handler Used to receive preference change events. 78 | * @param authority The authority of the preference provider. 79 | * @param prefFileName The name of the preference file to access. 80 | * @param strictMode Whether strict mode is enabled. 81 | */ 82 | /* package */ RemotePreferences(Context context, Handler handler, String authority, String prefFileName, boolean strictMode) { 83 | checkNotNull("context", context); 84 | checkNotNull("handler", handler); 85 | checkNotNull("authority", authority); 86 | checkNotNull("prefFileName", prefFileName); 87 | mContext = context; 88 | mHandler = handler; 89 | mBaseUri = Uri.parse("content://" + authority).buildUpon().appendPath(prefFileName).build(); 90 | mStrictMode = strictMode; 91 | mListeners = new WeakHashMap(); 92 | mUriParser = new RemotePreferenceUriParser(authority); 93 | } 94 | 95 | @Override 96 | public Map getAll() { 97 | return queryAll(); 98 | } 99 | 100 | @Override 101 | public String getString(String key, String defValue) { 102 | return (String)querySingle(key, defValue, RemoteContract.TYPE_STRING); 103 | } 104 | 105 | @Override 106 | @TargetApi(11) 107 | public Set getStringSet(String key, Set defValues) { 108 | if (Build.VERSION.SDK_INT < 11) { 109 | throw new UnsupportedOperationException("String sets only supported on API 11 and above"); 110 | } 111 | return RemoteUtils.castStringSet(querySingle(key, defValues, RemoteContract.TYPE_STRING_SET)); 112 | } 113 | 114 | @Override 115 | public int getInt(String key, int defValue) { 116 | return (Integer)querySingle(key, defValue, RemoteContract.TYPE_INT); 117 | } 118 | 119 | @Override 120 | public long getLong(String key, long defValue) { 121 | return (Long)querySingle(key, defValue, RemoteContract.TYPE_LONG); 122 | } 123 | 124 | @Override 125 | public float getFloat(String key, float defValue) { 126 | return (Float)querySingle(key, defValue, RemoteContract.TYPE_FLOAT); 127 | } 128 | 129 | @Override 130 | public boolean getBoolean(String key, boolean defValue) { 131 | return (Boolean)querySingle(key, defValue, RemoteContract.TYPE_BOOLEAN); 132 | } 133 | 134 | @Override 135 | public boolean contains(String key) { 136 | return containsKey(key); 137 | } 138 | 139 | @Override 140 | public Editor edit() { 141 | return new RemotePreferencesEditor(); 142 | } 143 | 144 | @Override 145 | public void registerOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) { 146 | checkNotNull("listener", listener); 147 | if (mListeners.containsKey(listener)) return; 148 | PreferenceContentObserver observer = new PreferenceContentObserver(listener); 149 | mListeners.put(listener, observer); 150 | mContext.getContentResolver().registerContentObserver(mBaseUri, true, observer); 151 | } 152 | 153 | @Override 154 | public void unregisterOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) { 155 | checkNotNull("listener", listener); 156 | PreferenceContentObserver observer = mListeners.remove(listener); 157 | if (observer != null) { 158 | mContext.getContentResolver().unregisterContentObserver(observer); 159 | } 160 | } 161 | 162 | /** 163 | * If {@code object} is {@code null}, throws an exception. 164 | * 165 | * @param name The name of the object, for use in the exception message. 166 | * @param object The object to check. 167 | */ 168 | private static void checkNotNull(String name, Object object) { 169 | if (object == null) { 170 | throw new IllegalArgumentException(name + " is null"); 171 | } 172 | } 173 | 174 | /** 175 | * If {@code key} is {@code null} or {@code ""}, throws an exception. 176 | * 177 | * @param key The object to check. 178 | */ 179 | private static void checkKeyNotEmpty(String key) { 180 | if (key == null || key.length() == 0) { 181 | throw new IllegalArgumentException("Key is null or empty"); 182 | } 183 | } 184 | 185 | /** 186 | * If strict mode is enabled, wraps and throws the given exception. 187 | * Otherwise, does nothing. 188 | * 189 | * @param e The exception to wrap. 190 | */ 191 | private void wrapException(Exception e) { 192 | if (mStrictMode) { 193 | throw new RemotePreferenceAccessException(e); 194 | } 195 | } 196 | 197 | /** 198 | * Queries the specified URI. If the query fails and strict mode is 199 | * enabled, an exception will be thrown; otherwise {@code null} will 200 | * be returned. 201 | * 202 | * @param uri The URI to query. 203 | * @param columns The columns to include in the returned cursor. 204 | * @return A cursor used to access the queried preference data. 205 | */ 206 | private Cursor query(Uri uri, String[] columns) { 207 | Cursor cursor = null; 208 | try { 209 | cursor = mContext.getContentResolver().query(uri, columns, null, null, null); 210 | } catch (Exception e) { 211 | wrapException(e); 212 | } 213 | if (cursor == null && mStrictMode) { 214 | throw new RemotePreferenceAccessException("query() failed or returned null cursor"); 215 | } 216 | return cursor; 217 | } 218 | 219 | /** 220 | * Writes multiple preferences at once to the preference provider. 221 | * If the operation fails and strict mode is enabled, an exception 222 | * will be thrown; otherwise {@code false} will be returned. 223 | * 224 | * @param uri The URI to modify. 225 | * @param values The values to write. 226 | * @return Whether the operation succeeded. 227 | */ 228 | private boolean bulkInsert(Uri uri, ContentValues[] values) { 229 | int count; 230 | try { 231 | count = mContext.getContentResolver().bulkInsert(uri, values); 232 | } catch (Exception e) { 233 | wrapException(e); 234 | return false; 235 | } 236 | if (count != values.length && mStrictMode) { 237 | throw new RemotePreferenceAccessException("bulkInsert() failed"); 238 | } 239 | return count == values.length; 240 | } 241 | 242 | /** 243 | * Reads a single preference from the preference provider. This may 244 | * throw a {@link ClassCastException} even if strict mode is disabled 245 | * if the provider returns an incompatible type. If strict mode is 246 | * disabled and the preference cannot be read, the default value is returned. 247 | * 248 | * @param key The preference key to read. 249 | * @param defValue The default value, if there is no existing value. 250 | * @param expectedType The expected type of the value. 251 | * @return The value of the preference, or {@code defValue} if no value exists. 252 | */ 253 | private Object querySingle(String key, Object defValue, int expectedType) { 254 | checkKeyNotEmpty(key); 255 | Uri uri = mBaseUri.buildUpon().appendPath(key).build(); 256 | String[] columns = {RemoteContract.COLUMN_TYPE, RemoteContract.COLUMN_VALUE}; 257 | Cursor cursor = query(uri, columns); 258 | try { 259 | if (cursor == null || !cursor.moveToFirst()) { 260 | return defValue; 261 | } 262 | 263 | int typeCol = cursor.getColumnIndexOrThrow(RemoteContract.COLUMN_TYPE); 264 | int type = cursor.getInt(typeCol); 265 | if (type == RemoteContract.TYPE_NULL) { 266 | return defValue; 267 | } else if (type != expectedType) { 268 | throw new ClassCastException("Preference type mismatch"); 269 | } 270 | 271 | int valueCol = cursor.getColumnIndexOrThrow(RemoteContract.COLUMN_VALUE); 272 | return getValue(cursor, typeCol, valueCol); 273 | } finally { 274 | if (cursor != null) { 275 | cursor.close(); 276 | } 277 | } 278 | } 279 | 280 | /** 281 | * Reads all preferences from the preference provider. If strict 282 | * mode is disabled and the preferences cannot be read, an empty 283 | * map is returned. 284 | * 285 | * @return A map containing all preferences. 286 | */ 287 | private Map queryAll() { 288 | Uri uri = mBaseUri.buildUpon().appendPath("").build(); 289 | String[] columns = {RemoteContract.COLUMN_KEY, RemoteContract.COLUMN_TYPE, RemoteContract.COLUMN_VALUE}; 290 | Cursor cursor = query(uri, columns); 291 | try { 292 | HashMap map = new HashMap(); 293 | if (cursor == null) { 294 | return map; 295 | } 296 | 297 | int keyCol = cursor.getColumnIndexOrThrow(RemoteContract.COLUMN_KEY); 298 | int typeCol = cursor.getColumnIndexOrThrow(RemoteContract.COLUMN_TYPE); 299 | int valueCol = cursor.getColumnIndexOrThrow(RemoteContract.COLUMN_VALUE); 300 | while (cursor.moveToNext()) { 301 | String key = cursor.getString(keyCol); 302 | map.put(key, getValue(cursor, typeCol, valueCol)); 303 | } 304 | return map; 305 | } finally { 306 | if (cursor != null) { 307 | cursor.close(); 308 | } 309 | } 310 | } 311 | 312 | /** 313 | * Checks whether the preference exists. If strict mode is 314 | * disabled and the preferences cannot be read, {@code false} 315 | * is returned. 316 | * 317 | * @param key The key to check existence for. 318 | * @return Whether the preference exists. 319 | */ 320 | private boolean containsKey(String key) { 321 | checkKeyNotEmpty(key); 322 | Uri uri = mBaseUri.buildUpon().appendPath(key).build(); 323 | String[] columns = {RemoteContract.COLUMN_TYPE}; 324 | Cursor cursor = query(uri, columns); 325 | try { 326 | if (cursor == null || !cursor.moveToFirst()) { 327 | return false; 328 | } 329 | 330 | int typeCol = cursor.getColumnIndexOrThrow(RemoteContract.COLUMN_TYPE); 331 | return cursor.getInt(typeCol) != RemoteContract.TYPE_NULL; 332 | } finally { 333 | if (cursor != null) { 334 | cursor.close(); 335 | } 336 | } 337 | } 338 | 339 | /** 340 | * Extracts a preference value from a cursor. Performs deserialization 341 | * of the value if necessary. 342 | * 343 | * @param cursor The cursor containing the preference value. 344 | * @param typeCol The index containing the {@link RemoteContract#COLUMN_TYPE} column. 345 | * @param valueCol The index containing the {@link RemoteContract#COLUMN_VALUE} column. 346 | * @return The value from the cursor. 347 | */ 348 | private Object getValue(Cursor cursor, int typeCol, int valueCol) { 349 | int expectedType = cursor.getInt(typeCol); 350 | switch (expectedType) { 351 | case RemoteContract.TYPE_STRING: 352 | return cursor.getString(valueCol); 353 | case RemoteContract.TYPE_STRING_SET: 354 | return RemoteUtils.deserializeStringSet(cursor.getString(valueCol)); 355 | case RemoteContract.TYPE_INT: 356 | return cursor.getInt(valueCol); 357 | case RemoteContract.TYPE_LONG: 358 | return cursor.getLong(valueCol); 359 | case RemoteContract.TYPE_FLOAT: 360 | return cursor.getFloat(valueCol); 361 | case RemoteContract.TYPE_BOOLEAN: 362 | return cursor.getInt(valueCol) != 0; 363 | default: 364 | throw new AssertionError("Invalid expected type: " + expectedType); 365 | } 366 | } 367 | 368 | /** 369 | * Implementation of the {@link SharedPreferences.Editor} interface 370 | * for use with RemotePreferences. 371 | */ 372 | private class RemotePreferencesEditor implements Editor { 373 | private final ArrayList mValues = new ArrayList(); 374 | 375 | /** 376 | * Creates a new {@link ContentValues} with the specified key and 377 | * type columns pre-filled. The {@link RemoteContract#COLUMN_VALUE} 378 | * field is NOT filled in. 379 | * 380 | * @param key The preference key. 381 | * @param type The preference type. 382 | * @return The pre-filled values. 383 | */ 384 | private ContentValues createContentValues(String key, int type) { 385 | ContentValues values = new ContentValues(4); 386 | values.put(RemoteContract.COLUMN_KEY, key); 387 | values.put(RemoteContract.COLUMN_TYPE, type); 388 | return values; 389 | } 390 | 391 | /** 392 | * Creates an operation to add/set a new preference. Again, the 393 | * {@link RemoteContract#COLUMN_VALUE} field is NOT filled in. 394 | * This will also add the values to the operation queue. 395 | * 396 | * @param key The preference key to add. 397 | * @param type The preference type to add. 398 | * @return The pre-filled values. 399 | */ 400 | private ContentValues createAddOp(String key, int type) { 401 | checkKeyNotEmpty(key); 402 | ContentValues values = createContentValues(key, type); 403 | mValues.add(values); 404 | return values; 405 | } 406 | 407 | /** 408 | * Creates an operation to delete a preference. All fields 409 | * are pre-filled. This will also add the values to the 410 | * operation queue. 411 | * 412 | * @param key The preference key to delete. 413 | * @return The pre-filled values. 414 | */ 415 | private ContentValues createRemoveOp(String key) { 416 | // Note: Remove operations are inserted at the beginning 417 | // of the list (this preserves the SharedPreferences behavior 418 | // that all removes are performed before any adds) 419 | ContentValues values = createContentValues(key, RemoteContract.TYPE_NULL); 420 | values.putNull(RemoteContract.COLUMN_VALUE); 421 | mValues.add(0, values); 422 | return values; 423 | } 424 | 425 | @Override 426 | public Editor putString(String key, String value) { 427 | createAddOp(key, RemoteContract.TYPE_STRING).put(RemoteContract.COLUMN_VALUE, value); 428 | return this; 429 | } 430 | 431 | @Override 432 | @TargetApi(11) 433 | public Editor putStringSet(String key, Set value) { 434 | if (Build.VERSION.SDK_INT < 11) { 435 | throw new UnsupportedOperationException("String sets only supported on API 11 and above"); 436 | } 437 | String serializedSet = RemoteUtils.serializeStringSet(value); 438 | createAddOp(key, RemoteContract.TYPE_STRING_SET).put(RemoteContract.COLUMN_VALUE, serializedSet); 439 | return this; 440 | } 441 | 442 | @Override 443 | public Editor putInt(String key, int value) { 444 | createAddOp(key, RemoteContract.TYPE_INT).put(RemoteContract.COLUMN_VALUE, value); 445 | return this; 446 | } 447 | 448 | @Override 449 | public Editor putLong(String key, long value) { 450 | createAddOp(key, RemoteContract.TYPE_LONG).put(RemoteContract.COLUMN_VALUE, value); 451 | return this; 452 | } 453 | 454 | @Override 455 | public Editor putFloat(String key, float value) { 456 | createAddOp(key, RemoteContract.TYPE_FLOAT).put(RemoteContract.COLUMN_VALUE, value); 457 | return this; 458 | } 459 | 460 | @Override 461 | public Editor putBoolean(String key, boolean value) { 462 | createAddOp(key, RemoteContract.TYPE_BOOLEAN).put(RemoteContract.COLUMN_VALUE, value ? 1 : 0); 463 | return this; 464 | } 465 | 466 | @Override 467 | public Editor remove(String key) { 468 | checkKeyNotEmpty(key); 469 | createRemoveOp(key); 470 | return this; 471 | } 472 | 473 | @Override 474 | public Editor clear() { 475 | createRemoveOp(""); 476 | return this; 477 | } 478 | 479 | @Override 480 | public boolean commit() { 481 | ContentValues[] values = mValues.toArray(new ContentValues[mValues.size()]); 482 | Uri uri = mBaseUri.buildUpon().appendPath("").build(); 483 | return bulkInsert(uri, values); 484 | } 485 | 486 | @Override 487 | public void apply() { 488 | commit(); 489 | } 490 | } 491 | 492 | /** 493 | * {@link ContentObserver} subclass used to monitor preference changes 494 | * in the remote preference provider. When a change is detected, this will notify 495 | * the corresponding {@link SharedPreferences.OnSharedPreferenceChangeListener}. 496 | */ 497 | private class PreferenceContentObserver extends ContentObserver { 498 | private final WeakReference mListener; 499 | 500 | private PreferenceContentObserver(OnSharedPreferenceChangeListener listener) { 501 | super(mHandler); 502 | mListener = new WeakReference(listener); 503 | } 504 | 505 | @Override 506 | public boolean deliverSelfNotifications() { 507 | return true; 508 | } 509 | 510 | @Override 511 | public void onChange(boolean selfChange, Uri uri) { 512 | RemotePreferencePath path = mUriParser.parse(uri); 513 | 514 | // We use a weak reference to mimic the behavior of SharedPreferences. 515 | // The code which registered the listener is responsible for holding a 516 | // reference to it. If at any point we find that the listener has been 517 | // garbage collected, we unregister the observer. 518 | OnSharedPreferenceChangeListener listener = mListener.get(); 519 | if (listener == null) { 520 | mContext.getContentResolver().unregisterContentObserver(this); 521 | } else { 522 | listener.onSharedPreferenceChanged(RemotePreferences.this, path.key); 523 | } 524 | } 525 | } 526 | } 527 | -------------------------------------------------------------------------------- /library/src/main/java/com/crossbowffs/remotepreferences/RemoteUtils.java: -------------------------------------------------------------------------------- 1 | package com.crossbowffs.remotepreferences; 2 | 3 | import java.util.HashSet; 4 | import java.util.Set; 5 | 6 | /** 7 | * Common utilities used to serialize and deserialize 8 | * preferences between the preference provider and caller. 9 | */ 10 | /* package */ final class RemoteUtils { 11 | private RemoteUtils() {} 12 | 13 | /** 14 | * Casts the parameter to a string set. Useful to avoid the unchecked 15 | * warning that would normally come with the cast. The value must 16 | * already be a string set; this does not deserialize it. 17 | * 18 | * @param value The value, as type {@link Object}. 19 | * @return The value, as type {@link Set}. 20 | */ 21 | @SuppressWarnings("unchecked") 22 | public static Set castStringSet(Object value) { 23 | return (Set)value; 24 | } 25 | 26 | /** 27 | * Returns the {@code TYPE_*} constant corresponding to the given 28 | * object's type. 29 | * 30 | * @param value The original object. 31 | * @return One of the {@link RemoteContract}{@code .TYPE_*} constants. 32 | */ 33 | public static int getPreferenceType(Object value) { 34 | if (value == null) return RemoteContract.TYPE_NULL; 35 | if (value instanceof String) return RemoteContract.TYPE_STRING; 36 | if (value instanceof Set) return RemoteContract.TYPE_STRING_SET; 37 | if (value instanceof Integer) return RemoteContract.TYPE_INT; 38 | if (value instanceof Long) return RemoteContract.TYPE_LONG; 39 | if (value instanceof Float) return RemoteContract.TYPE_FLOAT; 40 | if (value instanceof Boolean) return RemoteContract.TYPE_BOOLEAN; 41 | throw new AssertionError("Unknown preference type: " + value.getClass()); 42 | } 43 | 44 | /** 45 | * Serializes the specified object to a format that is safe to use 46 | * with {@link android.content.ContentValues}. To recover the original 47 | * object, use {@link #deserializeInput(Object, int)}. 48 | * 49 | * @param value The object to serialize. 50 | * @return The serialized object. 51 | */ 52 | public static Object serializeOutput(Object value) { 53 | if (value instanceof Boolean) { 54 | return serializeBoolean((Boolean)value); 55 | } else if (value instanceof Set) { 56 | return serializeStringSet(castStringSet(value)); 57 | } else { 58 | return value; 59 | } 60 | } 61 | 62 | /** 63 | * Deserializes an object that was serialized using 64 | * {@link #serializeOutput(Object)}. If the expected type does 65 | * not match the actual type of the object, a {@link ClassCastException} 66 | * will be thrown. 67 | * 68 | * @param value The object to deserialize. 69 | * @param expectedType The expected type of the deserialized object. 70 | * @return The deserialized object. 71 | */ 72 | public static Object deserializeInput(Object value, int expectedType) { 73 | if (expectedType == RemoteContract.TYPE_NULL) { 74 | if (value != null) { 75 | throw new IllegalArgumentException("Expected null, got non-null value"); 76 | } else { 77 | return null; 78 | } 79 | } 80 | try { 81 | switch (expectedType) { 82 | case RemoteContract.TYPE_STRING: 83 | return (String)value; 84 | case RemoteContract.TYPE_STRING_SET: 85 | return deserializeStringSet((String)value); 86 | case RemoteContract.TYPE_INT: 87 | return (Integer)value; 88 | case RemoteContract.TYPE_LONG: 89 | return (Long)value; 90 | case RemoteContract.TYPE_FLOAT: 91 | return (Float)value; 92 | case RemoteContract.TYPE_BOOLEAN: 93 | return deserializeBoolean(value); 94 | } 95 | } catch (ClassCastException e) { 96 | throw new IllegalArgumentException("Expected type " + expectedType + ", got " + value.getClass(), e); 97 | } 98 | throw new IllegalArgumentException("Unknown type: " + expectedType); 99 | } 100 | 101 | /** 102 | * Serializes a {@link Boolean} to a format that is safe to use 103 | * with {@link android.content.ContentValues}. 104 | * 105 | * @param value The {@link Boolean} to serialize. 106 | * @return 1 if {@code value} is {@code true}, 0 if {@code value} is {@code false}. 107 | */ 108 | private static Integer serializeBoolean(Boolean value) { 109 | if (value == null) { 110 | return null; 111 | } else { 112 | return value ? 1 : 0; 113 | } 114 | } 115 | 116 | /** 117 | * Deserializes a {@link Boolean} that was serialized using 118 | * {@link #serializeBoolean(Boolean)}. 119 | * 120 | * @param value The {@link Boolean} to deserialize. 121 | * @return {@code true} if {@code value} is 1, {@code false} if {@code value} is 0. 122 | */ 123 | private static Boolean deserializeBoolean(Object value) { 124 | if (value == null) { 125 | return null; 126 | } else if (value instanceof Boolean) { 127 | return (Boolean)value; 128 | } else { 129 | return (Integer)value != 0; 130 | } 131 | } 132 | 133 | /** 134 | * Serializes a {@link Set} to a format that is safe to use 135 | * with {@link android.content.ContentValues}. 136 | * 137 | * @param stringSet The {@link Set} to serialize. 138 | * @return The serialized string set. 139 | */ 140 | public static String serializeStringSet(Set stringSet) { 141 | if (stringSet == null) { 142 | return null; 143 | } 144 | StringBuilder sb = new StringBuilder(); 145 | for (String s : stringSet) { 146 | sb.append(s.replace("\\", "\\\\").replace(";", "\\;")); 147 | sb.append(';'); 148 | } 149 | return sb.toString(); 150 | } 151 | 152 | /** 153 | * Deserializes a {@link Set} that was serialized using 154 | * {@link #serializeStringSet(Set)}. 155 | * 156 | * @param serializedString The {@link Set} to deserialize. 157 | * @return The deserialized string set. 158 | */ 159 | public static Set deserializeStringSet(String serializedString) { 160 | if (serializedString == null) { 161 | return null; 162 | } 163 | HashSet stringSet = new HashSet(); 164 | StringBuilder sb = new StringBuilder(); 165 | for (int i = 0; i < serializedString.length(); ++i) { 166 | char c = serializedString.charAt(i); 167 | if (c == '\\') { 168 | char next = serializedString.charAt(++i); 169 | sb.append(next); 170 | } else if (c == ';') { 171 | stringSet.add(sb.toString()); 172 | sb.delete(0, sb.length()); 173 | } else { 174 | sb.append(c); 175 | } 176 | } 177 | 178 | // We require that the serialized string ends with a ; per element 179 | // since that's how we distinguish empty sets from sets containing 180 | // an empty string. Assume caller is doing unsafe string joins 181 | // instead of using the serializeStringSet API, and fail fast. 182 | if (sb.length() != 0) { 183 | throw new IllegalArgumentException("Serialized string set contains trailing chars"); 184 | } 185 | 186 | return stringSet; 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | include(":library") 2 | include(":testapp") 3 | -------------------------------------------------------------------------------- /testapp/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("com.android.application") 3 | } 4 | 5 | android { 6 | namespace = "com.crossbowffs.remotepreferences.testapp" 7 | compileSdk = 34 8 | 9 | defaultConfig { 10 | minSdk = 14 11 | targetSdk = 34 12 | versionCode = 1 13 | versionName = "1.0" 14 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 15 | } 16 | } 17 | 18 | dependencies { 19 | implementation(project(":library")) 20 | 21 | androidTestImplementation("junit:junit:4.13.2") 22 | androidTestImplementation("androidx.test:core:1.5.0") 23 | androidTestImplementation("androidx.test:runner:1.5.2") 24 | androidTestImplementation("androidx.test:rules:1.5.0") 25 | androidTestImplementation("androidx.test.ext:junit:1.1.5") 26 | } 27 | -------------------------------------------------------------------------------- /testapp/src/androidTest/java/com/crossbowffs/remotepreferences/RemotePreferenceProviderTest.java: -------------------------------------------------------------------------------- 1 | package com.crossbowffs.remotepreferences; 2 | 3 | import android.content.ContentResolver; 4 | import android.content.ContentValues; 5 | import android.content.Context; 6 | import android.content.SharedPreferences; 7 | import android.database.Cursor; 8 | import android.net.Uri; 9 | 10 | import androidx.test.ext.junit.runners.AndroidJUnit4; 11 | import androidx.test.platform.app.InstrumentationRegistry; 12 | 13 | import com.crossbowffs.remotepreferences.testapp.TestConstants; 14 | 15 | import org.junit.Assert; 16 | import org.junit.Before; 17 | import org.junit.Test; 18 | import org.junit.runner.RunWith; 19 | 20 | import java.util.HashSet; 21 | 22 | @RunWith(AndroidJUnit4.class) 23 | public class RemotePreferenceProviderTest { 24 | private Context getLocalContext() { 25 | return InstrumentationRegistry.getInstrumentation().getContext(); 26 | } 27 | 28 | private Context getRemoteContext() { 29 | return InstrumentationRegistry.getInstrumentation().getTargetContext(); 30 | } 31 | 32 | private SharedPreferences getSharedPreferences() { 33 | Context context = getRemoteContext(); 34 | return context.getSharedPreferences(TestConstants.PREF_FILE, Context.MODE_PRIVATE); 35 | } 36 | 37 | private Uri getQueryUri(String key) { 38 | String uri = "content://" + TestConstants.AUTHORITY + "/" + TestConstants.PREF_FILE; 39 | if (key != null) { 40 | uri += "/" + key; 41 | } 42 | return Uri.parse(uri); 43 | } 44 | 45 | @Before 46 | public void resetPreferences() { 47 | getSharedPreferences().edit().clear().commit(); 48 | } 49 | 50 | @Test 51 | public void testQueryAllPrefs() { 52 | getSharedPreferences() 53 | .edit() 54 | .putString("string", "foobar") 55 | .putInt("int", 1337) 56 | .apply(); 57 | 58 | ContentResolver resolver = getLocalContext().getContentResolver(); 59 | Cursor q = resolver.query(getQueryUri(null), null, null, null, null); 60 | Assert.assertEquals(2, q.getCount()); 61 | 62 | int key = q.getColumnIndex(RemoteContract.COLUMN_KEY); 63 | int type = q.getColumnIndex(RemoteContract.COLUMN_TYPE); 64 | int value = q.getColumnIndex(RemoteContract.COLUMN_VALUE); 65 | 66 | while (q.moveToNext()) { 67 | if (q.getString(key).equals("string")) { 68 | Assert.assertEquals(RemoteContract.TYPE_STRING, q.getInt(type)); 69 | Assert.assertEquals("foobar", q.getString(value)); 70 | } else if (q.getString(key).equals("int")) { 71 | Assert.assertEquals(RemoteContract.TYPE_INT, q.getInt(type)); 72 | Assert.assertEquals(1337, q.getInt(value)); 73 | } else { 74 | Assert.fail(); 75 | } 76 | } 77 | } 78 | 79 | @Test 80 | public void testQuerySinglePref() { 81 | getSharedPreferences() 82 | .edit() 83 | .putString("string", "foobar") 84 | .putInt("int", 1337) 85 | .apply(); 86 | 87 | ContentResolver resolver = getLocalContext().getContentResolver(); 88 | Cursor q = resolver.query(getQueryUri("string"), null, null, null, null); 89 | Assert.assertEquals(1, q.getCount()); 90 | 91 | int key = q.getColumnIndex(RemoteContract.COLUMN_KEY); 92 | int type = q.getColumnIndex(RemoteContract.COLUMN_TYPE); 93 | int value = q.getColumnIndex(RemoteContract.COLUMN_VALUE); 94 | 95 | q.moveToFirst(); 96 | Assert.assertEquals("string", q.getString(key)); 97 | Assert.assertEquals(RemoteContract.TYPE_STRING, q.getInt(type)); 98 | Assert.assertEquals("foobar", q.getString(value)); 99 | } 100 | 101 | @Test 102 | public void testQueryFailPermissionCheck() { 103 | getSharedPreferences() 104 | .edit() 105 | .putString(TestConstants.UNREADABLE_PREF_KEY, "foobar") 106 | .apply(); 107 | ContentResolver resolver = getLocalContext().getContentResolver(); 108 | try { 109 | resolver.query(getQueryUri(TestConstants.UNREADABLE_PREF_KEY), null, null, null, null); 110 | Assert.fail(); 111 | } catch (SecurityException e) { 112 | // Expected 113 | } 114 | } 115 | 116 | @Test 117 | public void testInsertPref() { 118 | ContentValues values = new ContentValues(); 119 | values.put(RemoteContract.COLUMN_KEY, "string"); 120 | values.put(RemoteContract.COLUMN_TYPE, RemoteContract.TYPE_STRING); 121 | values.put(RemoteContract.COLUMN_VALUE, "foobar"); 122 | 123 | ContentResolver resolver = getLocalContext().getContentResolver(); 124 | Uri uri = resolver.insert(getQueryUri(null), values); 125 | Assert.assertEquals(getQueryUri("string"), uri); 126 | 127 | SharedPreferences prefs = getSharedPreferences(); 128 | Assert.assertEquals("foobar", prefs.getString("string", null)); 129 | } 130 | 131 | @Test 132 | public void testInsertOverridePref() { 133 | SharedPreferences prefs = getSharedPreferences(); 134 | prefs 135 | .edit() 136 | .putString("string", "nyaa") 137 | .putInt("int", 1337) 138 | .apply(); 139 | 140 | ContentValues values = new ContentValues(); 141 | values.put(RemoteContract.COLUMN_KEY, "string"); 142 | values.put(RemoteContract.COLUMN_TYPE, RemoteContract.TYPE_STRING); 143 | values.put(RemoteContract.COLUMN_VALUE, "foobar"); 144 | 145 | ContentResolver resolver = getLocalContext().getContentResolver(); 146 | Uri uri = resolver.insert(getQueryUri(null), values); 147 | Assert.assertEquals(getQueryUri("string"), uri); 148 | 149 | Assert.assertEquals("foobar", prefs.getString("string", null)); 150 | Assert.assertEquals(1337, prefs.getInt("int", 0)); 151 | } 152 | 153 | @Test 154 | public void testInsertPrefKeyInUri() { 155 | ContentValues values = new ContentValues(); 156 | values.put(RemoteContract.COLUMN_TYPE, RemoteContract.TYPE_STRING); 157 | values.put(RemoteContract.COLUMN_VALUE, "foobar"); 158 | 159 | ContentResolver resolver = getLocalContext().getContentResolver(); 160 | Uri uri = resolver.insert(getQueryUri("string"), values); 161 | Assert.assertEquals(getQueryUri("string"), uri); 162 | 163 | SharedPreferences prefs = getSharedPreferences(); 164 | Assert.assertEquals("foobar", prefs.getString("string", null)); 165 | } 166 | 167 | @Test 168 | public void testInsertPrefKeyInUriAndValues() { 169 | ContentValues values = new ContentValues(); 170 | values.put(RemoteContract.COLUMN_KEY, "string"); 171 | values.put(RemoteContract.COLUMN_TYPE, RemoteContract.TYPE_STRING); 172 | values.put(RemoteContract.COLUMN_VALUE, "foobar"); 173 | 174 | ContentResolver resolver = getLocalContext().getContentResolver(); 175 | Uri uri = resolver.insert(getQueryUri("string"), values); 176 | Assert.assertEquals(getQueryUri("string"), uri); 177 | 178 | SharedPreferences prefs = getSharedPreferences(); 179 | Assert.assertEquals("foobar", prefs.getString("string", null)); 180 | } 181 | 182 | @Test 183 | public void testInsertPrefFailKeyInUriAndValuesMismatch() { 184 | ContentValues values = new ContentValues(); 185 | values.put(RemoteContract.COLUMN_KEY, "string"); 186 | values.put(RemoteContract.COLUMN_TYPE, RemoteContract.TYPE_STRING); 187 | values.put(RemoteContract.COLUMN_VALUE, "foobar"); 188 | 189 | ContentResolver resolver = getLocalContext().getContentResolver(); 190 | try { 191 | resolver.insert(getQueryUri("string2"), values); 192 | Assert.fail(); 193 | } catch (IllegalArgumentException e) { 194 | // Expected 195 | } 196 | 197 | SharedPreferences prefs = getSharedPreferences(); 198 | Assert.assertEquals("default", prefs.getString("string", "default")); 199 | } 200 | 201 | @Test 202 | public void testInsertMultiplePrefs() { 203 | ContentValues[] values = new ContentValues[2]; 204 | values[0] = new ContentValues(); 205 | values[0].put(RemoteContract.COLUMN_KEY, "string"); 206 | values[0].put(RemoteContract.COLUMN_TYPE, RemoteContract.TYPE_STRING); 207 | values[0].put(RemoteContract.COLUMN_VALUE, "foobar"); 208 | 209 | values[1] = new ContentValues(); 210 | values[1].put(RemoteContract.COLUMN_KEY, "int"); 211 | values[1].put(RemoteContract.COLUMN_TYPE, RemoteContract.TYPE_INT); 212 | values[1].put(RemoteContract.COLUMN_VALUE, 1337); 213 | 214 | ContentResolver resolver = getLocalContext().getContentResolver(); 215 | int ret = resolver.bulkInsert(getQueryUri(null), values); 216 | Assert.assertEquals(2, ret); 217 | 218 | SharedPreferences prefs = getSharedPreferences(); 219 | Assert.assertEquals("foobar", prefs.getString("string", null)); 220 | Assert.assertEquals(1337, prefs.getInt("int", 0)); 221 | } 222 | 223 | @Test 224 | public void testInsertFailPermissionCheck() { 225 | ContentValues[] values = new ContentValues[2]; 226 | values[0] = new ContentValues(); 227 | values[0].put(RemoteContract.COLUMN_KEY, "string"); 228 | values[0].put(RemoteContract.COLUMN_TYPE, RemoteContract.TYPE_STRING); 229 | values[0].put(RemoteContract.COLUMN_VALUE, "foobar"); 230 | 231 | values[1] = new ContentValues(); 232 | values[1].put(RemoteContract.COLUMN_KEY, TestConstants.UNWRITABLE_PREF_KEY); 233 | values[1].put(RemoteContract.COLUMN_TYPE, RemoteContract.TYPE_INT); 234 | values[1].put(RemoteContract.COLUMN_VALUE, 1337); 235 | 236 | ContentResolver resolver = getLocalContext().getContentResolver(); 237 | try { 238 | resolver.bulkInsert(getQueryUri(null), values); 239 | Assert.fail(); 240 | } catch (SecurityException e) { 241 | // Expected 242 | } 243 | 244 | SharedPreferences prefs = getSharedPreferences(); 245 | Assert.assertEquals("default", prefs.getString("string", "default")); 246 | Assert.assertEquals(0, prefs.getInt(TestConstants.UNWRITABLE_PREF_KEY, 0)); 247 | } 248 | 249 | @Test 250 | public void testInsertMultipleFailUriContainingKey() { 251 | ContentValues[] values = new ContentValues[1]; 252 | values[0] = new ContentValues(); 253 | values[0].put(RemoteContract.COLUMN_KEY, "string"); 254 | values[0].put(RemoteContract.COLUMN_TYPE, RemoteContract.TYPE_STRING); 255 | values[0].put(RemoteContract.COLUMN_VALUE, "foobar"); 256 | 257 | ContentResolver resolver = getLocalContext().getContentResolver(); 258 | try { 259 | resolver.bulkInsert(getQueryUri("key"), values); 260 | Assert.fail(); 261 | } catch (IllegalArgumentException e) { 262 | // Expected 263 | } 264 | 265 | SharedPreferences prefs = getSharedPreferences(); 266 | Assert.assertEquals("default", prefs.getString("string", "default")); 267 | } 268 | 269 | @Test 270 | public void testDeletePref() { 271 | SharedPreferences prefs = getSharedPreferences(); 272 | prefs 273 | .edit() 274 | .putString("string", "nyaa") 275 | .apply(); 276 | 277 | ContentResolver resolver = getLocalContext().getContentResolver(); 278 | resolver.delete(getQueryUri("string"), null, null); 279 | 280 | Assert.assertEquals("default", prefs.getString("string", "default")); 281 | } 282 | 283 | @Test 284 | public void testDeleteUnwritablePref() { 285 | SharedPreferences prefs = getSharedPreferences(); 286 | prefs 287 | .edit() 288 | .putString(TestConstants.UNWRITABLE_PREF_KEY, "nyaa") 289 | .apply(); 290 | 291 | ContentResolver resolver = getLocalContext().getContentResolver(); 292 | try { 293 | resolver.delete(getQueryUri(TestConstants.UNWRITABLE_PREF_KEY), null, null); 294 | Assert.fail(); 295 | } catch (SecurityException e) { 296 | // Expected 297 | } 298 | 299 | Assert.assertEquals("nyaa", prefs.getString(TestConstants.UNWRITABLE_PREF_KEY, "default")); 300 | } 301 | 302 | @Test 303 | public void testReadBoolean() { 304 | getSharedPreferences() 305 | .edit() 306 | .putBoolean("true", true) 307 | .putBoolean("false", false) 308 | .apply(); 309 | 310 | ContentResolver resolver = getLocalContext().getContentResolver(); 311 | Cursor q = resolver.query(getQueryUri(null), null, null, null, null); 312 | Assert.assertEquals(2, q.getCount()); 313 | 314 | int key = q.getColumnIndex(RemoteContract.COLUMN_KEY); 315 | int type = q.getColumnIndex(RemoteContract.COLUMN_TYPE); 316 | int value = q.getColumnIndex(RemoteContract.COLUMN_VALUE); 317 | 318 | while (q.moveToNext()) { 319 | if (q.getString(key).equals("true")) { 320 | Assert.assertEquals(RemoteContract.TYPE_BOOLEAN, q.getInt(type)); 321 | Assert.assertEquals(1, q.getInt(value)); 322 | } else if (q.getString(key).equals("false")) { 323 | Assert.assertEquals(RemoteContract.TYPE_BOOLEAN, q.getInt(type)); 324 | Assert.assertEquals(0, q.getInt(value)); 325 | } else { 326 | Assert.fail(); 327 | } 328 | } 329 | } 330 | 331 | @Test 332 | public void testReadStringSet() { 333 | HashSet set = new HashSet<>(); 334 | set.add("foo"); 335 | set.add("bar;"); 336 | set.add("baz"); 337 | set.add(""); 338 | 339 | getSharedPreferences() 340 | .edit() 341 | .putStringSet("pref", set) 342 | .apply(); 343 | 344 | ContentResolver resolver = getLocalContext().getContentResolver(); 345 | Cursor q = resolver.query(getQueryUri("pref"), null, null, null, null); 346 | Assert.assertEquals(1, q.getCount()); 347 | 348 | int key = q.getColumnIndex(RemoteContract.COLUMN_KEY); 349 | int type = q.getColumnIndex(RemoteContract.COLUMN_TYPE); 350 | int value = q.getColumnIndex(RemoteContract.COLUMN_VALUE); 351 | 352 | while (q.moveToNext()) { 353 | if (q.getString(key).equals("pref")) { 354 | Assert.assertEquals(RemoteContract.TYPE_STRING_SET, q.getInt(type)); 355 | String serialized = q.getString(value); 356 | Assert.assertEquals(set, RemoteUtils.deserializeStringSet(serialized)); 357 | } else { 358 | Assert.fail(); 359 | } 360 | } 361 | } 362 | 363 | @Test 364 | public void testInsertStringSet() { 365 | HashSet set = new HashSet<>(); 366 | set.add("foo"); 367 | set.add("bar;"); 368 | set.add("baz"); 369 | set.add(""); 370 | 371 | ContentValues values = new ContentValues(); 372 | values.put(RemoteContract.COLUMN_KEY, "pref"); 373 | values.put(RemoteContract.COLUMN_TYPE, RemoteContract.TYPE_STRING_SET); 374 | values.put(RemoteContract.COLUMN_VALUE, RemoteUtils.serializeStringSet(set)); 375 | 376 | ContentResolver resolver = getLocalContext().getContentResolver(); 377 | Uri uri = resolver.insert(getQueryUri(null), values); 378 | Assert.assertEquals(getQueryUri("pref"), uri); 379 | 380 | Assert.assertEquals(set, getSharedPreferences().getStringSet("pref", null)); 381 | } 382 | } 383 | -------------------------------------------------------------------------------- /testapp/src/androidTest/java/com/crossbowffs/remotepreferences/RemotePreferencesTest.java: -------------------------------------------------------------------------------- 1 | package com.crossbowffs.remotepreferences; 2 | 3 | import android.content.Context; 4 | import android.content.SharedPreferences; 5 | import android.os.Build; 6 | import android.os.Handler; 7 | import android.os.HandlerThread; 8 | 9 | import androidx.test.ext.junit.runners.AndroidJUnit4; 10 | import androidx.test.filters.SdkSuppress; 11 | import androidx.test.platform.app.InstrumentationRegistry; 12 | 13 | import com.crossbowffs.remotepreferences.testapp.TestConstants; 14 | import com.crossbowffs.remotepreferences.testapp.TestPreferenceListener; 15 | 16 | import org.junit.Assert; 17 | import org.junit.Before; 18 | import org.junit.Test; 19 | import org.junit.runner.RunWith; 20 | 21 | import java.util.HashSet; 22 | import java.util.Map; 23 | 24 | @RunWith(AndroidJUnit4.class) 25 | public class RemotePreferencesTest { 26 | private Context getLocalContext() { 27 | return InstrumentationRegistry.getInstrumentation().getContext(); 28 | } 29 | 30 | private Context getRemoteContext() { 31 | return InstrumentationRegistry.getInstrumentation().getTargetContext(); 32 | } 33 | 34 | private SharedPreferences getSharedPreferences() { 35 | Context context = getRemoteContext(); 36 | return context.getSharedPreferences(TestConstants.PREF_FILE, Context.MODE_PRIVATE); 37 | } 38 | 39 | private RemotePreferences getRemotePreferences(boolean strictMode) { 40 | // This is not a typo! We are using the LOCAL context to initialize a REMOTE prefs 41 | // instance. This is the whole point of RemotePreferences! 42 | Context context = getLocalContext(); 43 | return new RemotePreferences(context, TestConstants.AUTHORITY, TestConstants.PREF_FILE, strictMode); 44 | } 45 | 46 | private RemotePreferences getDisabledRemotePreferences(boolean strictMode) { 47 | Context context = getLocalContext(); 48 | return new RemotePreferences(context, TestConstants.AUTHORITY_DISABLED, TestConstants.PREF_FILE, strictMode); 49 | } 50 | 51 | private RemotePreferences getRemotePreferencesWithHandler(Handler handler, boolean strictMode) { 52 | Context context = getLocalContext(); 53 | return new RemotePreferences(context, handler, TestConstants.AUTHORITY, TestConstants.PREF_FILE, strictMode); 54 | } 55 | 56 | @Before 57 | public void resetPreferences() { 58 | getSharedPreferences().edit().clear().commit(); 59 | } 60 | 61 | @Test 62 | public void testBasicRead() { 63 | getSharedPreferences() 64 | .edit() 65 | .putString("string", "foobar") 66 | .putInt("int", 0xeceb3026) 67 | .putFloat("float", 3.14f) 68 | .putBoolean("bool", true) 69 | .apply(); 70 | 71 | RemotePreferences remotePrefs = getRemotePreferences(true); 72 | Assert.assertEquals("foobar", remotePrefs.getString("string", null)); 73 | Assert.assertEquals(0xeceb3026, remotePrefs.getInt("int", 0)); 74 | Assert.assertEquals(3.14f, remotePrefs.getFloat("float", 0f), 0.0); 75 | Assert.assertEquals(true, remotePrefs.getBoolean("bool", false)); 76 | } 77 | 78 | @Test 79 | public void testBasicWrite() { 80 | getRemotePreferences(true) 81 | .edit() 82 | .putString("string", "foobar") 83 | .putInt("int", 0xeceb3026) 84 | .putFloat("float", 3.14f) 85 | .putBoolean("bool", true) 86 | .apply(); 87 | 88 | SharedPreferences sharedPrefs = getSharedPreferences(); 89 | Assert.assertEquals("foobar", sharedPrefs.getString("string", null)); 90 | Assert.assertEquals(0xeceb3026, sharedPrefs.getInt("int", 0)); 91 | Assert.assertEquals(3.14f, sharedPrefs.getFloat("float", 0f), 0.0); 92 | Assert.assertEquals(true, sharedPrefs.getBoolean("bool", false)); 93 | } 94 | 95 | @Test 96 | public void testRemove() { 97 | getSharedPreferences() 98 | .edit() 99 | .putString("string", "foobar") 100 | .putInt("int", 0xeceb3026) 101 | .apply(); 102 | 103 | RemotePreferences remotePrefs = getRemotePreferences(true); 104 | remotePrefs.edit().remove("string").apply(); 105 | 106 | Assert.assertEquals("default", remotePrefs.getString("string", "default")); 107 | Assert.assertEquals(0xeceb3026, remotePrefs.getInt("int", 0)); 108 | } 109 | 110 | @Test 111 | public void testClear() { 112 | SharedPreferences sharedPrefs = getSharedPreferences(); 113 | getSharedPreferences() 114 | .edit() 115 | .putString("string", "foobar") 116 | .putInt("int", 0xeceb3026) 117 | .apply(); 118 | 119 | RemotePreferences remotePrefs = getRemotePreferences(true); 120 | remotePrefs.edit().clear().apply(); 121 | 122 | Assert.assertEquals(0, sharedPrefs.getAll().size()); 123 | Assert.assertEquals("default", remotePrefs.getString("string", "default")); 124 | Assert.assertEquals(0, remotePrefs.getInt("int", 0)); 125 | } 126 | 127 | @Test 128 | public void testGetAll() { 129 | getSharedPreferences() 130 | .edit() 131 | .putString("string", "foobar") 132 | .putInt("int", 0xeceb3026) 133 | .putFloat("float", 3.14f) 134 | .putBoolean("bool", true) 135 | .apply(); 136 | 137 | RemotePreferences remotePrefs = getRemotePreferences(true); 138 | Map prefs = remotePrefs.getAll(); 139 | Assert.assertEquals("foobar", prefs.get("string")); 140 | Assert.assertEquals(0xeceb3026, prefs.get("int")); 141 | Assert.assertEquals(3.14f, prefs.get("float")); 142 | Assert.assertEquals(true, prefs.get("bool")); 143 | } 144 | 145 | @Test 146 | public void testContains() { 147 | getSharedPreferences() 148 | .edit() 149 | .putString("string", "foobar") 150 | .putInt("int", 0xeceb3026) 151 | .putFloat("float", 3.14f) 152 | .putBoolean("bool", true) 153 | .apply(); 154 | 155 | RemotePreferences remotePrefs = getRemotePreferences(true); 156 | Assert.assertTrue(remotePrefs.contains("string")); 157 | Assert.assertTrue(remotePrefs.contains("int")); 158 | Assert.assertFalse(remotePrefs.contains("nonexistent")); 159 | } 160 | 161 | @Test 162 | public void testReadNonexistentPref() { 163 | RemotePreferences remotePrefs = getRemotePreferences(true); 164 | Assert.assertEquals("default", remotePrefs.getString("nonexistent_string", "default")); 165 | Assert.assertEquals(1337, remotePrefs.getInt("nonexistent_int", 1337)); 166 | } 167 | 168 | @Test 169 | public void testStringSetRead() { 170 | HashSet set = new HashSet<>(); 171 | set.add("Chocola"); 172 | set.add("Vanilla"); 173 | set.add("Coconut"); 174 | set.add("Azuki"); 175 | set.add("Maple"); 176 | set.add("Cinnamon"); 177 | 178 | getSharedPreferences() 179 | .edit() 180 | .putStringSet("pref", set) 181 | .apply(); 182 | 183 | RemotePreferences remotePrefs = getRemotePreferences(true); 184 | Assert.assertEquals(set, remotePrefs.getStringSet("pref", null)); 185 | } 186 | 187 | @Test 188 | public void testStringSetWrite() { 189 | HashSet set = new HashSet<>(); 190 | set.add("Chocola"); 191 | set.add("Vanilla"); 192 | set.add("Coconut"); 193 | set.add("Azuki"); 194 | set.add("Maple"); 195 | set.add("Cinnamon"); 196 | 197 | getRemotePreferences(true) 198 | .edit() 199 | .putStringSet("pref", set) 200 | .apply(); 201 | 202 | SharedPreferences sharedPrefs = getSharedPreferences(); 203 | Assert.assertEquals(set, sharedPrefs.getStringSet("pref", null)); 204 | } 205 | 206 | @Test 207 | public void testEmptyStringSetRead() { 208 | HashSet set = new HashSet<>(); 209 | 210 | getSharedPreferences() 211 | .edit() 212 | .putStringSet("pref", set) 213 | .apply(); 214 | 215 | RemotePreferences remotePrefs = getRemotePreferences(true); 216 | Assert.assertEquals(set, remotePrefs.getStringSet("pref", null)); 217 | } 218 | 219 | @Test 220 | public void testEmptyStringSetWrite() { 221 | HashSet set = new HashSet<>(); 222 | 223 | getRemotePreferences(true) 224 | .edit() 225 | .putStringSet("pref", set) 226 | .apply(); 227 | 228 | SharedPreferences sharedPrefs = getSharedPreferences(); 229 | Assert.assertEquals(set, sharedPrefs.getStringSet("pref", null)); 230 | } 231 | 232 | @Test 233 | public void testSetContainingEmptyStringRead() { 234 | HashSet set = new HashSet<>(); 235 | set.add(""); 236 | 237 | getSharedPreferences() 238 | .edit() 239 | .putStringSet("pref", set) 240 | .apply(); 241 | 242 | RemotePreferences remotePrefs = getRemotePreferences(true); 243 | Assert.assertEquals(set, remotePrefs.getStringSet("pref", null)); 244 | } 245 | 246 | @Test 247 | public void testSetContainingEmptyStringWrite() { 248 | HashSet set = new HashSet<>(); 249 | set.add(""); 250 | 251 | getRemotePreferences(true) 252 | .edit() 253 | .putStringSet("pref", set) 254 | .apply(); 255 | 256 | SharedPreferences sharedPrefs = getSharedPreferences(); 257 | Assert.assertEquals(set, sharedPrefs.getStringSet("pref", null)); 258 | } 259 | 260 | @Test 261 | public void testReadStringAsStringSetFail() { 262 | getSharedPreferences() 263 | .edit() 264 | .putString("pref", "foo;bar;") 265 | .apply(); 266 | 267 | RemotePreferences remotePrefs = getRemotePreferences(true); 268 | try { 269 | remotePrefs.getStringSet("pref", null); 270 | Assert.fail(); 271 | } catch (ClassCastException e) { 272 | // Expected 273 | } 274 | } 275 | 276 | @Test 277 | public void testReadStringSetAsStringFail() { 278 | HashSet set = new HashSet<>(); 279 | set.add("foo"); 280 | set.add("bar"); 281 | 282 | getSharedPreferences() 283 | .edit() 284 | .putStringSet("pref", set) 285 | .apply(); 286 | 287 | RemotePreferences remotePrefs = getRemotePreferences(true); 288 | try { 289 | remotePrefs.getString("pref", null); 290 | Assert.fail(); 291 | } catch (ClassCastException e) { 292 | // Expected 293 | } 294 | } 295 | 296 | @Test 297 | public void testReadBooleanAsIntFail() { 298 | getSharedPreferences() 299 | .edit() 300 | .putBoolean("pref", true) 301 | .apply(); 302 | 303 | RemotePreferences remotePrefs = getRemotePreferences(true); 304 | try { 305 | remotePrefs.getInt("pref", 0); 306 | Assert.fail(); 307 | } catch (ClassCastException e) { 308 | // Expected 309 | } 310 | } 311 | 312 | @Test 313 | public void testReadIntAsBooleanFail() { 314 | getSharedPreferences() 315 | .edit() 316 | .putInt("pref", 42) 317 | .apply(); 318 | 319 | RemotePreferences remotePrefs = getRemotePreferences(true); 320 | try { 321 | remotePrefs.getBoolean("pref", false); 322 | Assert.fail(); 323 | } catch (ClassCastException e) { 324 | // Expected 325 | } 326 | } 327 | 328 | @Test 329 | public void testInvalidAuthorityStrictMode() { 330 | Context context = getLocalContext(); 331 | RemotePreferences remotePrefs = new RemotePreferences(context, "foo", "bar", true); 332 | try { 333 | remotePrefs.getString("pref", null); 334 | Assert.fail(); 335 | } catch (RemotePreferenceAccessException e) { 336 | // Expected 337 | } 338 | } 339 | 340 | @Test 341 | public void testInvalidAuthorityNonStrictMode() { 342 | Context context = getLocalContext(); 343 | RemotePreferences remotePrefs = new RemotePreferences(context, "foo", "bar", false); 344 | Assert.assertEquals("default", remotePrefs.getString("pref", "default")); 345 | } 346 | 347 | @Test 348 | public void testDisabledProviderStrictMode() { 349 | RemotePreferences remotePrefs = getDisabledRemotePreferences(true); 350 | try { 351 | remotePrefs.getString("pref", null); 352 | Assert.fail(); 353 | } catch (RemotePreferenceAccessException e) { 354 | // Expected 355 | } 356 | } 357 | 358 | @Test 359 | public void testDisabledProviderNonStrictMode() { 360 | RemotePreferences remotePrefs = getDisabledRemotePreferences(false); 361 | Assert.assertEquals("default", remotePrefs.getString("pref", "default")); 362 | } 363 | 364 | @Test 365 | public void testUnreadablePrefStrictMode() { 366 | RemotePreferences remotePrefs = getRemotePreferences(true); 367 | try { 368 | remotePrefs.getString(TestConstants.UNREADABLE_PREF_KEY, null); 369 | Assert.fail(); 370 | } catch (RemotePreferenceAccessException e) { 371 | // Expected 372 | } 373 | } 374 | 375 | @Test 376 | public void testUnreadablePrefNonStrictMode() { 377 | RemotePreferences remotePrefs = getRemotePreferences(false); 378 | Assert.assertEquals("default", remotePrefs.getString(TestConstants.UNREADABLE_PREF_KEY, "default")); 379 | } 380 | 381 | @Test 382 | public void testUnwritablePrefStrictMode() { 383 | RemotePreferences remotePrefs = getRemotePreferences(true); 384 | try { 385 | remotePrefs.edit().putString(TestConstants.UNWRITABLE_PREF_KEY, "foobar").commit(); 386 | Assert.fail(); 387 | } catch (RemotePreferenceAccessException e) { 388 | // Expected 389 | } 390 | } 391 | 392 | @Test 393 | public void testUnwritablePrefNonStrictMode() { 394 | RemotePreferences remotePrefs = getRemotePreferences(false); 395 | Assert.assertFalse( 396 | remotePrefs 397 | .edit() 398 | .putString(TestConstants.UNWRITABLE_PREF_KEY, "foobar") 399 | .commit() 400 | ); 401 | } 402 | 403 | @Test 404 | public void testRemoveUnwritablePrefStrictMode() { 405 | getSharedPreferences() 406 | .edit() 407 | .putString(TestConstants.UNWRITABLE_PREF_KEY, "foobar") 408 | .apply(); 409 | 410 | RemotePreferences remotePrefs = getRemotePreferences(true); 411 | try { 412 | remotePrefs.edit().remove(TestConstants.UNWRITABLE_PREF_KEY).commit(); 413 | Assert.fail(); 414 | } catch (RemotePreferenceAccessException e) { 415 | // Expected 416 | } 417 | 418 | Assert.assertEquals("foobar", remotePrefs.getString(TestConstants.UNWRITABLE_PREF_KEY, "default")); 419 | } 420 | 421 | @Test 422 | public void testRemoveUnwritablePrefNonStrictMode() { 423 | getSharedPreferences() 424 | .edit() 425 | .putString(TestConstants.UNWRITABLE_PREF_KEY, "foobar") 426 | .apply(); 427 | 428 | RemotePreferences remotePrefs = getRemotePreferences(false); 429 | Assert.assertFalse(remotePrefs.edit().remove(TestConstants.UNWRITABLE_PREF_KEY).commit()); 430 | 431 | Assert.assertEquals("foobar", remotePrefs.getString(TestConstants.UNWRITABLE_PREF_KEY, "default")); 432 | } 433 | 434 | @Test 435 | public void testPreferenceChangeListener() { 436 | HandlerThread ht = new HandlerThread(getClass().getName()); 437 | try { 438 | ht.start(); 439 | Handler handler = new Handler(ht.getLooper()); 440 | 441 | RemotePreferences remotePrefs = getRemotePreferencesWithHandler(handler, true); 442 | TestPreferenceListener listener = new TestPreferenceListener(); 443 | 444 | try { 445 | remotePrefs.registerOnSharedPreferenceChangeListener(listener); 446 | 447 | getSharedPreferences() 448 | .edit() 449 | .putInt("foobar", 1337) 450 | .apply(); 451 | 452 | Assert.assertTrue(listener.waitForChange(1)); 453 | Assert.assertEquals("foobar", listener.getKey()); 454 | } finally { 455 | remotePrefs.unregisterOnSharedPreferenceChangeListener(listener); 456 | } 457 | } finally { 458 | ht.quit(); 459 | } 460 | } 461 | 462 | @Test 463 | @SdkSuppress(minSdkVersion = Build.VERSION_CODES.R) 464 | public void testPreferenceChangeListenerClear() { 465 | HandlerThread ht = new HandlerThread(getClass().getName()); 466 | try { 467 | ht.start(); 468 | Handler handler = new Handler(ht.getLooper()); 469 | 470 | RemotePreferences remotePrefs = getRemotePreferencesWithHandler(handler, true); 471 | TestPreferenceListener listener = new TestPreferenceListener(); 472 | 473 | try { 474 | remotePrefs.registerOnSharedPreferenceChangeListener(listener); 475 | 476 | getSharedPreferences() 477 | .edit() 478 | .clear() 479 | .apply(); 480 | 481 | Assert.assertTrue(listener.waitForChange(1)); 482 | Assert.assertNull(listener.getKey()); 483 | } finally { 484 | remotePrefs.unregisterOnSharedPreferenceChangeListener(listener); 485 | } 486 | } finally { 487 | ht.quit(); 488 | } 489 | } 490 | 491 | @Test 492 | public void testUnregisterPreferenceChangeListener() { 493 | HandlerThread ht = new HandlerThread(getClass().getName()); 494 | try { 495 | ht.start(); 496 | Handler handler = new Handler(ht.getLooper()); 497 | 498 | RemotePreferences remotePrefs = getRemotePreferencesWithHandler(handler, true); 499 | TestPreferenceListener listener = new TestPreferenceListener(); 500 | 501 | try { 502 | remotePrefs.registerOnSharedPreferenceChangeListener(listener); 503 | remotePrefs.unregisterOnSharedPreferenceChangeListener(listener); 504 | 505 | getSharedPreferences() 506 | .edit() 507 | .putInt("foobar", 1337) 508 | .apply(); 509 | 510 | Assert.assertFalse(listener.waitForChange(1)); 511 | } finally { 512 | remotePrefs.unregisterOnSharedPreferenceChangeListener(listener); 513 | } 514 | } finally { 515 | ht.quit(); 516 | } 517 | } 518 | } 519 | -------------------------------------------------------------------------------- /testapp/src/androidTest/java/com/crossbowffs/remotepreferences/RemoteUtilsTest.java: -------------------------------------------------------------------------------- 1 | package com.crossbowffs.remotepreferences; 2 | 3 | import androidx.test.ext.junit.runners.AndroidJUnit4; 4 | 5 | import org.junit.Assert; 6 | import org.junit.Test; 7 | import org.junit.runner.RunWith; 8 | 9 | import java.util.HashSet; 10 | import java.util.LinkedHashSet; 11 | import java.util.Set; 12 | 13 | @RunWith(AndroidJUnit4.class) 14 | public class RemoteUtilsTest { 15 | @Test 16 | public void testSerializeStringSet() { 17 | Set set = new LinkedHashSet(); 18 | set.add("foo"); 19 | set.add("bar;"); 20 | set.add("baz"); 21 | set.add(""); 22 | 23 | String serialized = RemoteUtils.serializeStringSet(set); 24 | Assert.assertEquals("foo;bar\\;;baz;;", serialized); 25 | } 26 | 27 | @Test 28 | public void testDeserializeStringSet() { 29 | Set set = new LinkedHashSet(); 30 | set.add("foo"); 31 | set.add("bar;"); 32 | set.add("baz"); 33 | set.add(""); 34 | 35 | String serialized = RemoteUtils.serializeStringSet(set); 36 | Set deserialized = RemoteUtils.deserializeStringSet(serialized); 37 | Assert.assertEquals(set, deserialized); 38 | } 39 | 40 | @Test 41 | public void testSerializeEmptyStringSet() { 42 | Assert.assertEquals("", RemoteUtils.serializeStringSet(new HashSet())); 43 | } 44 | 45 | @Test 46 | public void testDeserializeEmptyStringSet() { 47 | Assert.assertEquals(new HashSet(), RemoteUtils.deserializeStringSet("")); 48 | } 49 | 50 | @Test 51 | public void testDeserializeInvalidStringSet() { 52 | try { 53 | RemoteUtils.deserializeStringSet("foo;bar"); 54 | Assert.fail(); 55 | } catch (IllegalArgumentException e) { 56 | // Expected 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /testapp/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 8 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /testapp/src/main/java/com/crossbowffs/remotepreferences/testapp/TestConstants.java: -------------------------------------------------------------------------------- 1 | package com.crossbowffs.remotepreferences.testapp; 2 | 3 | public final class TestConstants { 4 | private TestConstants() {} 5 | 6 | public static final String AUTHORITY = BuildConfig.APPLICATION_ID + ".preferences"; 7 | public static final String AUTHORITY_DISABLED = BuildConfig.APPLICATION_ID + ".preferences.disabled"; 8 | public static final String PREF_FILE = "main_prefs"; 9 | public static final String UNREADABLE_PREF_KEY = "cannot_read_me"; 10 | public static final String UNWRITABLE_PREF_KEY = "cannot_write_me"; 11 | } 12 | -------------------------------------------------------------------------------- /testapp/src/main/java/com/crossbowffs/remotepreferences/testapp/TestPreferenceListener.java: -------------------------------------------------------------------------------- 1 | package com.crossbowffs.remotepreferences.testapp; 2 | 3 | import android.content.SharedPreferences; 4 | 5 | import java.util.concurrent.CountDownLatch; 6 | import java.util.concurrent.TimeUnit; 7 | 8 | public class TestPreferenceListener implements SharedPreferences.OnSharedPreferenceChangeListener { 9 | private boolean mIsCalled; 10 | private String mKey; 11 | private final CountDownLatch mLatch; 12 | 13 | public TestPreferenceListener() { 14 | mIsCalled = false; 15 | mKey = null; 16 | mLatch = new CountDownLatch(1); 17 | } 18 | 19 | public boolean isCalled() { 20 | return mIsCalled; 21 | } 22 | 23 | public String getKey() { 24 | if (!mIsCalled) { 25 | throw new IllegalStateException("Listener was not called"); 26 | } 27 | return mKey; 28 | } 29 | 30 | public boolean waitForChange(long seconds) { 31 | try { 32 | return mLatch.await(seconds, TimeUnit.SECONDS); 33 | } catch (InterruptedException e) { 34 | throw new IllegalStateException("Listener wait was interrupted"); 35 | } 36 | } 37 | 38 | @Override 39 | public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) { 40 | mIsCalled = true; 41 | mKey = key; 42 | mLatch.countDown(); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /testapp/src/main/java/com/crossbowffs/remotepreferences/testapp/TestPreferenceProvider.java: -------------------------------------------------------------------------------- 1 | package com.crossbowffs.remotepreferences.testapp; 2 | 3 | import com.crossbowffs.remotepreferences.RemotePreferenceProvider; 4 | 5 | public class TestPreferenceProvider extends RemotePreferenceProvider { 6 | public TestPreferenceProvider() { 7 | super(TestConstants.AUTHORITY, new String[] {TestConstants.PREF_FILE}); 8 | } 9 | 10 | @Override 11 | protected boolean checkAccess(String prefName, String prefKey, boolean write) { 12 | if (prefKey.equals(TestConstants.UNREADABLE_PREF_KEY) && !write) return false; 13 | if (prefKey.equals(TestConstants.UNWRITABLE_PREF_KEY) && write) return false; 14 | return true; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /testapp/src/main/java/com/crossbowffs/remotepreferences/testapp/TestPreferenceProviderDisabled.java: -------------------------------------------------------------------------------- 1 | package com.crossbowffs.remotepreferences.testapp; 2 | 3 | import com.crossbowffs.remotepreferences.RemotePreferenceProvider; 4 | 5 | public class TestPreferenceProviderDisabled extends RemotePreferenceProvider { 6 | public TestPreferenceProviderDisabled() { 7 | super(TestConstants.AUTHORITY_DISABLED, new String[] {TestConstants.PREF_FILE}); 8 | } 9 | } 10 | --------------------------------------------------------------------------------