Repository: apsun/RemotePreferences
Branch: master
Commit: c3e3e59d0b30
Files: 29
Total size: 116.0 KB
Directory structure:
gitextract_286r2t3v/
├── .gitignore
├── LICENSE.txt
├── README.md
├── build.gradle.kts
├── gradle/
│ └── wrapper/
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradle.properties
├── gradlew
├── gradlew.bat
├── library/
│ ├── build.gradle.kts
│ └── src/
│ └── main/
│ ├── AndroidManifest.xml
│ └── java/
│ └── com/
│ └── crossbowffs/
│ └── remotepreferences/
│ ├── RemoteContract.java
│ ├── RemotePreferenceAccessException.java
│ ├── RemotePreferenceFile.java
│ ├── RemotePreferencePath.java
│ ├── RemotePreferenceProvider.java
│ ├── RemotePreferenceUriParser.java
│ ├── RemotePreferences.java
│ └── RemoteUtils.java
├── settings.gradle.kts
└── testapp/
├── build.gradle.kts
└── src/
├── androidTest/
│ └── java/
│ └── com/
│ └── crossbowffs/
│ └── remotepreferences/
│ ├── RemotePreferenceProviderTest.java
│ ├── RemotePreferencesTest.java
│ └── RemoteUtilsTest.java
└── main/
├── AndroidManifest.xml
└── java/
└── com/
└── crossbowffs/
└── remotepreferences/
└── testapp/
├── TestConstants.java
├── TestPreferenceListener.java
├── TestPreferenceProvider.java
└── TestPreferenceProviderDisabled.java
================================================
FILE CONTENTS
================================================
================================================
FILE: .gitignore
================================================
# Gradle files
.gradle/
build/
# Local configuration file (sdk path, etc)
local.properties
# Log/OS Files
*.log
# Android Studio generated files and folders
captures/
.externalNativeBuild/
.cxx/
*.apk
output.json
# IntelliJ
*.iml
.idea/
misc.xml
deploymentTargetDropDown.xml
render.experimental.xml
# Keystore files
*.jks
*.keystore
# Google Services (e.g. APIs or Firebase)
google-services.json
# Android Profiling
*.hprof
================================================
FILE: LICENSE.txt
================================================
The MIT License (MIT)
Copyright (c) 2016 Andrew Sun (@crossbowffs)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================
# RemotePreferences
A drop-in solution for inter-app access to `SharedPreferences`.
## Installation
1\. Add the dependency to your `build.gradle` file:
```
repositories {
mavenCentral()
}
dependencies {
implementation 'com.crossbowffs.remotepreferences:remotepreferences:0.8'
}
```
2\. Subclass `RemotePreferenceProvider` and implement a 0-argument
constructor which calls the super constructor with an authority
(e.g. `"com.example.app.preferences"`) and an array of
preference files to expose:
```Java
public class MyPreferenceProvider extends RemotePreferenceProvider {
public MyPreferenceProvider() {
super("com.example.app.preferences", new String[] {"main_prefs"});
}
}
```
3\. Add the corresponding entry to `AndroidManifest.xml`, with
`android:authorities` equal to the authority you picked in the
last step, and `android:exported` set to `true`:
```XML
<provider
android:name=".MyPreferenceProvider"
android:authorities="com.example.app.preferences"
android:exported="true"/>
```
4\. You're all set! To access your preferences, create a new
instance of `RemotePreferences` with the same authority and the
name of the preference file:
```Java
SharedPreferences prefs = new RemotePreferences(context, "com.example.app.preferences", "main_prefs");
int value = prefs.getInt("my_int_pref", 0);
```
**WARNING**: **DO NOT** use `RemotePreferences` from within
`IXposedHookZygoteInit.initZygote`, since app providers have not been
initialized at this point. Instead, defer preference loading to
`IXposedHookLoadPackage.handleLoadPackage`.
Note that you should still use `context.getSharedPreferences("main_prefs", MODE_PRIVATE)`
if your code is executing within the app that owns the preferences. Only use
`RemotePreferences` when accessing preferences from the context of another app.
Also note that your preference keys cannot be `null` or `""` (empty string).
## Security
By default, all preferences have global read/write access. If this is what
you want, then no additional configuration is required. However, chances are
you'll want to prevent 3rd party apps from reading or writing your
preferences. There are two ways to accomplish this:
1. Use the Android permissions system built into `ContentProvider`
2. Override the `checkAccess` method in `RemotePreferenceProvider`
Option 1 is the simplest to implement - just add `android:readPermission`
and/or `android:writePermission` to your preference provider in
`AndroidManifest.xml`. Unfortunately, this does not work very well if
you are hooking apps that you do not control (e.g. Xposed), since you
cannot modify their permissions.
Option 2 requires a bit of code, but is extremely powerful since you
can control exactly which preferences can be accessed. To do this,
override the `checkAccess` method in your preference provider class:
```Java
@Override
protected boolean checkAccess(String prefFileName, String prefKey, boolean write) {
// Only allow read access
if (write) {
return false;
}
// Only allow access to certain preference keys
if (!"my_pref_key".equals(prefKey)) {
return false;
}
// Only allow access from certain apps
if (!"com.example.otherapp".equals(getCallingPackage())) {
return false;
}
return true;
}
```
Warning: when checking an operation such as `getAll()` or `clear()`,
`prefKey` will be an empty string. If you are blacklisting certain
keys, make sure to also blacklist the `""` key as well!
## Device encrypted preferences
By default, devices with Android N+ come with file-based encryption, which
prevents RemotePreferences from accessing them before the first unlock after
reboot. If preferences need to be accessed before the first unlock, the
following modifications are needed.
1\. Modify the provider constructor to mark the preference file as device protected:
```Java
public class MyPreferenceProvider extends RemotePreferenceProvider {
public MyPreferenceProvider() {
super("com.example.app.preferences", new RemotePreferenceFile[] {
new RemotePreferenceFile("main_prefs", /* isDeviceProtected */ true)
});
}
}
```
This will cause the provider to use `context.createDeviceProtectedStorageContext()`
to access the preferences.
2\. Add support for direct boot in your manifest:
```XML
<provider
android:name=".MyPreferenceProvider"
android:authorities="com.example.app.preferences"
android:exported="true"
android:directBootAware="true"/>
```
3\. Update your app to access shared preferences from device protected storage.
If you are using `PreferenceManager`, call `setStorageDeviceProtected()`. If you
are using `SharedPreferences`, use `createDeviceProtectedStorageContext()` to
create the preferences. For example:
```Java
Context prefContext = context.createDeviceProtectedStorageContext();
SharedPreferences prefs = prefContext.getSharedPreferences("main_prefs", MODE_PRIVATE);
```
## Strict mode
To maintain API compatibility with `SharedPreferences`, by default any errors
encountered while accessing the preference provider will be ignored, resulting
in default values being returned from the getter methods and `apply()` silently
failing (we advise using `commit()` and checking the return value, at least).
This can be caused by bugs in your code, or the user disabling your app/provider
component. To detect and handle this scenario, you may opt-in to *strict mode*
by passing an extra parameter to the `RemotePreferences` constructor:
```Java
SharedPreferences prefs = new RemotePreferences(context, authority, prefFileName, true);
```
Now, if the preference provider cannot be accessed, a
`RemotePreferenceAccessException` will be thrown. You can handle this by
wrapping your preference accesses in a try-catch block:
```Java
try {
int value = prefs.getInt("my_int_pref", 0);
prefs.edit().putInt("my_int_pref", value + 1).apply();
} catch (RemotePreferenceAccessException e) {
// Handle the error
}
```
## Why would I need this?
This library was developed to simplify Xposed module preference access.
`XSharedPreferences` [has been known to silently fail on some devices](https://github.com/rovo89/XposedBridge/issues/74),
and does not support remote write access or value changed listeners.
Thus, RemotePreferences was born.
Of course, feel free to use this library anywhere you like; it's not
limited to Xposed at all! :-)
## How does it work?
To achieve true inter-process `SharedPreferences` access, all requests
are proxied through a `ContentProvider`. Preference change callbacks are
implemented using `ContentObserver`.
This solution does **not** use `MODE_WORLD_WRITEABLE` (which was
deprecated in Android 4.2) or any other file permission hacks.
## Running tests
Connect your Android device and run:
```
./gradlew :testapp:connectedAndroidTest
```
## License
Distributed under the [MIT License](http://opensource.org/licenses/MIT).
## Changelog
0.8
- RemotePreferences is now hosted on `mavenCentral()`
- Fixed `onSharedPreferenceChanged` getting the wrong `key` when calling `clear()`
0.7
- Added support for preferences located in device protected storage (thanks to Rijul-A)
0.6
- Improved error checking
- Fixed case where strict mode was not applying when editing multiple preferences
- Added more documentation for library internals
- Updated project to modern Android Studio layout
0.5
- Ensure edits are atomic - either all or no edits succeed when committing
- Minor performance improvement when adding/removing multiple keys
0.4
- Fixed `IllegalArgumentException` being thrown instead of `RemotePreferenceAccessException`
0.3
- Values can now be `null` again
- Improved error checking if you are using the ContentProvider interface directly
0.2
- Fixed catastrophic security bug allowing anyone to write to preferences
- Added strict mode to distinguish between "cannot access provider" vs. "key doesn't exist"
- Keys can no longer be `null` or `""`, values can no longer be `null`
0.1
- Initial release.
================================================
FILE: build.gradle.kts
================================================
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath("com.android.tools.build:gradle:8.1.2")
}
}
allprojects {
repositories {
google()
mavenCentral()
}
}
================================================
FILE: gradle/wrapper/gradle-wrapper.properties
================================================
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip
networkTimeout=10000
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
================================================
FILE: gradle.properties
================================================
android.useAndroidX=true
android.defaults.buildfeatures.buildconfig=true
================================================
FILE: gradlew
================================================
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Collect all arguments for the java command;
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
# shell script including quotes and variable substitutions, so put them in
# double quotes to make sure that they get re-expanded; and
# * put everything else in single quotes, so that it's not re-expanded.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
================================================
FILE: gradlew.bat
================================================
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
================================================
FILE: library/build.gradle.kts
================================================
plugins {
id("com.android.library")
id("maven-publish")
id("signing")
}
android {
namespace = "com.crossbowffs.remotepreferences"
compileSdk = 34
defaultConfig {
minSdk = 1
}
publishing {
singleVariant("release") {
withSourcesJar()
withJavadocJar()
}
}
}
publishing {
publications {
afterEvaluate {
create<MavenPublication>("release") {
from(components["release"])
groupId = "com.crossbowffs.remotepreferences"
artifactId = "remotepreferences"
version = "0.8"
pom {
packaging = "aar"
name.set("RemotePreferences")
description.set("A drop-in solution for inter-app access to SharedPreferences on Android.")
url.set("https://github.com/apsun/RemotePreferences")
licenses {
license {
name.set("MIT")
url.set("https://opensource.org/licenses/MIT")
}
}
developers {
developer {
name.set("Andrew Sun")
email.set("andrew@crossbowffs.com")
}
}
scm {
url.set(pom.url.get())
connection.set("scm:git:${url.get()}.git")
developerConnection.set("scm:git:${url.get()}.git")
}
}
}
}
}
repositories {
maven {
name = "OSSRH"
url = uri("https://oss.sonatype.org/service/local/staging/deploy/maven2")
credentials {
username = project.findProperty("ossrhUsername") as String?
password = project.findProperty("ossrhPassword") as String?
}
}
}
}
signing {
useGpgCmd()
afterEvaluate {
sign(publishing.publications["release"])
}
}
================================================
FILE: library/src/main/AndroidManifest.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<manifest />
================================================
FILE: library/src/main/java/com/crossbowffs/remotepreferences/RemoteContract.java
================================================
package com.crossbowffs.remotepreferences;
/**
* Constants used for communicating with the preference provider.
*/
/* package */ final class RemoteContract {
public static final String COLUMN_KEY = "key";
public static final String COLUMN_TYPE = "type";
public static final String COLUMN_VALUE = "value";
public static final String[] COLUMN_ALL = {
RemoteContract.COLUMN_KEY,
RemoteContract.COLUMN_TYPE,
RemoteContract.COLUMN_VALUE
};
public static final int TYPE_NULL = 0;
public static final int TYPE_STRING = 1;
public static final int TYPE_STRING_SET = 2;
public static final int TYPE_INT = 3;
public static final int TYPE_LONG = 4;
public static final int TYPE_FLOAT = 5;
public static final int TYPE_BOOLEAN = 6;
private RemoteContract() {}
}
================================================
FILE: library/src/main/java/com/crossbowffs/remotepreferences/RemotePreferenceAccessException.java
================================================
package com.crossbowffs.remotepreferences;
/**
* Thrown if the preference provider could not be accessed.
* This is commonly thrown under these conditions:
* <ul>
* <li>Preference provider component is disabled</li>
* <li>Preference provider denied access via {@link RemotePreferenceProvider#checkAccess(String, String, boolean)}</li>
* <li>Insufficient permissions to access provider (via {@code AndroidManifest.xml})</li>
* <li>Incorrect provider authority/file name passed to constructor</li>
* </ul>
*/
public class RemotePreferenceAccessException extends RuntimeException {
public RemotePreferenceAccessException() {
}
public RemotePreferenceAccessException(String detailMessage) {
super(detailMessage);
}
public RemotePreferenceAccessException(String detailMessage, Throwable throwable) {
super(detailMessage, throwable);
}
public RemotePreferenceAccessException(Throwable throwable) {
super(throwable);
}
}
================================================
FILE: library/src/main/java/com/crossbowffs/remotepreferences/RemotePreferenceFile.java
================================================
package com.crossbowffs.remotepreferences;
/**
* Represents a single preference file and the information needed to
* access that preference file.
*/
public class RemotePreferenceFile {
private final String mFileName;
private final boolean mIsDeviceProtected;
/**
* Initializes the preference file information. If you are targeting Android
* N or above and the preference needs to be accessed before the first unlock,
* set {@code isDeviceProtected} to {@code true}.
*
* @param fileName Name of the preference file.
* @param isDeviceProtected {@code true} if the preference file is device protected,
* {@code false} if it is credential protected.
*/
public RemotePreferenceFile(String fileName, boolean isDeviceProtected) {
mFileName = fileName;
mIsDeviceProtected = isDeviceProtected;
}
/**
* Initializes the preference file information. Assumes the preferences are
* located in credential protected storage.
*
* @param fileName Name of the preference file.
*/
public RemotePreferenceFile(String fileName) {
this(fileName, false);
}
/**
* Returns the name of the preference file.
*
* @return The name of the preference file.
*/
public String getFileName() {
return mFileName;
}
/**
* Returns whether the preferences are located in device protected storage.
*
* @return {@code true} if the preference file is device protected,
* {@code false} if it is credential protected.
*/
public boolean isDeviceProtected() {
return mIsDeviceProtected;
}
/**
* Converts an array of preference file names to {@link RemotePreferenceFile}
* objects. Assumes all preference files are NOT in device protected storage.
*
* @param prefFileNames The names of the preference files to expose.
* @return An array of {@link RemotePreferenceFile} objects.
*/
public static RemotePreferenceFile[] fromFileNames(String[] prefFileNames) {
RemotePreferenceFile[] prefFiles = new RemotePreferenceFile[prefFileNames.length];
for (int i = 0; i < prefFileNames.length; i++) {
prefFiles[i] = new RemotePreferenceFile(prefFileNames[i]);
}
return prefFiles;
}
}
================================================
FILE: library/src/main/java/com/crossbowffs/remotepreferences/RemotePreferencePath.java
================================================
package com.crossbowffs.remotepreferences;
/**
* A path consists of a preference file name and optionally a key within
* the preference file. The key will be set for operations that involve
* a single preference (e.g. {@code getInt}), and {@code null} for operations
* on an entire preference file (e.g. {@code getAll}).
*/
/* package */ class RemotePreferencePath {
public final String fileName;
public final String key;
public RemotePreferencePath(String prefFileName, String prefKey) {
this.fileName = prefFileName;
this.key = prefKey;
}
public RemotePreferencePath withKey(String prefKey) {
if (this.key != null) {
throw new IllegalArgumentException("Path already has a key");
}
return new RemotePreferencePath(this.fileName, prefKey);
}
@Override
public String toString() {
String ret = "file:" + this.fileName;
if (this.key != null) {
ret += "/key:" + this.key;
}
return ret;
}
}
================================================
FILE: library/src/main/java/com/crossbowffs/remotepreferences/RemotePreferenceProvider.java
================================================
package com.crossbowffs.remotepreferences;
import android.content.ContentProvider;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.content.SharedPreferences;
import android.database.ContentObserver;
import android.database.Cursor;
import android.database.MatrixCursor;
import android.net.Uri;
import android.os.Build;
import java.util.HashMap;
import java.util.Map;
/**
* <p>
* Exposes {@link SharedPreferences} to other apps running on the device.
* </p>
*
* <p>
* You must extend this class and declare a 0-argument constructor which
* calls the super constructor with the appropriate authority and
* preference file name parameters. Remember to add your provider to
* your {@code AndroidManifest.xml} file and set the {@code android:exported}
* property to true.
* </p>
*
* <p>
* For granular access control, override {@link #checkAccess(String, String, boolean)}
* and return {@code false} to deny the operation.
* </p>
*
* <p>
* To access the data from a remote process, use {@link RemotePreferences}
* initialized with the same authority and the desired preference file name.
* You may also manually query the provider; here are some example queries
* and their equivalent {@link SharedPreferences} API calls:
* </p>
*
* <pre>
* query(uri = content://authority/foo/bar)
* = getSharedPreferences("foo").get("bar")
*
* query(uri = content://authority/foo)
* = getSharedPreferences("foo").getAll()
*
* insert(uri = content://authority/foo/bar, values = [{type = TYPE_STRING, value = "baz"}])
* = getSharedPreferences("foo").edit().putString("bar", "baz").commit()
*
* insert(uri = content://authority/foo, values = [{key = "bar", type = TYPE_STRING, value = "baz"}])
* = getSharedPreferences("foo").edit().putString("bar", "baz").commit()
*
* delete(uri = content://authority/foo/bar)
* = getSharedPreferences("foo").edit().remove("bar").commit()
*
* delete(uri = content://authority/foo)
* = getSharedPreferences("foo").edit().clear().commit()
* </pre>
*
* <p>
* Also note that if you are querying string sets, they will be returned
* in a serialized form: {@code ["foo;bar", "baz"]} is converted to
* {@code "foo\\;bar;baz;"} (note the trailing semicolon). Booleans are
* converted into integers: 1 for true, 0 for false. This is only applicable
* if you are using raw queries; all of these subtleties are transparently
* handled by {@link RemotePreferences}.
* </p>
*/
public abstract class RemotePreferenceProvider extends ContentProvider implements SharedPreferences.OnSharedPreferenceChangeListener {
private final Uri mBaseUri;
private final RemotePreferenceFile[] mPrefFiles;
private final Map<String, SharedPreferences> mPreferences;
private final RemotePreferenceUriParser mUriParser;
/**
* Initializes the remote preference provider with the specified
* authority and preference file names. The authority must match the
* {@code android:authorities} property defined in your manifest
* file. Only the specified preference files will be accessible
* through the provider. This constructor assumes all preferences
* are located in credential protected storage; if you are using
* device protected storage, use
* {@link #RemotePreferenceProvider(String, RemotePreferenceFile[])}.
*
* @param authority The authority of the provider.
* @param prefFileNames The names of the preference files to expose.
*/
public RemotePreferenceProvider(String authority, String[] prefFileNames) {
this(authority, RemotePreferenceFile.fromFileNames(prefFileNames));
}
/**
* Initializes the remote preference provider with the specified
* authority and preference files. The authority must match the
* {@code android:authorities} property defined in your manifest
* file. Only the specified preference files will be accessible
* through the provider.
*
* @param authority The authority of the provider.
* @param prefFiles The preference files to expose.
*/
public RemotePreferenceProvider(String authority, RemotePreferenceFile[] prefFiles) {
mBaseUri = Uri.parse("content://" + authority);
mPrefFiles = prefFiles;
mPreferences = new HashMap<String, SharedPreferences>(prefFiles.length);
mUriParser = new RemotePreferenceUriParser(authority);
}
/**
* Checks whether the specified preference is accessible by callers.
* The default implementation returns {@code true} for all accesses.
* You may override this method to control which preferences can be
* read or written. Note that {@code prefKey} will be {@code ""} when
* accessing an entire file, so a whitelist is strongly recommended
* over a blacklist (your default case should be {@code return false},
* not {@code return true}).
*
* @param prefFileName The name of the preference file.
* @param prefKey The preference key. This is an empty string when handling the
* {@link SharedPreferences#getAll()} and
* {@link SharedPreferences.Editor#clear()} operations.
* @param write {@code true} for put/remove/clear operations; {@code false} for get operations.
* @return {@code true} if the access is allowed; {@code false} otherwise.
*/
protected boolean checkAccess(String prefFileName, String prefKey, boolean write) {
return true;
}
/**
* Called at application startup to register preference change listeners.
*
* @return Always returns {@code true}.
*/
@Override
public boolean onCreate() {
// We register the shared preference listeners whenever the provider
// is created. This method is called before almost all other code in
// the app, which ensures that we never miss a preference change.
for (RemotePreferenceFile file : mPrefFiles) {
Context context = getContext();
if (file.isDeviceProtected() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
context = context.createDeviceProtectedStorageContext();
}
SharedPreferences prefs = getSharedPreferences(context, file.getFileName());
prefs.registerOnSharedPreferenceChangeListener(this);
mPreferences.put(file.getFileName(), prefs);
}
return true;
}
/**
* Generate {@link SharedPreferences} to store the key-value data.
* Override this method to provide a custom implementation of {@link SharedPreferences}.
*
* @param context The context that should be used to get the preferences object.
* @param prefFileName The name of the preference file.
* @return An object implementing the {@link SharedPreferences} interface.
*/
protected SharedPreferences getSharedPreferences(Context context, String prefFileName) {
return context.getSharedPreferences(prefFileName, Context.MODE_PRIVATE);
}
/**
* Returns a cursor for the specified preference(s). If {@code uri}
* is in the form {@code content://authority/prefFileName/prefKey}, the
* cursor will contain a single row containing the queried preference.
* If {@code uri} is in the form {@code content://authority/prefFileName},
* the cursor will contain one row for each preference in the specified
* file.
*
* @param uri Specifies the preference file and key (optional) to query.
* @param projection Specifies which fields should be returned in the cursor.
* @param selection Ignored.
* @param selectionArgs Ignored.
* @param sortOrder Ignored.
* @return A cursor used to access the queried preference data.
*/
@Override
public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
RemotePreferencePath prefPath = mUriParser.parse(uri);
SharedPreferences prefs = getSharedPreferencesOrThrow(prefPath, false);
Map<String, ?> prefMap = prefs.getAll();
// If no projection is specified, we return all columns.
if (projection == null) {
projection = RemoteContract.COLUMN_ALL;
}
// Fill out the cursor with the preference data. If the caller
// didn't ask for a particular preference, we return all of them.
MatrixCursor cursor = new MatrixCursor(projection);
if (isSingleKey(prefPath.key)) {
Object prefValue = prefMap.get(prefPath.key);
cursor.addRow(buildRow(projection, prefPath.key, prefValue));
} else {
for (Map.Entry<String, ?> entry : prefMap.entrySet()) {
String prefKey = entry.getKey();
Object prefValue = entry.getValue();
cursor.addRow(buildRow(projection, prefKey, prefValue));
}
}
return cursor;
}
/**
* Not used in RemotePreferences. Always returns {@code null}.
*
* @param uri Ignored.
* @return Always returns {@code null}.
*/
@Override
public String getType(Uri uri) {
return null;
}
/**
* Writes the value of the specified preference(s). If no key is specified,
* {@link RemoteContract#COLUMN_TYPE} must be equal to {@link RemoteContract#TYPE_NULL},
* representing the {@link SharedPreferences.Editor#clear()} operation.
*
* @param uri Specifies the preference file and key (optional) to write.
* @param values Specifies the key (optional), type and value of the preference to write.
* @return A URI representing the preference written, or {@code null} on failure.
*/
@Override
public Uri insert(Uri uri, ContentValues values) {
if (values == null) {
return null;
}
RemotePreferencePath prefPath = mUriParser.parse(uri);
String prefKey = getKeyFromUriOrValues(prefPath, values);
SharedPreferences prefs = getSharedPreferencesOrThrow(prefPath, true);
SharedPreferences.Editor editor = prefs.edit();
putPreference(editor, prefKey, values);
if (editor.commit()) {
return getPreferenceUri(prefPath.fileName, prefKey);
} else {
return null;
}
}
/**
* Writes multiple preference values at once. {@code uri} must
* be in the form {@code content://authority/prefFileName}. See
* {@link #insert(Uri, ContentValues)} for more information.
*
* @param uri Specifies the preference file to write to.
* @param values See {@link #insert(Uri, ContentValues)}.
* @return The number of preferences written, or 0 on failure.
*/
@Override
public int bulkInsert(Uri uri, ContentValues[] values) {
RemotePreferencePath prefPath = mUriParser.parse(uri);
if (isSingleKey(prefPath.key)) {
throw new IllegalArgumentException("Cannot bulk insert with single key URI");
}
SharedPreferences prefs = getSharedPreferencesByName(prefPath.fileName);
SharedPreferences.Editor editor = prefs.edit();
for (ContentValues value : values) {
String prefKey = getKeyFromValues(value);
checkAccessOrThrow(prefPath.withKey(prefKey), true);
putPreference(editor, prefKey, value);
}
if (editor.commit()) {
return values.length;
} else {
return 0;
}
}
/**
* Deletes the specified preference(s). If {@code uri} is in the form
* {@code content://authority/prefFileName/prefKey}, this will only delete
* the one preference specified in the URI; if {@code uri} is in the form
* {@code content://authority/prefFileName}, clears all preferences.
*
* @param uri Specifies the preference file and key (optional) to delete.
* @param selection Ignored.
* @param selectionArgs Ignored.
* @return 1 if the preferences committed successfully, or 0 on failure.
*/
@Override
public int delete(Uri uri, String selection, String[] selectionArgs) {
RemotePreferencePath prefPath = mUriParser.parse(uri);
SharedPreferences prefs = getSharedPreferencesOrThrow(prefPath, true);
SharedPreferences.Editor editor = prefs.edit();
if (isSingleKey(prefPath.key)) {
editor.remove(prefPath.key);
} else {
editor.clear();
}
// There's no reliable method of getting the actual number of
// preference values changed, so callers should not rely on this
// value. A return value of 1 means success, 0 means failure.
if (editor.commit()) {
return 1;
} else {
return 0;
}
}
/**
* Updates the value of the specified preference(s). This is a wrapper
* around {@link #insert(Uri, ContentValues)} if {@code values} is not
* {@code null}, or {@link #delete(Uri, String, String[])} if {@code values}
* is {@code null}.
*
* @param uri Specifies the preference file and key (optional) to update.
* @param values {@code null} to delete the preference,
* @param selection Ignored.
* @param selectionArgs Ignored.
* @return 1 if the preferences committed successfully, or 0 on failure.
*/
@Override
public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
if (values == null) {
return delete(uri, selection, selectionArgs);
} else {
return insert(uri, values) != null ? 1 : 0;
}
}
/**
* Listener for preference value changes in the local application.
* Re-raises the event through the
* {@link ContentResolver#notifyChange(Uri, ContentObserver)} API
* to any registered {@link ContentObserver} objects. Note that this
* is NOT called for {@link SharedPreferences.Editor#clear()}.
*
* @param prefs The preference file that changed.
* @param prefKey The preference key that changed.
*/
@Override
public void onSharedPreferenceChanged(SharedPreferences prefs, String prefKey) {
RemotePreferenceFile prefFile = getSharedPreferencesFile(prefs);
Uri uri = getPreferenceUri(prefFile.getFileName(), prefKey);
Context context = getContext();
if (prefFile.isDeviceProtected() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
context = context.createDeviceProtectedStorageContext();
}
ContentResolver resolver = context.getContentResolver();
resolver.notifyChange(uri, null);
}
/**
* Writes the value of the specified preference(s). If {@code prefKey}
* is empty, {@code values} must contain {@link RemoteContract#TYPE_NULL}
* for the type, representing the {@link SharedPreferences.Editor#clear()}
* operation.
*
* @param editor The preference file to modify.
* @param prefKey The preference key to modify, or {@code null} for the entire file.
* @param values The values to write.
*/
private void putPreference(SharedPreferences.Editor editor, String prefKey, ContentValues values) {
// Get the new value type. Note that we manually check
// for null, then unbox the Integer so we don't cause a NPE.
Integer type = values.getAsInteger(RemoteContract.COLUMN_TYPE);
if (type == null) {
throw new IllegalArgumentException("Invalid or no preference type specified");
}
// deserializeInput makes sure the actual object type matches
// the expected type, so we must perform this step before actually
// performing any actions.
Object rawValue = values.get(RemoteContract.COLUMN_VALUE);
Object value = RemoteUtils.deserializeInput(rawValue, type);
// If we are writing to the "directory" and the type is null,
// then we should clear the preferences.
if (!isSingleKey(prefKey)) {
if (type == RemoteContract.TYPE_NULL) {
editor.clear();
return;
} else {
throw new IllegalArgumentException("Attempting to insert preference with null or empty key");
}
}
switch (type) {
case RemoteContract.TYPE_NULL:
editor.remove(prefKey);
break;
case RemoteContract.TYPE_STRING:
editor.putString(prefKey, (String)value);
break;
case RemoteContract.TYPE_STRING_SET:
if (Build.VERSION.SDK_INT >= 11) {
editor.putStringSet(prefKey, RemoteUtils.castStringSet(value));
} else {
throw new IllegalArgumentException("String set preferences not supported on API < 11");
}
break;
case RemoteContract.TYPE_INT:
editor.putInt(prefKey, (Integer)value);
break;
case RemoteContract.TYPE_LONG:
editor.putLong(prefKey, (Long)value);
break;
case RemoteContract.TYPE_FLOAT:
editor.putFloat(prefKey, (Float)value);
break;
case RemoteContract.TYPE_BOOLEAN:
editor.putBoolean(prefKey, (Boolean)value);
break;
default:
throw new IllegalArgumentException("Cannot set preference with type " + type);
}
}
/**
* Used to project a preference value to the schema requested by the caller.
*
* @param projection The projection requested by the caller.
* @param key The preference key.
* @param value The preference value.
* @return A row representing the preference using the given schema.
*/
private Object[] buildRow(String[] projection, String key, Object value) {
Object[] row = new Object[projection.length];
for (int i = 0; i < row.length; ++i) {
String col = projection[i];
if (RemoteContract.COLUMN_KEY.equals(col)) {
row[i] = key;
} else if (RemoteContract.COLUMN_TYPE.equals(col)) {
row[i] = RemoteUtils.getPreferenceType(value);
} else if (RemoteContract.COLUMN_VALUE.equals(col)) {
row[i] = RemoteUtils.serializeOutput(value);
} else {
throw new IllegalArgumentException("Invalid column name: " + col);
}
}
return row;
}
/**
* Returns whether the specified key represents a single preference key
* (as opposed to the entire preference file).
*
* @param prefKey The preference key to check.
* @return Whether the key refers to a single preference.
*/
private static boolean isSingleKey(String prefKey) {
return prefKey != null;
}
/**
* Parses the preference key from {@code values}. If the key is not
* specified in the values, {@code null} is returned.
*
* @param values The query values to parse.
* @return The parsed key, or {@code null} if no key was found.
*/
private static String getKeyFromValues(ContentValues values) {
String key = values.getAsString(RemoteContract.COLUMN_KEY);
if (key != null && key.length() == 0) {
key = null;
}
return key;
}
/**
* Parses the preference key from the specified sources. Since there
* are two ways to specify the key (from the URI or from the query values),
* the only allowed combinations are:
*
* uri.key == values.key
* uri.key != null and values.key == null = URI key is used
* uri.key == null and values.key != null = values key is used
* uri.key == null and values.key == null = no key
*
* If none of these conditions are met, an exception is thrown.
*
* @param prefPath Parsed URI key from {@code mUriParser.parse(uri)}.
* @param values Query values provided by the caller.
* @return The parsed key, or {@code null} if the key refers to a preference file.
*/
private static String getKeyFromUriOrValues(RemotePreferencePath prefPath, ContentValues values) {
String uriKey = prefPath.key;
String valuesKey = getKeyFromValues(values);
if (isSingleKey(uriKey) && isSingleKey(valuesKey)) {
// If a key is specified in both the URI and
// ContentValues, they must match
if (!uriKey.equals(valuesKey)) {
throw new IllegalArgumentException("Conflicting keys specified in URI and ContentValues");
}
return uriKey;
} else if (isSingleKey(uriKey)) {
return uriKey;
} else if (isSingleKey(valuesKey)) {
return valuesKey;
} else {
return null;
}
}
/**
* Checks that the caller has permissions to access the specified preference.
* Throws an exception if permission is denied.
*
* @param prefPath The preference file and key to be accessed.
* @param write Whether the operation will modify the preference.
*/
private void checkAccessOrThrow(RemotePreferencePath prefPath, boolean write) {
// For backwards compatibility, checkAccess takes an empty string when
// referring to the whole file.
String prefKey = prefPath.key;
if (!isSingleKey(prefKey)) {
prefKey = "";
}
if (!checkAccess(prefPath.fileName, prefKey, write)) {
throw new SecurityException("Insufficient permissions to access: " + prefPath);
}
}
/**
* Returns the {@link SharedPreferences} instance with the specified name.
* This is essentially equivalent to {@link Context#getSharedPreferences(String, int)},
* except that it will used the internally cached version, and throws an
* exception if the provider was not configured to access that preference file.
*
* @param prefFileName The name of the preference file to access.
* @return The {@link SharedPreferences} instance with the specified file name.
*/
private SharedPreferences getSharedPreferencesByName(String prefFileName) {
SharedPreferences prefs = mPreferences.get(prefFileName);
if (prefs == null) {
throw new IllegalArgumentException("Unknown preference file name: " + prefFileName);
}
return prefs;
}
/**
* Returns the file name for a {@link SharedPreferences} instance.
* Throws an exception if the provider was not configured to access
* the specified preferences.
*
* @param prefs The shared preferences object.
* @return The name of the preference file.
*/
private String getSharedPreferencesFileName(SharedPreferences prefs) {
for (Map.Entry<String, SharedPreferences> entry : mPreferences.entrySet()) {
if (entry.getValue() == prefs) {
return entry.getKey();
}
}
throw new IllegalArgumentException("Unknown preference file");
}
/**
* Get the corresponding {@link RemotePreferenceFile} object for a
* {@link SharedPreferences} instance. Throws an exception if the
* provider was not configured to access the specified preferences.
*
* @param prefs The shared preferences object.
* @return The corresponding {@link RemotePreferenceFile} object.
*/
private RemotePreferenceFile getSharedPreferencesFile(SharedPreferences prefs) {
String prefFileName = getSharedPreferencesFileName(prefs);
for (RemotePreferenceFile file : mPrefFiles) {
if (file.getFileName().equals(prefFileName)) {
return file;
}
}
throw new IllegalArgumentException("Unknown preference file");
}
/**
* Returns the {@link SharedPreferences} instance with the specified name,
* checking that the caller has permissions to access the specified key within
* that file. If not, an exception will be thrown.
*
* @param prefPath The preference file and key to be accessed.
* @param write Whether the operation will modify the preference.
* @return The {@link SharedPreferences} instance with the specified file name.
*/
private SharedPreferences getSharedPreferencesOrThrow(RemotePreferencePath prefPath, boolean write) {
checkAccessOrThrow(prefPath, write);
return getSharedPreferencesByName(prefPath.fileName);
}
/**
* Builds a URI for the specified preference file and key that can be used
* to later query the same preference.
*
* @param prefFileName The preference file.
* @param prefKey The preference key.
* @return A URI representing the specified preference.
*/
private Uri getPreferenceUri(String prefFileName, String prefKey) {
Uri.Builder builder = mBaseUri.buildUpon().appendPath(prefFileName);
if (isSingleKey(prefKey)) {
builder.appendPath(prefKey);
}
return builder.build();
}
}
================================================
FILE: library/src/main/java/com/crossbowffs/remotepreferences/RemotePreferenceUriParser.java
================================================
package com.crossbowffs.remotepreferences;
import android.content.UriMatcher;
import android.net.Uri;
import java.util.List;
/**
* Decodes URIs passed between {@link RemotePreferences} and {@link RemotePreferenceProvider}.
*/
/* package */ class RemotePreferenceUriParser {
private static final int PREFERENCES_ID = 1;
private static final int PREFERENCE_ID = 2;
private final UriMatcher mUriMatcher;
public RemotePreferenceUriParser(String authority) {
mUriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
mUriMatcher.addURI(authority, "*/", PREFERENCES_ID);
mUriMatcher.addURI(authority, "*/*", PREFERENCE_ID);
}
/**
* Parses the preference file and key from a query URI. If the key
* is not specified, the returned path will contain {@code null} as the key.
*
* @param uri The URI to parse.
* @return A path object containing the preference file name and key.
*/
public RemotePreferencePath parse(Uri uri) {
int match = mUriMatcher.match(uri);
if (match != PREFERENCE_ID && match != PREFERENCES_ID) {
throw new IllegalArgumentException("Invalid URI: " + uri);
}
// The URI must fall under one of these patterns:
//
// content://authority/prefFileName/prefKey
// content://authority/prefFileName/
// content://authority/prefFileName
//
// The match ID will be PREFERENCE_ID under the first case,
// and PREFERENCES_ID under the second and third cases
// (UriMatcher ignores trailing slashes).
List<String> pathSegments = uri.getPathSegments();
String prefFileName = pathSegments.get(0);
String prefKey = null;
if (match == PREFERENCE_ID) {
prefKey = pathSegments.get(1);
}
return new RemotePreferencePath(prefFileName, prefKey);
}
}
================================================
FILE: library/src/main/java/com/crossbowffs/remotepreferences/RemotePreferences.java
================================================
package com.crossbowffs.remotepreferences;
import android.annotation.TargetApi;
import android.content.ContentValues;
import android.content.Context;
import android.content.SharedPreferences;
import android.database.ContentObserver;
import android.database.Cursor;
import android.net.Uri;
import android.os.Build;
import android.os.Handler;
import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.WeakHashMap;
/**
* <p>
* Provides a {@link SharedPreferences} compatible API to
* {@link RemotePreferenceProvider}. See {@link RemotePreferenceProvider}
* for more information.
* </p>
*
* <p>
* If you are reading preferences from the same context as the
* provider, you should not use this class; just access the
* {@link SharedPreferences} API as you would normally.
* </p>
*/
public class RemotePreferences implements SharedPreferences {
private final Context mContext;
private final Handler mHandler;
private final Uri mBaseUri;
private final boolean mStrictMode;
private final WeakHashMap<OnSharedPreferenceChangeListener, PreferenceContentObserver> mListeners;
private final RemotePreferenceUriParser mUriParser;
/**
* Initializes a new remote preferences object, with strict
* mode disabled.
*
* @param context Used to access the preference provider.
* @param authority The authority of the preference provider.
* @param prefFileName The name of the preference file to access.
*/
public RemotePreferences(Context context, String authority, String prefFileName) {
this(context, authority, prefFileName, false);
}
/**
* Initializes a new remote preferences object. If {@code strictMode}
* is {@code true} and the remote preference provider cannot be accessed,
* read/write operations on this object will throw a
* {@link RemotePreferenceAccessException}. Otherwise, default values
* will be returned.
*
* @param context Used to access the preference provider.
* @param authority The authority of the preference provider.
* @param prefFileName The name of the preference file to access.
* @param strictMode Whether strict mode is enabled.
*/
public RemotePreferences(Context context, String authority, String prefFileName, boolean strictMode) {
this(context, new Handler(context.getMainLooper()), authority, prefFileName, strictMode);
}
/**
* Initializes a new remote preferences object. If {@code strictMode}
* is {@code true} and the remote preference provider cannot be accessed,
* read/write operations on this object will throw a
* {@link RemotePreferenceAccessException}. Otherwise, default values
* will be returned.
*
* @param context Used to access the preference provider.
* @param handler Used to receive preference change events.
* @param authority The authority of the preference provider.
* @param prefFileName The name of the preference file to access.
* @param strictMode Whether strict mode is enabled.
*/
/* package */ RemotePreferences(Context context, Handler handler, String authority, String prefFileName, boolean strictMode) {
checkNotNull("context", context);
checkNotNull("handler", handler);
checkNotNull("authority", authority);
checkNotNull("prefFileName", prefFileName);
mContext = context;
mHandler = handler;
mBaseUri = Uri.parse("content://" + authority).buildUpon().appendPath(prefFileName).build();
mStrictMode = strictMode;
mListeners = new WeakHashMap<OnSharedPreferenceChangeListener, PreferenceContentObserver>();
mUriParser = new RemotePreferenceUriParser(authority);
}
@Override
public Map<String, ?> getAll() {
return queryAll();
}
@Override
public String getString(String key, String defValue) {
return (String)querySingle(key, defValue, RemoteContract.TYPE_STRING);
}
@Override
@TargetApi(11)
public Set<String> getStringSet(String key, Set<String> defValues) {
if (Build.VERSION.SDK_INT < 11) {
throw new UnsupportedOperationException("String sets only supported on API 11 and above");
}
return RemoteUtils.castStringSet(querySingle(key, defValues, RemoteContract.TYPE_STRING_SET));
}
@Override
public int getInt(String key, int defValue) {
return (Integer)querySingle(key, defValue, RemoteContract.TYPE_INT);
}
@Override
public long getLong(String key, long defValue) {
return (Long)querySingle(key, defValue, RemoteContract.TYPE_LONG);
}
@Override
public float getFloat(String key, float defValue) {
return (Float)querySingle(key, defValue, RemoteContract.TYPE_FLOAT);
}
@Override
public boolean getBoolean(String key, boolean defValue) {
return (Boolean)querySingle(key, defValue, RemoteContract.TYPE_BOOLEAN);
}
@Override
public boolean contains(String key) {
return containsKey(key);
}
@Override
public Editor edit() {
return new RemotePreferencesEditor();
}
@Override
public void registerOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {
checkNotNull("listener", listener);
if (mListeners.containsKey(listener)) return;
PreferenceContentObserver observer = new PreferenceContentObserver(listener);
mListeners.put(listener, observer);
mContext.getContentResolver().registerContentObserver(mBaseUri, true, observer);
}
@Override
public void unregisterOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {
checkNotNull("listener", listener);
PreferenceContentObserver observer = mListeners.remove(listener);
if (observer != null) {
mContext.getContentResolver().unregisterContentObserver(observer);
}
}
/**
* If {@code object} is {@code null}, throws an exception.
*
* @param name The name of the object, for use in the exception message.
* @param object The object to check.
*/
private static void checkNotNull(String name, Object object) {
if (object == null) {
throw new IllegalArgumentException(name + " is null");
}
}
/**
* If {@code key} is {@code null} or {@code ""}, throws an exception.
*
* @param key The object to check.
*/
private static void checkKeyNotEmpty(String key) {
if (key == null || key.length() == 0) {
throw new IllegalArgumentException("Key is null or empty");
}
}
/**
* If strict mode is enabled, wraps and throws the given exception.
* Otherwise, does nothing.
*
* @param e The exception to wrap.
*/
private void wrapException(Exception e) {
if (mStrictMode) {
throw new RemotePreferenceAccessException(e);
}
}
/**
* Queries the specified URI. If the query fails and strict mode is
* enabled, an exception will be thrown; otherwise {@code null} will
* be returned.
*
* @param uri The URI to query.
* @param columns The columns to include in the returned cursor.
* @return A cursor used to access the queried preference data.
*/
private Cursor query(Uri uri, String[] columns) {
Cursor cursor = null;
try {
cursor = mContext.getContentResolver().query(uri, columns, null, null, null);
} catch (Exception e) {
wrapException(e);
}
if (cursor == null && mStrictMode) {
throw new RemotePreferenceAccessException("query() failed or returned null cursor");
}
return cursor;
}
/**
* Writes multiple preferences at once to the preference provider.
* If the operation fails and strict mode is enabled, an exception
* will be thrown; otherwise {@code false} will be returned.
*
* @param uri The URI to modify.
* @param values The values to write.
* @return Whether the operation succeeded.
*/
private boolean bulkInsert(Uri uri, ContentValues[] values) {
int count;
try {
count = mContext.getContentResolver().bulkInsert(uri, values);
} catch (Exception e) {
wrapException(e);
return false;
}
if (count != values.length && mStrictMode) {
throw new RemotePreferenceAccessException("bulkInsert() failed");
}
return count == values.length;
}
/**
* Reads a single preference from the preference provider. This may
* throw a {@link ClassCastException} even if strict mode is disabled
* if the provider returns an incompatible type. If strict mode is
* disabled and the preference cannot be read, the default value is returned.
*
* @param key The preference key to read.
* @param defValue The default value, if there is no existing value.
* @param expectedType The expected type of the value.
* @return The value of the preference, or {@code defValue} if no value exists.
*/
private Object querySingle(String key, Object defValue, int expectedType) {
checkKeyNotEmpty(key);
Uri uri = mBaseUri.buildUpon().appendPath(key).build();
String[] columns = {RemoteContract.COLUMN_TYPE, RemoteContract.COLUMN_VALUE};
Cursor cursor = query(uri, columns);
try {
if (cursor == null || !cursor.moveToFirst()) {
return defValue;
}
int typeCol = cursor.getColumnIndexOrThrow(RemoteContract.COLUMN_TYPE);
int type = cursor.getInt(typeCol);
if (type == RemoteContract.TYPE_NULL) {
return defValue;
} else if (type != expectedType) {
throw new ClassCastException("Preference type mismatch");
}
int valueCol = cursor.getColumnIndexOrThrow(RemoteContract.COLUMN_VALUE);
return getValue(cursor, typeCol, valueCol);
} finally {
if (cursor != null) {
cursor.close();
}
}
}
/**
* Reads all preferences from the preference provider. If strict
* mode is disabled and the preferences cannot be read, an empty
* map is returned.
*
* @return A map containing all preferences.
*/
private Map<String, Object> queryAll() {
Uri uri = mBaseUri.buildUpon().appendPath("").build();
String[] columns = {RemoteContract.COLUMN_KEY, RemoteContract.COLUMN_TYPE, RemoteContract.COLUMN_VALUE};
Cursor cursor = query(uri, columns);
try {
HashMap<String, Object> map = new HashMap<String, Object>();
if (cursor == null) {
return map;
}
int keyCol = cursor.getColumnIndexOrThrow(RemoteContract.COLUMN_KEY);
int typeCol = cursor.getColumnIndexOrThrow(RemoteContract.COLUMN_TYPE);
int valueCol = cursor.getColumnIndexOrThrow(RemoteContract.COLUMN_VALUE);
while (cursor.moveToNext()) {
String key = cursor.getString(keyCol);
map.put(key, getValue(cursor, typeCol, valueCol));
}
return map;
} finally {
if (cursor != null) {
cursor.close();
}
}
}
/**
* Checks whether the preference exists. If strict mode is
* disabled and the preferences cannot be read, {@code false}
* is returned.
*
* @param key The key to check existence for.
* @return Whether the preference exists.
*/
private boolean containsKey(String key) {
checkKeyNotEmpty(key);
Uri uri = mBaseUri.buildUpon().appendPath(key).build();
String[] columns = {RemoteContract.COLUMN_TYPE};
Cursor cursor = query(uri, columns);
try {
if (cursor == null || !cursor.moveToFirst()) {
return false;
}
int typeCol = cursor.getColumnIndexOrThrow(RemoteContract.COLUMN_TYPE);
return cursor.getInt(typeCol) != RemoteContract.TYPE_NULL;
} finally {
if (cursor != null) {
cursor.close();
}
}
}
/**
* Extracts a preference value from a cursor. Performs deserialization
* of the value if necessary.
*
* @param cursor The cursor containing the preference value.
* @param typeCol The index containing the {@link RemoteContract#COLUMN_TYPE} column.
* @param valueCol The index containing the {@link RemoteContract#COLUMN_VALUE} column.
* @return The value from the cursor.
*/
private Object getValue(Cursor cursor, int typeCol, int valueCol) {
int expectedType = cursor.getInt(typeCol);
switch (expectedType) {
case RemoteContract.TYPE_STRING:
return cursor.getString(valueCol);
case RemoteContract.TYPE_STRING_SET:
return RemoteUtils.deserializeStringSet(cursor.getString(valueCol));
case RemoteContract.TYPE_INT:
return cursor.getInt(valueCol);
case RemoteContract.TYPE_LONG:
return cursor.getLong(valueCol);
case RemoteContract.TYPE_FLOAT:
return cursor.getFloat(valueCol);
case RemoteContract.TYPE_BOOLEAN:
return cursor.getInt(valueCol) != 0;
default:
throw new AssertionError("Invalid expected type: " + expectedType);
}
}
/**
* Implementation of the {@link SharedPreferences.Editor} interface
* for use with RemotePreferences.
*/
private class RemotePreferencesEditor implements Editor {
private final ArrayList<ContentValues> mValues = new ArrayList<ContentValues>();
/**
* Creates a new {@link ContentValues} with the specified key and
* type columns pre-filled. The {@link RemoteContract#COLUMN_VALUE}
* field is NOT filled in.
*
* @param key The preference key.
* @param type The preference type.
* @return The pre-filled values.
*/
private ContentValues createContentValues(String key, int type) {
ContentValues values = new ContentValues(4);
values.put(RemoteContract.COLUMN_KEY, key);
values.put(RemoteContract.COLUMN_TYPE, type);
return values;
}
/**
* Creates an operation to add/set a new preference. Again, the
* {@link RemoteContract#COLUMN_VALUE} field is NOT filled in.
* This will also add the values to the operation queue.
*
* @param key The preference key to add.
* @param type The preference type to add.
* @return The pre-filled values.
*/
private ContentValues createAddOp(String key, int type) {
checkKeyNotEmpty(key);
ContentValues values = createContentValues(key, type);
mValues.add(values);
return values;
}
/**
* Creates an operation to delete a preference. All fields
* are pre-filled. This will also add the values to the
* operation queue.
*
* @param key The preference key to delete.
* @return The pre-filled values.
*/
private ContentValues createRemoveOp(String key) {
// Note: Remove operations are inserted at the beginning
// of the list (this preserves the SharedPreferences behavior
// that all removes are performed before any adds)
ContentValues values = createContentValues(key, RemoteContract.TYPE_NULL);
values.putNull(RemoteContract.COLUMN_VALUE);
mValues.add(0, values);
return values;
}
@Override
public Editor putString(String key, String value) {
createAddOp(key, RemoteContract.TYPE_STRING).put(RemoteContract.COLUMN_VALUE, value);
return this;
}
@Override
@TargetApi(11)
public Editor putStringSet(String key, Set<String> value) {
if (Build.VERSION.SDK_INT < 11) {
throw new UnsupportedOperationException("String sets only supported on API 11 and above");
}
String serializedSet = RemoteUtils.serializeStringSet(value);
createAddOp(key, RemoteContract.TYPE_STRING_SET).put(RemoteContract.COLUMN_VALUE, serializedSet);
return this;
}
@Override
public Editor putInt(String key, int value) {
createAddOp(key, RemoteContract.TYPE_INT).put(RemoteContract.COLUMN_VALUE, value);
return this;
}
@Override
public Editor putLong(String key, long value) {
createAddOp(key, RemoteContract.TYPE_LONG).put(RemoteContract.COLUMN_VALUE, value);
return this;
}
@Override
public Editor putFloat(String key, float value) {
createAddOp(key, RemoteContract.TYPE_FLOAT).put(RemoteContract.COLUMN_VALUE, value);
return this;
}
@Override
public Editor putBoolean(String key, boolean value) {
createAddOp(key, RemoteContract.TYPE_BOOLEAN).put(RemoteContract.COLUMN_VALUE, value ? 1 : 0);
return this;
}
@Override
public Editor remove(String key) {
checkKeyNotEmpty(key);
createRemoveOp(key);
return this;
}
@Override
public Editor clear() {
createRemoveOp("");
return this;
}
@Override
public boolean commit() {
ContentValues[] values = mValues.toArray(new ContentValues[mValues.size()]);
Uri uri = mBaseUri.buildUpon().appendPath("").build();
return bulkInsert(uri, values);
}
@Override
public void apply() {
commit();
}
}
/**
* {@link ContentObserver} subclass used to monitor preference changes
* in the remote preference provider. When a change is detected, this will notify
* the corresponding {@link SharedPreferences.OnSharedPreferenceChangeListener}.
*/
private class PreferenceContentObserver extends ContentObserver {
private final WeakReference<OnSharedPreferenceChangeListener> mListener;
private PreferenceContentObserver(OnSharedPreferenceChangeListener listener) {
super(mHandler);
mListener = new WeakReference<OnSharedPreferenceChangeListener>(listener);
}
@Override
public boolean deliverSelfNotifications() {
return true;
}
@Override
public void onChange(boolean selfChange, Uri uri) {
RemotePreferencePath path = mUriParser.parse(uri);
// We use a weak reference to mimic the behavior of SharedPreferences.
// The code which registered the listener is responsible for holding a
// reference to it. If at any point we find that the listener has been
// garbage collected, we unregister the observer.
OnSharedPreferenceChangeListener listener = mListener.get();
if (listener == null) {
mContext.getContentResolver().unregisterContentObserver(this);
} else {
listener.onSharedPreferenceChanged(RemotePreferences.this, path.key);
}
}
}
}
================================================
FILE: library/src/main/java/com/crossbowffs/remotepreferences/RemoteUtils.java
================================================
package com.crossbowffs.remotepreferences;
import java.util.HashSet;
import java.util.Set;
/**
* Common utilities used to serialize and deserialize
* preferences between the preference provider and caller.
*/
/* package */ final class RemoteUtils {
private RemoteUtils() {}
/**
* Casts the parameter to a string set. Useful to avoid the unchecked
* warning that would normally come with the cast. The value must
* already be a string set; this does not deserialize it.
*
* @param value The value, as type {@link Object}.
* @return The value, as type {@link Set<String>}.
*/
@SuppressWarnings("unchecked")
public static Set<String> castStringSet(Object value) {
return (Set<String>)value;
}
/**
* Returns the {@code TYPE_*} constant corresponding to the given
* object's type.
*
* @param value The original object.
* @return One of the {@link RemoteContract}{@code .TYPE_*} constants.
*/
public static int getPreferenceType(Object value) {
if (value == null) return RemoteContract.TYPE_NULL;
if (value instanceof String) return RemoteContract.TYPE_STRING;
if (value instanceof Set<?>) return RemoteContract.TYPE_STRING_SET;
if (value instanceof Integer) return RemoteContract.TYPE_INT;
if (value instanceof Long) return RemoteContract.TYPE_LONG;
if (value instanceof Float) return RemoteContract.TYPE_FLOAT;
if (value instanceof Boolean) return RemoteContract.TYPE_BOOLEAN;
throw new AssertionError("Unknown preference type: " + value.getClass());
}
/**
* Serializes the specified object to a format that is safe to use
* with {@link android.content.ContentValues}. To recover the original
* object, use {@link #deserializeInput(Object, int)}.
*
* @param value The object to serialize.
* @return The serialized object.
*/
public static Object serializeOutput(Object value) {
if (value instanceof Boolean) {
return serializeBoolean((Boolean)value);
} else if (value instanceof Set<?>) {
return serializeStringSet(castStringSet(value));
} else {
return value;
}
}
/**
* Deserializes an object that was serialized using
* {@link #serializeOutput(Object)}. If the expected type does
* not match the actual type of the object, a {@link ClassCastException}
* will be thrown.
*
* @param value The object to deserialize.
* @param expectedType The expected type of the deserialized object.
* @return The deserialized object.
*/
public static Object deserializeInput(Object value, int expectedType) {
if (expectedType == RemoteContract.TYPE_NULL) {
if (value != null) {
throw new IllegalArgumentException("Expected null, got non-null value");
} else {
return null;
}
}
try {
switch (expectedType) {
case RemoteContract.TYPE_STRING:
return (String)value;
case RemoteContract.TYPE_STRING_SET:
return deserializeStringSet((String)value);
case RemoteContract.TYPE_INT:
return (Integer)value;
case RemoteContract.TYPE_LONG:
return (Long)value;
case RemoteContract.TYPE_FLOAT:
return (Float)value;
case RemoteContract.TYPE_BOOLEAN:
return deserializeBoolean(value);
}
} catch (ClassCastException e) {
throw new IllegalArgumentException("Expected type " + expectedType + ", got " + value.getClass(), e);
}
throw new IllegalArgumentException("Unknown type: " + expectedType);
}
/**
* Serializes a {@link Boolean} to a format that is safe to use
* with {@link android.content.ContentValues}.
*
* @param value The {@link Boolean} to serialize.
* @return 1 if {@code value} is {@code true}, 0 if {@code value} is {@code false}.
*/
private static Integer serializeBoolean(Boolean value) {
if (value == null) {
return null;
} else {
return value ? 1 : 0;
}
}
/**
* Deserializes a {@link Boolean} that was serialized using
* {@link #serializeBoolean(Boolean)}.
*
* @param value The {@link Boolean} to deserialize.
* @return {@code true} if {@code value} is 1, {@code false} if {@code value} is 0.
*/
private static Boolean deserializeBoolean(Object value) {
if (value == null) {
return null;
} else if (value instanceof Boolean) {
return (Boolean)value;
} else {
return (Integer)value != 0;
}
}
/**
* Serializes a {@link Set<String>} to a format that is safe to use
* with {@link android.content.ContentValues}.
*
* @param stringSet The {@link Set<String>} to serialize.
* @return The serialized string set.
*/
public static String serializeStringSet(Set<String> stringSet) {
if (stringSet == null) {
return null;
}
StringBuilder sb = new StringBuilder();
for (String s : stringSet) {
sb.append(s.replace("\\", "\\\\").replace(";", "\\;"));
sb.append(';');
}
return sb.toString();
}
/**
* Deserializes a {@link Set<String>} that was serialized using
* {@link #serializeStringSet(Set)}.
*
* @param serializedString The {@link Set<String>} to deserialize.
* @return The deserialized string set.
*/
public static Set<String> deserializeStringSet(String serializedString) {
if (serializedString == null) {
return null;
}
HashSet<String> stringSet = new HashSet<String>();
StringBuilder sb = new StringBuilder();
for (int i = 0; i < serializedString.length(); ++i) {
char c = serializedString.charAt(i);
if (c == '\\') {
char next = serializedString.charAt(++i);
sb.append(next);
} else if (c == ';') {
stringSet.add(sb.toString());
sb.delete(0, sb.length());
} else {
sb.append(c);
}
}
// We require that the serialized string ends with a ; per element
// since that's how we distinguish empty sets from sets containing
// an empty string. Assume caller is doing unsafe string joins
// instead of using the serializeStringSet API, and fail fast.
if (sb.length() != 0) {
throw new IllegalArgumentException("Serialized string set contains trailing chars");
}
return stringSet;
}
}
================================================
FILE: settings.gradle.kts
================================================
include(":library")
include(":testapp")
================================================
FILE: testapp/build.gradle.kts
================================================
plugins {
id("com.android.application")
}
android {
namespace = "com.crossbowffs.remotepreferences.testapp"
compileSdk = 34
defaultConfig {
minSdk = 14
targetSdk = 34
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
}
dependencies {
implementation(project(":library"))
androidTestImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test:core:1.5.0")
androidTestImplementation("androidx.test:runner:1.5.2")
androidTestImplementation("androidx.test:rules:1.5.0")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
}
================================================
FILE: testapp/src/androidTest/java/com/crossbowffs/remotepreferences/RemotePreferenceProviderTest.java
================================================
package com.crossbowffs.remotepreferences;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.content.SharedPreferences;
import android.database.Cursor;
import android.net.Uri;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;
import com.crossbowffs.remotepreferences.testapp.TestConstants;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import java.util.HashSet;
@RunWith(AndroidJUnit4.class)
public class RemotePreferenceProviderTest {
private Context getLocalContext() {
return InstrumentationRegistry.getInstrumentation().getContext();
}
private Context getRemoteContext() {
return InstrumentationRegistry.getInstrumentation().getTargetContext();
}
private SharedPreferences getSharedPreferences() {
Context context = getRemoteContext();
return context.getSharedPreferences(TestConstants.PREF_FILE, Context.MODE_PRIVATE);
}
private Uri getQueryUri(String key) {
String uri = "content://" + TestConstants.AUTHORITY + "/" + TestConstants.PREF_FILE;
if (key != null) {
uri += "/" + key;
}
return Uri.parse(uri);
}
@Before
public void resetPreferences() {
getSharedPreferences().edit().clear().commit();
}
@Test
public void testQueryAllPrefs() {
getSharedPreferences()
.edit()
.putString("string", "foobar")
.putInt("int", 1337)
.apply();
ContentResolver resolver = getLocalContext().getContentResolver();
Cursor q = resolver.query(getQueryUri(null), null, null, null, null);
Assert.assertEquals(2, q.getCount());
int key = q.getColumnIndex(RemoteContract.COLUMN_KEY);
int type = q.getColumnIndex(RemoteContract.COLUMN_TYPE);
int value = q.getColumnIndex(RemoteContract.COLUMN_VALUE);
while (q.moveToNext()) {
if (q.getString(key).equals("string")) {
Assert.assertEquals(RemoteContract.TYPE_STRING, q.getInt(type));
Assert.assertEquals("foobar", q.getString(value));
} else if (q.getString(key).equals("int")) {
Assert.assertEquals(RemoteContract.TYPE_INT, q.getInt(type));
Assert.assertEquals(1337, q.getInt(value));
} else {
Assert.fail();
}
}
}
@Test
public void testQuerySinglePref() {
getSharedPreferences()
.edit()
.putString("string", "foobar")
.putInt("int", 1337)
.apply();
ContentResolver resolver = getLocalContext().getContentResolver();
Cursor q = resolver.query(getQueryUri("string"), null, null, null, null);
Assert.assertEquals(1, q.getCount());
int key = q.getColumnIndex(RemoteContract.COLUMN_KEY);
int type = q.getColumnIndex(RemoteContract.COLUMN_TYPE);
int value = q.getColumnIndex(RemoteContract.COLUMN_VALUE);
q.moveToFirst();
Assert.assertEquals("string", q.getString(key));
Assert.assertEquals(RemoteContract.TYPE_STRING, q.getInt(type));
Assert.assertEquals("foobar", q.getString(value));
}
@Test
public void testQueryFailPermissionCheck() {
getSharedPreferences()
.edit()
.putString(TestConstants.UNREADABLE_PREF_KEY, "foobar")
.apply();
ContentResolver resolver = getLocalContext().getContentResolver();
try {
resolver.query(getQueryUri(TestConstants.UNREADABLE_PREF_KEY), null, null, null, null);
Assert.fail();
} catch (SecurityException e) {
// Expected
}
}
@Test
public void testInsertPref() {
ContentValues values = new ContentValues();
values.put(RemoteContract.COLUMN_KEY, "string");
values.put(RemoteContract.COLUMN_TYPE, RemoteContract.TYPE_STRING);
values.put(RemoteContract.COLUMN_VALUE, "foobar");
ContentResolver resolver = getLocalContext().getContentResolver();
Uri uri = resolver.insert(getQueryUri(null), values);
Assert.assertEquals(getQueryUri("string"), uri);
SharedPreferences prefs = getSharedPreferences();
Assert.assertEquals("foobar", prefs.getString("string", null));
}
@Test
public void testInsertOverridePref() {
SharedPreferences prefs = getSharedPreferences();
prefs
.edit()
.putString("string", "nyaa")
.putInt("int", 1337)
.apply();
ContentValues values = new ContentValues();
values.put(RemoteContract.COLUMN_KEY, "string");
values.put(RemoteContract.COLUMN_TYPE, RemoteContract.TYPE_STRING);
values.put(RemoteContract.COLUMN_VALUE, "foobar");
ContentResolver resolver = getLocalContext().getContentResolver();
Uri uri = resolver.insert(getQueryUri(null), values);
Assert.assertEquals(getQueryUri("string"), uri);
Assert.assertEquals("foobar", prefs.getString("string", null));
Assert.assertEquals(1337, prefs.getInt("int", 0));
}
@Test
public void testInsertPrefKeyInUri() {
ContentValues values = new ContentValues();
values.put(RemoteContract.COLUMN_TYPE, RemoteContract.TYPE_STRING);
values.put(RemoteContract.COLUMN_VALUE, "foobar");
ContentResolver resolver = getLocalContext().getContentResolver();
Uri uri = resolver.insert(getQueryUri("string"), values);
Assert.assertEquals(getQueryUri("string"), uri);
SharedPreferences prefs = getSharedPreferences();
Assert.assertEquals("foobar", prefs.getString("string", null));
}
@Test
public void testInsertPrefKeyInUriAndValues() {
ContentValues values = new ContentValues();
values.put(RemoteContract.COLUMN_KEY, "string");
values.put(RemoteContract.COLUMN_TYPE, RemoteContract.TYPE_STRING);
values.put(RemoteContract.COLUMN_VALUE, "foobar");
ContentResolver resolver = getLocalContext().getContentResolver();
Uri uri = resolver.insert(getQueryUri("string"), values);
Assert.assertEquals(getQueryUri("string"), uri);
SharedPreferences prefs = getSharedPreferences();
Assert.assertEquals("foobar", prefs.getString("string", null));
}
@Test
public void testInsertPrefFailKeyInUriAndValuesMismatch() {
ContentValues values = new ContentValues();
values.put(RemoteContract.COLUMN_KEY, "string");
values.put(RemoteContract.COLUMN_TYPE, RemoteContract.TYPE_STRING);
values.put(RemoteContract.COLUMN_VALUE, "foobar");
ContentResolver resolver = getLocalContext().getContentResolver();
try {
resolver.insert(getQueryUri("string2"), values);
Assert.fail();
} catch (IllegalArgumentException e) {
// Expected
}
SharedPreferences prefs = getSharedPreferences();
Assert.assertEquals("default", prefs.getString("string", "default"));
}
@Test
public void testInsertMultiplePrefs() {
ContentValues[] values = new ContentValues[2];
values[0] = new ContentValues();
values[0].put(RemoteContract.COLUMN_KEY, "string");
values[0].put(RemoteContract.COLUMN_TYPE, RemoteContract.TYPE_STRING);
values[0].put(RemoteContract.COLUMN_VALUE, "foobar");
values[1] = new ContentValues();
values[1].put(RemoteContract.COLUMN_KEY, "int");
values[1].put(RemoteContract.COLUMN_TYPE, RemoteContract.TYPE_INT);
values[1].put(RemoteContract.COLUMN_VALUE, 1337);
ContentResolver resolver = getLocalContext().getContentResolver();
int ret = resolver.bulkInsert(getQueryUri(null), values);
Assert.assertEquals(2, ret);
SharedPreferences prefs = getSharedPreferences();
Assert.assertEquals("foobar", prefs.getString("string", null));
Assert.assertEquals(1337, prefs.getInt("int", 0));
}
@Test
public void testInsertFailPermissionCheck() {
ContentValues[] values = new ContentValues[2];
values[0] = new ContentValues();
values[0].put(RemoteContract.COLUMN_KEY, "string");
values[0].put(RemoteContract.COLUMN_TYPE, RemoteContract.TYPE_STRING);
values[0].put(RemoteContract.COLUMN_VALUE, "foobar");
values[1] = new ContentValues();
values[1].put(RemoteContract.COLUMN_KEY, TestConstants.UNWRITABLE_PREF_KEY);
values[1].put(RemoteContract.COLUMN_TYPE, RemoteContract.TYPE_INT);
values[1].put(RemoteContract.COLUMN_VALUE, 1337);
ContentResolver resolver = getLocalContext().getContentResolver();
try {
resolver.bulkInsert(getQueryUri(null), values);
Assert.fail();
} catch (SecurityException e) {
// Expected
}
SharedPreferences prefs = getSharedPreferences();
Assert.assertEquals("default", prefs.getString("string", "default"));
Assert.assertEquals(0, prefs.getInt(TestConstants.UNWRITABLE_PREF_KEY, 0));
}
@Test
public void testInsertMultipleFailUriContainingKey() {
ContentValues[] values = new ContentValues[1];
values[0] = new ContentValues();
values[0].put(RemoteContract.COLUMN_KEY, "string");
values[0].put(RemoteContract.COLUMN_TYPE, RemoteContract.TYPE_STRING);
values[0].put(RemoteContract.COLUMN_VALUE, "foobar");
ContentResolver resolver = getLocalContext().getContentResolver();
try {
resolver.bulkInsert(getQueryUri("key"), values);
Assert.fail();
} catch (IllegalArgumentException e) {
// Expected
}
SharedPreferences prefs = getSharedPreferences();
Assert.assertEquals("default", prefs.getString("string", "default"));
}
@Test
public void testDeletePref() {
SharedPreferences prefs = getSharedPreferences();
prefs
.edit()
.putString("string", "nyaa")
.apply();
ContentResolver resolver = getLocalContext().getContentResolver();
resolver.delete(getQueryUri("string"), null, null);
Assert.assertEquals("default", prefs.getString("string", "default"));
}
@Test
public void testDeleteUnwritablePref() {
SharedPreferences prefs = getSharedPreferences();
prefs
.edit()
.putString(TestConstants.UNWRITABLE_PREF_KEY, "nyaa")
.apply();
ContentResolver resolver = getLocalContext().getContentResolver();
try {
resolver.delete(getQueryUri(TestConstants.UNWRITABLE_PREF_KEY), null, null);
Assert.fail();
} catch (SecurityException e) {
// Expected
}
Assert.assertEquals("nyaa", prefs.getString(TestConstants.UNWRITABLE_PREF_KEY, "default"));
}
@Test
public void testReadBoolean() {
getSharedPreferences()
.edit()
.putBoolean("true", true)
.putBoolean("false", false)
.apply();
ContentResolver resolver = getLocalContext().getContentResolver();
Cursor q = resolver.query(getQueryUri(null), null, null, null, null);
Assert.assertEquals(2, q.getCount());
int key = q.getColumnIndex(RemoteContract.COLUMN_KEY);
int type = q.getColumnIndex(RemoteContract.COLUMN_TYPE);
int value = q.getColumnIndex(RemoteContract.COLUMN_VALUE);
while (q.moveToNext()) {
if (q.getString(key).equals("true")) {
Assert.assertEquals(RemoteContract.TYPE_BOOLEAN, q.getInt(type));
Assert.assertEquals(1, q.getInt(value));
} else if (q.getString(key).equals("false")) {
Assert.assertEquals(RemoteContract.TYPE_BOOLEAN, q.getInt(type));
Assert.assertEquals(0, q.getInt(value));
} else {
Assert.fail();
}
}
}
@Test
public void testReadStringSet() {
HashSet<String> set = new HashSet<>();
set.add("foo");
set.add("bar;");
set.add("baz");
set.add("");
getSharedPreferences()
.edit()
.putStringSet("pref", set)
.apply();
ContentResolver resolver = getLocalContext().getContentResolver();
Cursor q = resolver.query(getQueryUri("pref"), null, null, null, null);
Assert.assertEquals(1, q.getCount());
int key = q.getColumnIndex(RemoteContract.COLUMN_KEY);
int type = q.getColumnIndex(RemoteContract.COLUMN_TYPE);
int value = q.getColumnIndex(RemoteContract.COLUMN_VALUE);
while (q.moveToNext()) {
if (q.getString(key).equals("pref")) {
Assert.assertEquals(RemoteContract.TYPE_STRING_SET, q.getInt(type));
String serialized = q.getString(value);
Assert.assertEquals(set, RemoteUtils.deserializeStringSet(serialized));
} else {
Assert.fail();
}
}
}
@Test
public void testInsertStringSet() {
HashSet<String> set = new HashSet<>();
set.add("foo");
set.add("bar;");
set.add("baz");
set.add("");
ContentValues values = new ContentValues();
values.put(RemoteContract.COLUMN_KEY, "pref");
values.put(RemoteContract.COLUMN_TYPE, RemoteContract.TYPE_STRING_SET);
values.put(RemoteContract.COLUMN_VALUE, RemoteUtils.serializeStringSet(set));
ContentResolver resolver = getLocalContext().getContentResolver();
Uri uri = resolver.insert(getQueryUri(null), values);
Assert.assertEquals(getQueryUri("pref"), uri);
Assert.assertEquals(set, getSharedPreferences().getStringSet("pref", null));
}
}
================================================
FILE: testapp/src/androidTest/java/com/crossbowffs/remotepreferences/RemotePreferencesTest.java
================================================
package com.crossbowffs.remotepreferences;
import android.content.Context;
import android.content.SharedPreferences;
import android.os.Build;
import android.os.Handler;
import android.os.HandlerThread;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.filters.SdkSuppress;
import androidx.test.platform.app.InstrumentationRegistry;
import com.crossbowffs.remotepreferences.testapp.TestConstants;
import com.crossbowffs.remotepreferences.testapp.TestPreferenceListener;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import java.util.HashSet;
import java.util.Map;
@RunWith(AndroidJUnit4.class)
public class RemotePreferencesTest {
private Context getLocalContext() {
return InstrumentationRegistry.getInstrumentation().getContext();
}
private Context getRemoteContext() {
return InstrumentationRegistry.getInstrumentation().getTargetContext();
}
private SharedPreferences getSharedPreferences() {
Context context = getRemoteContext();
return context.getSharedPreferences(TestConstants.PREF_FILE, Context.MODE_PRIVATE);
}
private RemotePreferences getRemotePreferences(boolean strictMode) {
// This is not a typo! We are using the LOCAL context to initialize a REMOTE prefs
// instance. This is the whole point of RemotePreferences!
Context context = getLocalContext();
return new RemotePreferences(context, TestConstants.AUTHORITY, TestConstants.PREF_FILE, strictMode);
}
private RemotePreferences getDisabledRemotePreferences(boolean strictMode) {
Context context = getLocalContext();
return new RemotePreferences(context, TestConstants.AUTHORITY_DISABLED, TestConstants.PREF_FILE, strictMode);
}
private RemotePreferences getRemotePreferencesWithHandler(Handler handler, boolean strictMode) {
Context context = getLocalContext();
return new RemotePreferences(context, handler, TestConstants.AUTHORITY, TestConstants.PREF_FILE, strictMode);
}
@Before
public void resetPreferences() {
getSharedPreferences().edit().clear().commit();
}
@Test
public void testBasicRead() {
getSharedPreferences()
.edit()
.putString("string", "foobar")
.putInt("int", 0xeceb3026)
.putFloat("float", 3.14f)
.putBoolean("bool", true)
.apply();
RemotePreferences remotePrefs = getRemotePreferences(true);
Assert.assertEquals("foobar", remotePrefs.getString("string", null));
Assert.assertEquals(0xeceb3026, remotePrefs.getInt("int", 0));
Assert.assertEquals(3.14f, remotePrefs.getFloat("float", 0f), 0.0);
Assert.assertEquals(true, remotePrefs.getBoolean("bool", false));
}
@Test
public void testBasicWrite() {
getRemotePreferences(true)
.edit()
.putString("string", "foobar")
.putInt("int", 0xeceb3026)
.putFloat("float", 3.14f)
.putBoolean("bool", true)
.apply();
SharedPreferences sharedPrefs = getSharedPreferences();
Assert.assertEquals("foobar", sharedPrefs.getString("string", null));
Assert.assertEquals(0xeceb3026, sharedPrefs.getInt("int", 0));
Assert.assertEquals(3.14f, sharedPrefs.getFloat("float", 0f), 0.0);
Assert.assertEquals(true, sharedPrefs.getBoolean("bool", false));
}
@Test
public void testRemove() {
getSharedPreferences()
.edit()
.putString("string", "foobar")
.putInt("int", 0xeceb3026)
.apply();
RemotePreferences remotePrefs = getRemotePreferences(true);
remotePrefs.edit().remove("string").apply();
Assert.assertEquals("default", remotePrefs.getString("string", "default"));
Assert.assertEquals(0xeceb3026, remotePrefs.getInt("int", 0));
}
@Test
public void testClear() {
SharedPreferences sharedPrefs = getSharedPreferences();
getSharedPreferences()
.edit()
.putString("string", "foobar")
.putInt("int", 0xeceb3026)
.apply();
RemotePreferences remotePrefs = getRemotePreferences(true);
remotePrefs.edit().clear().apply();
Assert.assertEquals(0, sharedPrefs.getAll().size());
Assert.assertEquals("default", remotePrefs.getString("string", "default"));
Assert.assertEquals(0, remotePrefs.getInt("int", 0));
}
@Test
public void testGetAll() {
getSharedPreferences()
.edit()
.putString("string", "foobar")
.putInt("int", 0xeceb3026)
.putFloat("float", 3.14f)
.putBoolean("bool", true)
.apply();
RemotePreferences remotePrefs = getRemotePreferences(true);
Map<String, ?> prefs = remotePrefs.getAll();
Assert.assertEquals("foobar", prefs.get("string"));
Assert.assertEquals(0xeceb3026, prefs.get("int"));
Assert.assertEquals(3.14f, prefs.get("float"));
Assert.assertEquals(true, prefs.get("bool"));
}
@Test
public void testContains() {
getSharedPreferences()
.edit()
.putString("string", "foobar")
.putInt("int", 0xeceb3026)
.putFloat("float", 3.14f)
.putBoolean("bool", true)
.apply();
RemotePreferences remotePrefs = getRemotePreferences(true);
Assert.assertTrue(remotePrefs.contains("string"));
Assert.assertTrue(remotePrefs.contains("int"));
Assert.assertFalse(remotePrefs.contains("nonexistent"));
}
@Test
public void testReadNonexistentPref() {
RemotePreferences remotePrefs = getRemotePreferences(true);
Assert.assertEquals("default", remotePrefs.getString("nonexistent_string", "default"));
Assert.assertEquals(1337, remotePrefs.getInt("nonexistent_int", 1337));
}
@Test
public void testStringSetRead() {
HashSet<String> set = new HashSet<>();
set.add("Chocola");
set.add("Vanilla");
set.add("Coconut");
set.add("Azuki");
set.add("Maple");
set.add("Cinnamon");
getSharedPreferences()
.edit()
.putStringSet("pref", set)
.apply();
RemotePreferences remotePrefs = getRemotePreferences(true);
Assert.assertEquals(set, remotePrefs.getStringSet("pref", null));
}
@Test
public void testStringSetWrite() {
HashSet<String> set = new HashSet<>();
set.add("Chocola");
set.add("Vanilla");
set.add("Coconut");
set.add("Azuki");
set.add("Maple");
set.add("Cinnamon");
getRemotePreferences(true)
.edit()
.putStringSet("pref", set)
.apply();
SharedPreferences sharedPrefs = getSharedPreferences();
Assert.assertEquals(set, sharedPrefs.getStringSet("pref", null));
}
@Test
public void testEmptyStringSetRead() {
HashSet<String> set = new HashSet<>();
getSharedPreferences()
.edit()
.putStringSet("pref", set)
.apply();
RemotePreferences remotePrefs = getRemotePreferences(true);
Assert.assertEquals(set, remotePrefs.getStringSet("pref", null));
}
@Test
public void testEmptyStringSetWrite() {
HashSet<String> set = new HashSet<>();
getRemotePreferences(true)
.edit()
.putStringSet("pref", set)
.apply();
SharedPreferences sharedPrefs = getSharedPreferences();
Assert.assertEquals(set, sharedPrefs.getStringSet("pref", null));
}
@Test
public void testSetContainingEmptyStringRead() {
HashSet<String> set = new HashSet<>();
set.add("");
getSharedPreferences()
.edit()
.putStringSet("pref", set)
.apply();
RemotePreferences remotePrefs = getRemotePreferences(true);
Assert.assertEquals(set, remotePrefs.getStringSet("pref", null));
}
@Test
public void testSetContainingEmptyStringWrite() {
HashSet<String> set = new HashSet<>();
set.add("");
getRemotePreferences(true)
.edit()
.putStringSet("pref", set)
.apply();
SharedPreferences sharedPrefs = getSharedPreferences();
Assert.assertEquals(set, sharedPrefs.getStringSet("pref", null));
}
@Test
public void testReadStringAsStringSetFail() {
getSharedPreferences()
.edit()
.putString("pref", "foo;bar;")
.apply();
RemotePreferences remotePrefs = getRemotePreferences(true);
try {
remotePrefs.getStringSet("pref", null);
Assert.fail();
} catch (ClassCastException e) {
// Expected
}
}
@Test
public void testReadStringSetAsStringFail() {
HashSet<String> set = new HashSet<>();
set.add("foo");
set.add("bar");
getSharedPreferences()
.edit()
.putStringSet("pref", set)
.apply();
RemotePreferences remotePrefs = getRemotePreferences(true);
try {
remotePrefs.getString("pref", null);
Assert.fail();
} catch (ClassCastException e) {
// Expected
}
}
@Test
public void testReadBooleanAsIntFail() {
getSharedPreferences()
.edit()
.putBoolean("pref", true)
.apply();
RemotePreferences remotePrefs = getRemotePreferences(true);
try {
remotePrefs.getInt("pref", 0);
Assert.fail();
} catch (ClassCastException e) {
// Expected
}
}
@Test
public void testReadIntAsBooleanFail() {
getSharedPreferences()
.edit()
.putInt("pref", 42)
.apply();
RemotePreferences remotePrefs = getRemotePreferences(true);
try {
remotePrefs.getBoolean("pref", false);
Assert.fail();
} catch (ClassCastException e) {
// Expected
}
}
@Test
public void testInvalidAuthorityStrictMode() {
Context context = getLocalContext();
RemotePreferences remotePrefs = new RemotePreferences(context, "foo", "bar", true);
try {
remotePrefs.getString("pref", null);
Assert.fail();
} catch (RemotePreferenceAccessException e) {
// Expected
}
}
@Test
public void testInvalidAuthorityNonStrictMode() {
Context context = getLocalContext();
RemotePreferences remotePrefs = new RemotePreferences(context, "foo", "bar", false);
Assert.assertEquals("default", remotePrefs.getString("pref", "default"));
}
@Test
public void testDisabledProviderStrictMode() {
RemotePreferences remotePrefs = getDisabledRemotePreferences(true);
try {
remotePrefs.getString("pref", null);
Assert.fail();
} catch (RemotePreferenceAccessException e) {
// Expected
}
}
@Test
public void testDisabledProviderNonStrictMode() {
RemotePreferences remotePrefs = getDisabledRemotePreferences(false);
Assert.assertEquals("default", remotePrefs.getString("pref", "default"));
}
@Test
public void testUnreadablePrefStrictMode() {
RemotePreferences remotePrefs = getRemotePreferences(true);
try {
remotePrefs.getString(TestConstants.UNREADABLE_PREF_KEY, null);
Assert.fail();
} catch (RemotePreferenceAccessException e) {
// Expected
}
}
@Test
public void testUnreadablePrefNonStrictMode() {
RemotePreferences remotePrefs = getRemotePreferences(false);
Assert.assertEquals("default", remotePrefs.getString(TestConstants.UNREADABLE_PREF_KEY, "default"));
}
@Test
public void testUnwritablePrefStrictMode() {
RemotePreferences remotePrefs = getRemotePreferences(true);
try {
remotePrefs.edit().putString(TestConstants.UNWRITABLE_PREF_KEY, "foobar").commit();
Assert.fail();
} catch (RemotePreferenceAccessException e) {
// Expected
}
}
@Test
public void testUnwritablePrefNonStrictMode() {
RemotePreferences remotePrefs = getRemotePreferences(false);
Assert.assertFalse(
remotePrefs
.edit()
.putString(TestConstants.UNWRITABLE_PREF_KEY, "foobar")
.commit()
);
}
@Test
public void testRemoveUnwritablePrefStrictMode() {
getSharedPreferences()
.edit()
.putString(TestConstants.UNWRITABLE_PREF_KEY, "foobar")
.apply();
RemotePreferences remotePrefs = getRemotePreferences(true);
try {
remotePrefs.edit().remove(TestConstants.UNWRITABLE_PREF_KEY).commit();
Assert.fail();
} catch (RemotePreferenceAccessException e) {
// Expected
}
Assert.assertEquals("foobar", remotePrefs.getString(TestConstants.UNWRITABLE_PREF_KEY, "default"));
}
@Test
public void testRemoveUnwritablePrefNonStrictMode() {
getSharedPreferences()
.edit()
.putString(TestConstants.UNWRITABLE_PREF_KEY, "foobar")
.apply();
RemotePreferences remotePrefs = getRemotePreferences(false);
Assert.assertFalse(remotePrefs.edit().remove(TestConstants.UNWRITABLE_PREF_KEY).commit());
Assert.assertEquals("foobar", remotePrefs.getString(TestConstants.UNWRITABLE_PREF_KEY, "default"));
}
@Test
public void testPreferenceChangeListener() {
HandlerThread ht = new HandlerThread(getClass().getName());
try {
ht.start();
Handler handler = new Handler(ht.getLooper());
RemotePreferences remotePrefs = getRemotePreferencesWithHandler(handler, true);
TestPreferenceListener listener = new TestPreferenceListener();
try {
remotePrefs.registerOnSharedPreferenceChangeListener(listener);
getSharedPreferences()
.edit()
.putInt("foobar", 1337)
.apply();
Assert.assertTrue(listener.waitForChange(1));
Assert.assertEquals("foobar", listener.getKey());
} finally {
remotePrefs.unregisterOnSharedPreferenceChangeListener(listener);
}
} finally {
ht.quit();
}
}
@Test
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.R)
public void testPreferenceChangeListenerClear() {
HandlerThread ht = new HandlerThread(getClass().getName());
try {
ht.start();
Handler handler = new Handler(ht.getLooper());
RemotePreferences remotePrefs = getRemotePreferencesWithHandler(handler, true);
TestPreferenceListener listener = new TestPreferenceListener();
try {
remotePrefs.registerOnSharedPreferenceChangeListener(listener);
getSharedPreferences()
.edit()
.clear()
.apply();
Assert.assertTrue(listener.waitForChange(1));
Assert.assertNull(listener.getKey());
} finally {
remotePrefs.unregisterOnSharedPreferenceChangeListener(listener);
}
} finally {
ht.quit();
}
}
@Test
public void testUnregisterPreferenceChangeListener() {
HandlerThread ht = new HandlerThread(getClass().getName());
try {
ht.start();
Handler handler = new Handler(ht.getLooper());
RemotePreferences remotePrefs = getRemotePreferencesWithHandler(handler, true);
TestPreferenceListener listener = new TestPreferenceListener();
try {
remotePrefs.registerOnSharedPreferenceChangeListener(listener);
remotePrefs.unregisterOnSharedPreferenceChangeListener(listener);
getSharedPreferences()
.edit()
.putInt("foobar", 1337)
.apply();
Assert.assertFalse(listener.waitForChange(1));
} finally {
remotePrefs.unregisterOnSharedPreferenceChangeListener(listener);
}
} finally {
ht.quit();
}
}
}
================================================
FILE: testapp/src/androidTest/java/com/crossbowffs/remotepreferences/RemoteUtilsTest.java
================================================
package com.crossbowffs.remotepreferences;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.Set;
@RunWith(AndroidJUnit4.class)
public class RemoteUtilsTest {
@Test
public void testSerializeStringSet() {
Set<String> set = new LinkedHashSet<String>();
set.add("foo");
set.add("bar;");
set.add("baz");
set.add("");
String serialized = RemoteUtils.serializeStringSet(set);
Assert.assertEquals("foo;bar\\;;baz;;", serialized);
}
@Test
public void testDeserializeStringSet() {
Set<String> set = new LinkedHashSet<String>();
set.add("foo");
set.add("bar;");
set.add("baz");
set.add("");
String serialized = RemoteUtils.serializeStringSet(set);
Set<String> deserialized = RemoteUtils.deserializeStringSet(serialized);
Assert.assertEquals(set, deserialized);
}
@Test
public void testSerializeEmptyStringSet() {
Assert.assertEquals("", RemoteUtils.serializeStringSet(new HashSet<String>()));
}
@Test
public void testDeserializeEmptyStringSet() {
Assert.assertEquals(new HashSet<String>(), RemoteUtils.deserializeStringSet(""));
}
@Test
public void testDeserializeInvalidStringSet() {
try {
RemoteUtils.deserializeStringSet("foo;bar");
Assert.fail();
} catch (IllegalArgumentException e) {
// Expected
}
}
}
================================================
FILE: testapp/src/main/AndroidManifest.xml
================================================
<manifest
xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<provider
android:authorities="${applicationId}.preferences"
android:name=".TestPreferenceProvider"
android:exported="true"/>
<provider
android:enabled="false"
android:authorities="${applicationId}.preferences.disabled"
android:name=".TestPreferenceProviderDisabled"
android:exported="true"/>
</application>
</manifest>
================================================
FILE: testapp/src/main/java/com/crossbowffs/remotepreferences/testapp/TestConstants.java
================================================
package com.crossbowffs.remotepreferences.testapp;
public final class TestConstants {
private TestConstants() {}
public static final String AUTHORITY = BuildConfig.APPLICATION_ID + ".preferences";
public static final String AUTHORITY_DISABLED = BuildConfig.APPLICATION_ID + ".preferences.disabled";
public static final String PREF_FILE = "main_prefs";
public static final String UNREADABLE_PREF_KEY = "cannot_read_me";
public static final String UNWRITABLE_PREF_KEY = "cannot_write_me";
}
================================================
FILE: testapp/src/main/java/com/crossbowffs/remotepreferences/testapp/TestPreferenceListener.java
================================================
package com.crossbowffs.remotepreferences.testapp;
import android.content.SharedPreferences;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
public class TestPreferenceListener implements SharedPreferences.OnSharedPreferenceChangeListener {
private boolean mIsCalled;
private String mKey;
private final CountDownLatch mLatch;
public TestPreferenceListener() {
mIsCalled = false;
mKey = null;
mLatch = new CountDownLatch(1);
}
public boolean isCalled() {
return mIsCalled;
}
public String getKey() {
if (!mIsCalled) {
throw new IllegalStateException("Listener was not called");
}
return mKey;
}
public boolean waitForChange(long seconds) {
try {
return mLatch.await(seconds, TimeUnit.SECONDS);
} catch (InterruptedException e) {
throw new IllegalStateException("Listener wait was interrupted");
}
}
@Override
public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) {
mIsCalled = true;
mKey = key;
mLatch.countDown();
}
}
================================================
FILE: testapp/src/main/java/com/crossbowffs/remotepreferences/testapp/TestPreferenceProvider.java
================================================
package com.crossbowffs.remotepreferences.testapp;
import com.crossbowffs.remotepreferences.RemotePreferenceProvider;
public class TestPreferenceProvider extends RemotePreferenceProvider {
public TestPreferenceProvider() {
super(TestConstants.AUTHORITY, new String[] {TestConstants.PREF_FILE});
}
@Override
protected boolean checkAccess(String prefName, String prefKey, boolean write) {
if (prefKey.equals(TestConstants.UNREADABLE_PREF_KEY) && !write) return false;
if (prefKey.equals(TestConstants.UNWRITABLE_PREF_KEY) && write) return false;
return true;
}
}
================================================
FILE: testapp/src/main/java/com/crossbowffs/remotepreferences/testapp/TestPreferenceProviderDisabled.java
================================================
package com.crossbowffs.remotepreferences.testapp;
import com.crossbowffs.remotepreferences.RemotePreferenceProvider;
public class TestPreferenceProviderDisabled extends RemotePreferenceProvider {
public TestPreferenceProviderDisabled() {
super(TestConstants.AUTHORITY_DISABLED, new String[] {TestConstants.PREF_FILE});
}
}
gitextract_286r2t3v/
├── .gitignore
├── LICENSE.txt
├── README.md
├── build.gradle.kts
├── gradle/
│ └── wrapper/
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradle.properties
├── gradlew
├── gradlew.bat
├── library/
│ ├── build.gradle.kts
│ └── src/
│ └── main/
│ ├── AndroidManifest.xml
│ └── java/
│ └── com/
│ └── crossbowffs/
│ └── remotepreferences/
│ ├── RemoteContract.java
│ ├── RemotePreferenceAccessException.java
│ ├── RemotePreferenceFile.java
│ ├── RemotePreferencePath.java
│ ├── RemotePreferenceProvider.java
│ ├── RemotePreferenceUriParser.java
│ ├── RemotePreferences.java
│ └── RemoteUtils.java
├── settings.gradle.kts
└── testapp/
├── build.gradle.kts
└── src/
├── androidTest/
│ └── java/
│ └── com/
│ └── crossbowffs/
│ └── remotepreferences/
│ ├── RemotePreferenceProviderTest.java
│ ├── RemotePreferencesTest.java
│ └── RemoteUtilsTest.java
└── main/
├── AndroidManifest.xml
└── java/
└── com/
└── crossbowffs/
└── remotepreferences/
└── testapp/
├── TestConstants.java
├── TestPreferenceListener.java
├── TestPreferenceProvider.java
└── TestPreferenceProviderDisabled.java
SYMBOL INDEX (175 symbols across 15 files)
FILE: library/src/main/java/com/crossbowffs/remotepreferences/RemoteContract.java
class RemoteContract (line 6) | final class RemoteContract {
method RemoteContract (line 24) | private RemoteContract() {}
FILE: library/src/main/java/com/crossbowffs/remotepreferences/RemotePreferenceAccessException.java
class RemotePreferenceAccessException (line 13) | public class RemotePreferenceAccessException extends RuntimeException {
method RemotePreferenceAccessException (line 14) | public RemotePreferenceAccessException() {
method RemotePreferenceAccessException (line 18) | public RemotePreferenceAccessException(String detailMessage) {
method RemotePreferenceAccessException (line 22) | public RemotePreferenceAccessException(String detailMessage, Throwable...
method RemotePreferenceAccessException (line 26) | public RemotePreferenceAccessException(Throwable throwable) {
FILE: library/src/main/java/com/crossbowffs/remotepreferences/RemotePreferenceFile.java
class RemotePreferenceFile (line 7) | public class RemotePreferenceFile {
method RemotePreferenceFile (line 20) | public RemotePreferenceFile(String fileName, boolean isDeviceProtected) {
method RemotePreferenceFile (line 31) | public RemotePreferenceFile(String fileName) {
method getFileName (line 40) | public String getFileName() {
method isDeviceProtected (line 50) | public boolean isDeviceProtected() {
method fromFileNames (line 61) | public static RemotePreferenceFile[] fromFileNames(String[] prefFileNa...
FILE: library/src/main/java/com/crossbowffs/remotepreferences/RemotePreferencePath.java
class RemotePreferencePath (line 9) | class RemotePreferencePath {
method RemotePreferencePath (line 13) | public RemotePreferencePath(String prefFileName, String prefKey) {
method withKey (line 18) | public RemotePreferencePath withKey(String prefKey) {
method toString (line 25) | @Override
FILE: library/src/main/java/com/crossbowffs/remotepreferences/RemotePreferenceProvider.java
class RemotePreferenceProvider (line 71) | public abstract class RemotePreferenceProvider extends ContentProvider i...
method RemotePreferenceProvider (line 90) | public RemotePreferenceProvider(String authority, String[] prefFileNam...
method RemotePreferenceProvider (line 104) | public RemotePreferenceProvider(String authority, RemotePreferenceFile...
method checkAccess (line 127) | protected boolean checkAccess(String prefFileName, String prefKey, boo...
method onCreate (line 136) | @Override
method getSharedPreferences (line 161) | protected SharedPreferences getSharedPreferences(Context context, Stri...
method query (line 180) | @Override
method getType (line 215) | @Override
method insert (line 229) | @Override
method bulkInsert (line 259) | @Override
method delete (line 294) | @Override
method update (line 329) | @Override
method onSharedPreferenceChanged (line 348) | @Override
method putPreference (line 370) | private void putPreference(SharedPreferences.Editor editor, String pre...
method buildRow (line 434) | private Object[] buildRow(String[] projection, String key, Object valu...
method isSingleKey (line 458) | private static boolean isSingleKey(String prefKey) {
method getKeyFromValues (line 469) | private static String getKeyFromValues(ContentValues values) {
method getKeyFromUriOrValues (line 493) | private static String getKeyFromUriOrValues(RemotePreferencePath prefP...
method checkAccessOrThrow (line 519) | private void checkAccessOrThrow(RemotePreferencePath prefPath, boolean...
method getSharedPreferencesByName (line 541) | private SharedPreferences getSharedPreferencesByName(String prefFileNa...
method getSharedPreferencesFileName (line 557) | private String getSharedPreferencesFileName(SharedPreferences prefs) {
method getSharedPreferencesFile (line 574) | private RemotePreferenceFile getSharedPreferencesFile(SharedPreference...
method getSharedPreferencesOrThrow (line 593) | private SharedPreferences getSharedPreferencesOrThrow(RemotePreference...
method getPreferenceUri (line 606) | private Uri getPreferenceUri(String prefFileName, String prefKey) {
FILE: library/src/main/java/com/crossbowffs/remotepreferences/RemotePreferenceUriParser.java
class RemotePreferenceUriParser (line 11) | class RemotePreferenceUriParser {
method RemotePreferenceUriParser (line 17) | public RemotePreferenceUriParser(String authority) {
method parse (line 30) | public RemotePreferencePath parse(Uri uri) {
FILE: library/src/main/java/com/crossbowffs/remotepreferences/RemotePreferences.java
class RemotePreferences (line 33) | public class RemotePreferences implements SharedPreferences {
method RemotePreferences (line 49) | public RemotePreferences(Context context, String authority, String pre...
method RemotePreferences (line 65) | public RemotePreferences(Context context, String authority, String pre...
method RemotePreferences (line 82) | RemotePreferences(Context context, Handler handler, String authority, ...
method getAll (line 95) | @Override
method getString (line 100) | @Override
method getStringSet (line 105) | @Override
method getInt (line 114) | @Override
method getLong (line 119) | @Override
method getFloat (line 124) | @Override
method getBoolean (line 129) | @Override
method contains (line 134) | @Override
method edit (line 139) | @Override
method registerOnSharedPreferenceChangeListener (line 144) | @Override
method unregisterOnSharedPreferenceChangeListener (line 153) | @Override
method checkNotNull (line 168) | private static void checkNotNull(String name, Object object) {
method checkKeyNotEmpty (line 179) | private static void checkKeyNotEmpty(String key) {
method wrapException (line 191) | private void wrapException(Exception e) {
method query (line 206) | private Cursor query(Uri uri, String[] columns) {
method bulkInsert (line 228) | private boolean bulkInsert(Uri uri, ContentValues[] values) {
method querySingle (line 253) | private Object querySingle(String key, Object defValue, int expectedTy...
method queryAll (line 287) | private Map<String, Object> queryAll() {
method containsKey (line 320) | private boolean containsKey(String key) {
method getValue (line 348) | private Object getValue(Cursor cursor, int typeCol, int valueCol) {
class RemotePreferencesEditor (line 372) | private class RemotePreferencesEditor implements Editor {
method createContentValues (line 384) | private ContentValues createContentValues(String key, int type) {
method createAddOp (line 400) | private ContentValues createAddOp(String key, int type) {
method createRemoveOp (line 415) | private ContentValues createRemoveOp(String key) {
method putString (line 425) | @Override
method putStringSet (line 431) | @Override
method putInt (line 442) | @Override
method putLong (line 448) | @Override
method putFloat (line 454) | @Override
method putBoolean (line 460) | @Override
method remove (line 466) | @Override
method clear (line 473) | @Override
method commit (line 479) | @Override
method apply (line 486) | @Override
class PreferenceContentObserver (line 497) | private class PreferenceContentObserver extends ContentObserver {
method PreferenceContentObserver (line 500) | private PreferenceContentObserver(OnSharedPreferenceChangeListener l...
method deliverSelfNotifications (line 505) | @Override
method onChange (line 510) | @Override
FILE: library/src/main/java/com/crossbowffs/remotepreferences/RemoteUtils.java
class RemoteUtils (line 10) | final class RemoteUtils {
method RemoteUtils (line 11) | private RemoteUtils() {}
method castStringSet (line 21) | @SuppressWarnings("unchecked")
method getPreferenceType (line 33) | public static int getPreferenceType(Object value) {
method serializeOutput (line 52) | public static Object serializeOutput(Object value) {
method deserializeInput (line 72) | public static Object deserializeInput(Object value, int expectedType) {
method serializeBoolean (line 108) | private static Integer serializeBoolean(Boolean value) {
method deserializeBoolean (line 123) | private static Boolean deserializeBoolean(Object value) {
method serializeStringSet (line 140) | public static String serializeStringSet(Set<String> stringSet) {
method deserializeStringSet (line 159) | public static Set<String> deserializeStringSet(String serializedString) {
FILE: testapp/src/androidTest/java/com/crossbowffs/remotepreferences/RemotePreferenceProviderTest.java
class RemotePreferenceProviderTest (line 22) | @RunWith(AndroidJUnit4.class)
method getLocalContext (line 24) | private Context getLocalContext() {
method getRemoteContext (line 28) | private Context getRemoteContext() {
method getSharedPreferences (line 32) | private SharedPreferences getSharedPreferences() {
method getQueryUri (line 37) | private Uri getQueryUri(String key) {
method resetPreferences (line 45) | @Before
method testQueryAllPrefs (line 50) | @Test
method testQuerySinglePref (line 79) | @Test
method testQueryFailPermissionCheck (line 101) | @Test
method testInsertPref (line 116) | @Test
method testInsertOverridePref (line 131) | @Test
method testInsertPrefKeyInUri (line 153) | @Test
method testInsertPrefKeyInUriAndValues (line 167) | @Test
method testInsertPrefFailKeyInUriAndValuesMismatch (line 182) | @Test
method testInsertMultiplePrefs (line 201) | @Test
method testInsertFailPermissionCheck (line 223) | @Test
method testInsertMultipleFailUriContainingKey (line 249) | @Test
method testDeletePref (line 269) | @Test
method testDeleteUnwritablePref (line 283) | @Test
method testReadBoolean (line 302) | @Test
method testReadStringSet (line 331) | @Test
method testInsertStringSet (line 363) | @Test
FILE: testapp/src/androidTest/java/com/crossbowffs/remotepreferences/RemotePreferencesTest.java
class RemotePreferencesTest (line 24) | @RunWith(AndroidJUnit4.class)
method getLocalContext (line 26) | private Context getLocalContext() {
method getRemoteContext (line 30) | private Context getRemoteContext() {
method getSharedPreferences (line 34) | private SharedPreferences getSharedPreferences() {
method getRemotePreferences (line 39) | private RemotePreferences getRemotePreferences(boolean strictMode) {
method getDisabledRemotePreferences (line 46) | private RemotePreferences getDisabledRemotePreferences(boolean strictM...
method getRemotePreferencesWithHandler (line 51) | private RemotePreferences getRemotePreferencesWithHandler(Handler hand...
method resetPreferences (line 56) | @Before
method testBasicRead (line 61) | @Test
method testBasicWrite (line 78) | @Test
method testRemove (line 95) | @Test
method testClear (line 110) | @Test
method testGetAll (line 127) | @Test
method testContains (line 145) | @Test
method testReadNonexistentPref (line 161) | @Test
method testStringSetRead (line 168) | @Test
method testStringSetWrite (line 187) | @Test
method testEmptyStringSetRead (line 206) | @Test
method testEmptyStringSetWrite (line 219) | @Test
method testSetContainingEmptyStringRead (line 232) | @Test
method testSetContainingEmptyStringWrite (line 246) | @Test
method testReadStringAsStringSetFail (line 260) | @Test
method testReadStringSetAsStringFail (line 276) | @Test
method testReadBooleanAsIntFail (line 296) | @Test
method testReadIntAsBooleanFail (line 312) | @Test
method testInvalidAuthorityStrictMode (line 328) | @Test
method testInvalidAuthorityNonStrictMode (line 340) | @Test
method testDisabledProviderStrictMode (line 347) | @Test
method testDisabledProviderNonStrictMode (line 358) | @Test
method testUnreadablePrefStrictMode (line 364) | @Test
method testUnreadablePrefNonStrictMode (line 375) | @Test
method testUnwritablePrefStrictMode (line 381) | @Test
method testUnwritablePrefNonStrictMode (line 392) | @Test
method testRemoveUnwritablePrefStrictMode (line 403) | @Test
method testRemoveUnwritablePrefNonStrictMode (line 421) | @Test
method testPreferenceChangeListener (line 434) | @Test
method testPreferenceChangeListenerClear (line 462) | @Test
method testUnregisterPreferenceChangeListener (line 491) | @Test
FILE: testapp/src/androidTest/java/com/crossbowffs/remotepreferences/RemoteUtilsTest.java
class RemoteUtilsTest (line 13) | @RunWith(AndroidJUnit4.class)
method testSerializeStringSet (line 15) | @Test
method testDeserializeStringSet (line 27) | @Test
method testSerializeEmptyStringSet (line 40) | @Test
method testDeserializeEmptyStringSet (line 45) | @Test
method testDeserializeInvalidStringSet (line 50) | @Test
FILE: testapp/src/main/java/com/crossbowffs/remotepreferences/testapp/TestConstants.java
class TestConstants (line 3) | public final class TestConstants {
method TestConstants (line 4) | private TestConstants() {}
FILE: testapp/src/main/java/com/crossbowffs/remotepreferences/testapp/TestPreferenceListener.java
class TestPreferenceListener (line 8) | public class TestPreferenceListener implements SharedPreferences.OnShare...
method TestPreferenceListener (line 13) | public TestPreferenceListener() {
method isCalled (line 19) | public boolean isCalled() {
method getKey (line 23) | public String getKey() {
method waitForChange (line 30) | public boolean waitForChange(long seconds) {
method onSharedPreferenceChanged (line 38) | @Override
FILE: testapp/src/main/java/com/crossbowffs/remotepreferences/testapp/TestPreferenceProvider.java
class TestPreferenceProvider (line 5) | public class TestPreferenceProvider extends RemotePreferenceProvider {
method TestPreferenceProvider (line 6) | public TestPreferenceProvider() {
method checkAccess (line 10) | @Override
FILE: testapp/src/main/java/com/crossbowffs/remotepreferences/testapp/TestPreferenceProviderDisabled.java
class TestPreferenceProviderDisabled (line 5) | public class TestPreferenceProviderDisabled extends RemotePreferenceProv...
method TestPreferenceProviderDisabled (line 6) | public TestPreferenceProviderDisabled() {
Condensed preview — 29 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (125K chars).
[
{
"path": ".gitignore",
"chars": 431,
"preview": "# Gradle files\n.gradle/\nbuild/\n\n# Local configuration file (sdk path, etc)\nlocal.properties\n\n# Log/OS Files\n*.log\n\n# And"
},
{
"path": "LICENSE.txt",
"chars": 1092,
"preview": "The MIT License (MIT)\n\nCopyright (c) 2016 Andrew Sun (@crossbowffs)\n\nPermission is hereby granted, free of charge, to an"
},
{
"path": "README.md",
"chars": 8070,
"preview": "# RemotePreferences\n\nA drop-in solution for inter-app access to `SharedPreferences`.\n\n\n## Installation\n\n1\\. Add the depe"
},
{
"path": "build.gradle.kts",
"chars": 247,
"preview": "buildscript {\n repositories {\n google()\n mavenCentral()\n }\n\n dependencies {\n classpath(\"co"
},
{
"path": "gradle/wrapper/gradle-wrapper.properties",
"chars": 221,
"preview": "distributionBase=GRADLE_USER_HOME\ndistributionPath=wrapper/dists\ndistributionUrl=https\\://services.gradle.org/distributi"
},
{
"path": "gradle.properties",
"chars": 73,
"preview": "android.useAndroidX=true\nandroid.defaults.buildfeatures.buildconfig=true\n"
},
{
"path": "gradlew",
"chars": 8472,
"preview": "#!/bin/sh\n\n#\n# Copyright © 2015-2021 the original authors.\n#\n# Licensed under the Apache License, Version 2.0 (the \"Lice"
},
{
"path": "gradlew.bat",
"chars": 2776,
"preview": "@rem\n@rem Copyright 2015 the original author or authors.\n@rem\n@rem Licensed under the Apache License, Version 2.0 (the \""
},
{
"path": "library/build.gradle.kts",
"chars": 2147,
"preview": "plugins {\n id(\"com.android.library\")\n id(\"maven-publish\")\n id(\"signing\")\n}\n\nandroid {\n namespace = \"com.cros"
},
{
"path": "library/src/main/AndroidManifest.xml",
"chars": 52,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<manifest />\n"
},
{
"path": "library/src/main/java/com/crossbowffs/remotepreferences/RemoteContract.java",
"chars": 830,
"preview": "package com.crossbowffs.remotepreferences;\n\n/**\n * Constants used for communicating with the preference provider.\n */\n/*"
},
{
"path": "library/src/main/java/com/crossbowffs/remotepreferences/RemotePreferenceAccessException.java",
"chars": 1000,
"preview": "package com.crossbowffs.remotepreferences;\n\n/**\n * Thrown if the preference provider could not be accessed.\n * This is c"
},
{
"path": "library/src/main/java/com/crossbowffs/remotepreferences/RemotePreferenceFile.java",
"chars": 2359,
"preview": "package com.crossbowffs.remotepreferences;\n\n/**\n * Represents a single preference file and the information needed to\n * "
},
{
"path": "library/src/main/java/com/crossbowffs/remotepreferences/RemotePreferencePath.java",
"chars": 1027,
"preview": "package com.crossbowffs.remotepreferences;\n\n/**\n * A path consists of a preference file name and optionally a key within"
},
{
"path": "library/src/main/java/com/crossbowffs/remotepreferences/RemotePreferenceProvider.java",
"chars": 25132,
"preview": "package com.crossbowffs.remotepreferences;\n\nimport android.content.ContentProvider;\nimport android.content.ContentResolv"
},
{
"path": "library/src/main/java/com/crossbowffs/remotepreferences/RemotePreferenceUriParser.java",
"chars": 1902,
"preview": "package com.crossbowffs.remotepreferences;\n\nimport android.content.UriMatcher;\nimport android.net.Uri;\n\nimport java.util"
},
{
"path": "library/src/main/java/com/crossbowffs/remotepreferences/RemotePreferences.java",
"chars": 19706,
"preview": "package com.crossbowffs.remotepreferences;\n\nimport android.annotation.TargetApi;\nimport android.content.ContentValues;\ni"
},
{
"path": "library/src/main/java/com/crossbowffs/remotepreferences/RemoteUtils.java",
"chars": 6862,
"preview": "package com.crossbowffs.remotepreferences;\n\nimport java.util.HashSet;\nimport java.util.Set;\n\n/**\n * Common utilities use"
},
{
"path": "settings.gradle.kts",
"chars": 40,
"preview": "include(\":library\")\ninclude(\":testapp\")\n"
},
{
"path": "testapp/build.gradle.kts",
"chars": 690,
"preview": "plugins {\n id(\"com.android.application\")\n}\n\nandroid {\n namespace = \"com.crossbowffs.remotepreferences.testapp\"\n "
},
{
"path": "testapp/src/androidTest/java/com/crossbowffs/remotepreferences/RemotePreferenceProviderTest.java",
"chars": 14066,
"preview": "package com.crossbowffs.remotepreferences;\n\nimport android.content.ContentResolver;\nimport android.content.ContentValues"
},
{
"path": "testapp/src/androidTest/java/com/crossbowffs/remotepreferences/RemotePreferencesTest.java",
"chars": 16808,
"preview": "package com.crossbowffs.remotepreferences;\n\nimport android.content.Context;\nimport android.content.SharedPreferences;\nim"
},
{
"path": "testapp/src/androidTest/java/com/crossbowffs/remotepreferences/RemoteUtilsTest.java",
"chars": 1624,
"preview": "package com.crossbowffs.remotepreferences;\n\nimport androidx.test.ext.junit.runners.AndroidJUnit4;\n\nimport org.junit.Asse"
},
{
"path": "testapp/src/main/AndroidManifest.xml",
"chars": 516,
"preview": "<manifest\n xmlns:android=\"http://schemas.android.com/apk/res/android\">\n <application>\n <provider\n "
},
{
"path": "testapp/src/main/java/com/crossbowffs/remotepreferences/testapp/TestConstants.java",
"chars": 515,
"preview": "package com.crossbowffs.remotepreferences.testapp;\n\npublic final class TestConstants {\n private TestConstants() {}\n\n "
},
{
"path": "testapp/src/main/java/com/crossbowffs/remotepreferences/testapp/TestPreferenceListener.java",
"chars": 1189,
"preview": "package com.crossbowffs.remotepreferences.testapp;\n\nimport android.content.SharedPreferences;\n\nimport java.util.concurre"
},
{
"path": "testapp/src/main/java/com/crossbowffs/remotepreferences/testapp/TestPreferenceProvider.java",
"chars": 616,
"preview": "package com.crossbowffs.remotepreferences.testapp;\n\nimport com.crossbowffs.remotepreferences.RemotePreferenceProvider;\n\n"
},
{
"path": "testapp/src/main/java/com/crossbowffs/remotepreferences/testapp/TestPreferenceProviderDisabled.java",
"chars": 342,
"preview": "package com.crossbowffs.remotepreferences.testapp;\n\nimport com.crossbowffs.remotepreferences.RemotePreferenceProvider;\n\n"
}
]
// ... and 1 more files (download for full content)
About this extraction
This page contains the full source code of the apsun/RemotePreferences GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 29 files (116.0 KB), approximately 26.5k tokens, and a symbol index with 175 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.