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