diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..00a51af --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +# +# https://help.github.com/articles/dealing-with-line-endings/ +# +# These are explicitly windows files and should use crlf +*.bat text eol=crlf + diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..4a7692b --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,27 @@ +name: Build and Test + +on: + push: + branches: [ '*' ] + pull_request: + branches: [ '*' ] + + repository_dispatch: + types: [test] + +jobs: + run-unit-test: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Setup Java + uses: actions/setup-java@v2 + with: + distribution: 'adopt' + java-version: '17' + + - name: build + run: ./gradlew check -S --no-daemon diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..c2d2d19 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,47 @@ +name: Publish to Sonatype + +on: + push: + tags: + - '*' + repository_dispatch: + types: [publish] + +jobs: + publish: + runs-on: ubuntu-latest + if: github.repository == 'johnsonlee/sandbox' + + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Setup Java + uses: actions/setup-java@v2 + with: + distribution: 'adopt' + java-version: '17' + + - name: Publish to sonatype + run: | + echo "Create GPG private key" + echo $GPG_KEY_ARMOR | base64 --decode > ${GITHUB_WORKSPACE}/secring.gpg + echo "Publish ${GITHUB_REF} to Sonatype" + ./gradlew clean publishToSonatype -S --no-daemon \ + -Pversion=${GITHUB_REF/refs\/tags\/v/} \ + -POSSRH_USERNAME=${OSSRH_USERNAME} \ + -POSSRH_PASSWORD=${OSSRH_PASSWORD} \ + -Psigning.keyId=${GPG_KEY_ID} \ + -Psigning.password=${GPG_PASSPHRASE} \ + -Psigning.secretKeyRingFile=${GITHUB_WORKSPACE}/secring.gpg + ./gradlew closeAndReleaseRepository -S --no-daemon \ + -Pversion=${GITHUB_REF/refs\/tags\/v/} \ + -POSSRH_USERNAME=${OSSRH_USERNAME} \ + -POSSRH_PASSWORD=${OSSRH_PASSWORD} \ + --no-daemon + env: + GPG_KEY_ARMOR: ${{ secrets.GPG_KEY_ARMOR }} + GPG_KEY_ID: ${{ secrets.GPG_KEY_ID }} + GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} + OSSRH_PASSWORD: ${{ secrets.OSSRH_PASSWORD }} + OSSRH_USERNAME: ${{ secrets.OSSRH_USERNAME }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7b77430 --- /dev/null +++ b/.gitignore @@ -0,0 +1,48 @@ +# Generated by Mac OS X +.DS_Store + +# Generated by VIM +.*.swp + +# Generated by Gradle +.gradle +build/ +out/ + +# Generated by IDEA +.idea +*.iml + +# Generated by Eclipse +.metadata +.settings +.project +.classpath +/bin/ + +# Generated by Maven +target/ +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +pom.xml.next +release.properties +dependency-reduced-pom.xml +buildNumber.properties +.mvn/timing.properties + +# Generated by IDE +local.properties + +# Node & NPM +node_modules + +# GitBook +_book + +# Misc +*.log +*.bak + +# Travis CI +.travis/secring.gpg diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..3d8cdaa --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,51 @@ +import org.gradle.api.Project.DEFAULT_VERSION +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + kotlin("jvm") version embeddedKotlinVersion + id("io.johnsonlee.sonatype-publish-plugin") version "1.9.0" +} + +group = "io.johnsonlee.playground" +version = project.findProperty("version")?.takeIf { it != DEFAULT_VERSION } ?: "1.0.0-SNAPSHOT" + +repositories { + mavenCentral() + google() +} + +dependencies { + api(kotlin("bom")) + api(kotlin("stdlib")) + api(kotlin("reflect")) + api(libs.android.tools.build.aapt2.proto) + api(libs.android.tools.common) + api(libs.android.tools.external.intellij.core) + api(libs.android.tools.layoutlib.api) + api(libs.android.tools.sdk.common) + api(libs.androidx.lifecycle.common.java8) + api(libs.jackson.databind) + api(libs.kxml2) + api(libs.layoutlib.native.jdk11) + api(libs.okio) + api(libs.protobuf) + api(libs.slf4j.api) + testImplementation(kotlin("test")) + testImplementation(kotlin("test-junit")) +} + +java { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 +} + +tasks.withType { + kotlinOptions { + freeCompilerArgs = listOf("-Xjsr305=strict") + jvmTarget = "11" + } +} + +tasks.withType { + useJUnitPlatform() +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..9cd327d --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,26 @@ +[versions] +aapt2 = "8.1.1-10154469" +android-tools = "31.1.2" +androidx-lifecycle = "2.6.2" +jackson = "2.13.4" +kxml2 = "2.3.0" +layoutlib = "2022.3.1-5e972ea" +slf4j = "1.7.36" +okio = "3.4.0" +protobuf = "3.19.3" + + +[libraries] +android-tools-build-aapt2-proto = { module = "com.android.tools.build:aapt2-proto", version.ref = "aapt2" } +android-tools-common = { module = "com.android.tools:common", version.ref = "android-tools" } +android-tools-external-intellij-core = { module = "com.android.tools.external.com-intellij:intellij-core", version.ref = "android-tools" } +android-tools-layoutlib-api = { module = "com.android.tools.layoutlib:layoutlib-api", version.ref = "android-tools" } +android-tools-ninepatch = { module = "com.android.tools:ninepatch", version.ref = "android-tools" } +android-tools-sdk-common = { module = "com.android.tools:sdk-common", version.ref = "android-tools" } +androidx-lifecycle-common-java8 = { module = "androidx.lifecycle:lifecycle-common-java8", version.ref = "androidx-lifecycle" } +jackson-databind = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jackson" } +kxml2 = { module = "net.sf.kxml:kxml2", version.ref = "kxml2" } +layoutlib-native-jdk11 = { module = "io.johnsonlee:layoutlib-native-jdk11", version.ref = "layoutlib" } +slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" } +okio = { module = "com.squareup.okio:okio-jvm", version.ref = "okio" } +protobuf = { module = "com.google.protobuf:protobuf-java", version.ref = "protobuf" } \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..41d9927 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..db9a6b8 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..1b6c787 --- /dev/null +++ b/gradlew @@ -0,0 +1,234 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..107acd3 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..794f3fb --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,16 @@ +rootProject.name = "sandbox" + +pluginManagement { + repositories { + mavenCentral() + google() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositories { + mavenCentral() + google() + } +} \ No newline at end of file diff --git a/src/main/java/android/os/Build.java b/src/main/java/android/os/Build.java new file mode 100644 index 0000000..87dce91 --- /dev/null +++ b/src/main/java/android/os/Build.java @@ -0,0 +1,56 @@ +package android.os; + +public class Build { + + public static String MANUFACTURER = "Google"; + + public static String MODEL = "Playground"; + + public static String PRODUCT = "playground"; + + public static String DEVICE = "playground"; + + public static String BRAND = "google"; + + public static class VERSION { + public static int SDK_INT = 31; + } + + public static class VERSION_CODES { + public static final int CUR_DEVELOPMENT = 10000; + public static final int BASE = 1; + public static final int BASE_1_1 = 2; + public static final int CUPCAKE = 3; + public static final int DONUT = 4; + public static final int ECLAIR = 5; + public static final int ECLAIR_0_1 = 6; + public static final int ECLAIR_MR1 = 7; + public static final int FROYO = 8; + public static final int GINGERBREAD = 9; + public static final int GINGERBREAD_MR1 = 10; + public static final int HONEYCOMB = 11; + public static final int HONEYCOMB_MR1 = 12; + public static final int HONEYCOMB_MR2 = 13; + public static final int ICE_CREAM_SANDWICH = 14; + public static final int ICE_CREAM_SANDWICH_MR1 = 15; + public static final int JELLY_BEAN = 16; + public static final int JELLY_BEAN_MR1 = 17; + public static final int JELLY_BEAN_MR2 = 18; + public static final int KITKAT = 19; + public static final int KITKAT_WATCH = 20; + public static final int LOLLIPOP = 21; + public static final int LOLLIPOP_MR1 = 22; + public static final int M = 23; + public static final int N = 24; + public static final int N_MR1 = 25; + public static final int O = 26; + public static final int O_MR1 = 27; + public static final int P = 28; + public static final int Q = 29; + public static final int R = 30; + public static final int S = 31; + public static final int S_V2 = 32; + public static final int TIRAMISU = 33; + } + +} diff --git a/src/main/java/com/android/resources/GrammaticalGender.java b/src/main/java/com/android/resources/GrammaticalGender.java new file mode 100644 index 0000000..9723257 --- /dev/null +++ b/src/main/java/com/android/resources/GrammaticalGender.java @@ -0,0 +1,70 @@ +package com.android.resources; + +public enum GrammaticalGender implements ResourceEnum { + NEUTER("neuter", "Neuter", "Neuter"), + FEMININE("feminine", "Feminine", "Feminine"), + MASCULINE("masculine", "Masculine", "Masculine"); + + private final String mValue; + private final String mShortDisplayValue; + private final String mLongDisplayValue; + + GrammaticalGender(String value, String shortDisplayValue, String longDisplayValue) { + mValue = value; + mShortDisplayValue = shortDisplayValue; + mLongDisplayValue = longDisplayValue; + } + + /** + * Returns the enum for matching the provided qualifier value. + * + * @param value The qualifier value. + * @return the enum for the qualifier value or null if no matching was found. + */ + public static GrammaticalGender getEnum(String value) { + for (GrammaticalGender state : values()) { + if (state.mValue.equals(value)) { + return state; + } + } + + return null; + } + + @Override + public String getResourceValue() { + return mValue; + } + + @Override + public String getShortDisplayValue() { + return mShortDisplayValue; + } + + @Override + public String getLongDisplayValue() { + return mLongDisplayValue; + } + + public static int getIndex(GrammaticalGender value) { + return value == null ? -1 : value.ordinal(); + } + + public static GrammaticalGender getByIndex(int index) { + GrammaticalGender[] values = values(); + if (index >= 0 && index < values.length) { + return values[index]; + } + return null; + } + + @Override + public boolean isFakeValue() { + return false; + } + + @Override + public boolean isValidValueForDevice() { + return true; + } +} \ No newline at end of file diff --git a/src/main/java/com/android/resources/aar/AarProtoResourceRepository.java b/src/main/java/com/android/resources/aar/AarProtoResourceRepository.java new file mode 100644 index 0000000..4f9f115 --- /dev/null +++ b/src/main/java/com/android/resources/aar/AarProtoResourceRepository.java @@ -0,0 +1,920 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.resources.aar; + +import com.android.aapt.ConfigurationOuterClass.Configuration; +import com.android.aapt.Resources; +import com.android.ide.common.rendering.api.AttrResourceValue; +import com.android.ide.common.rendering.api.AttributeFormat; +import com.android.ide.common.rendering.api.DensityBasedResourceValue; +import com.android.ide.common.rendering.api.ResourceNamespace; +import com.android.ide.common.rendering.api.StyleItemResourceValue; +import com.android.ide.common.rendering.api.StyleItemResourceValueImpl; +import com.android.ide.common.rendering.api.StyleResourceValue; +import com.android.ide.common.resources.AndroidManifestPackageNameUtils; +import com.android.ide.common.resources.ResourceItem; +import com.android.ide.common.resources.configuration.DensityQualifier; +import com.android.ide.common.resources.configuration.FolderConfiguration; +import com.android.ide.common.util.PathString; +import com.android.resources.Arity; +import com.android.resources.Density; +import com.android.resources.ResourceType; +import com.android.resources.ResourceVisibility; +import com.android.resources.base.BasicArrayResourceItem; +import com.android.resources.base.BasicAttrReference; +import com.android.resources.base.BasicAttrResourceItem; +import com.android.resources.base.BasicDensityBasedFileResourceItem; +import com.android.resources.base.BasicFileResourceItem; +import com.android.resources.base.BasicPluralsResourceItem; +import com.android.resources.base.BasicResourceItem; +import com.android.resources.base.BasicStyleResourceItem; +import com.android.resources.base.BasicStyleableResourceItem; +import com.android.resources.base.BasicTextValueResourceItem; +import com.android.resources.base.BasicValueResourceItem; +import com.android.resources.base.RepositoryConfiguration; +import com.android.resources.base.RepositoryLoader; +import com.android.resources.base.ResourceSourceFile; +import com.android.resources.base.ResourceSourceFileImpl; +import com.android.resources.base.ResourceUrlParser; +import com.android.utils.SdkUtils; +import com.google.common.base.CharMatcher; +import com.google.common.collect.ArrayListMultimap; +import com.google.common.collect.HashBasedTable; +import com.google.common.collect.ListMultimap; +import com.google.common.collect.Table; +import com.google.protobuf.ByteString; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.util.BitUtil; +import com.intellij.util.io.URLUtil; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.BufferedInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.EnumMap; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.function.Predicate; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +import static com.android.SdkConstants.DOT_XML; +import static com.android.resources.base.RepositoryLoader.portableFileName; +import static com.android.utils.DecimalUtils.trimInsignificantZeros; + +/** + * Repository of resources defined in an AAR file where resources are stored in protocol buffer format. + * See https://developer.android.com/studio/projects/android-library.html. + * See https://android.googlesource.com/platform/frameworks/base/+/master/tools/aapt2/Resources.proto + */ +public class AarProtoResourceRepository extends AbstractAarResourceRepository { + /** Configuration filter that accepts all configurations. */ + protected static final Predicate TRIVIAL_CONFIG_FILTER = config -> true; + /** Resource type filter that accepts all resource types. */ + protected static final Predicate TRIVIAL_RESOURCE_TYPE_FILTER = type -> true; + + /** Protocol for accessing contents of .apk files. */ + @NotNull private static final String APK_PROTOCOL = "apk"; + /** The name of the res.apk ZIP entry containing value resources. */ + private static final String RESOURCE_TABLE_ENTRY = "resources.pb"; + + private static final Logger LOG = Logger.getInstance(AarProtoResourceRepository.class); + + // The following constants represent the complex dimension encoding defined in + // https://android.googlesource.com/platform/frameworks/base/+/master/libs/androidfw/include/androidfw/ResourceTypes.h + private static final int COMPLEX_UNIT_MASK = 0xF; + private static final String[] DIMEN_SUFFIXES = {"px", "dp", "sp", "pt", "in", "mm"}; + private static final String[] FRACTION_SUFFIXES = {"%", "%p"}; + private static final int COMPLEX_RADIX_SHIFT = 4; + private static final int COMPLEX_RADIX_MASK = 0x3; + /** Multiplication factors for 4 possible radixes. */ + private static final double[] RADIX_FACTORS = {1., 1. / (1 << 7), 1. / (1 << 15), 1. / (1 << 23)}; + // The signed mantissa is stored in the higher 24 bits of the value. + private static final int COMPLEX_MANTISSA_SHIFT = 8; + + @NotNull protected final Path myResApkFile; + /** + * Common prefix of paths of all file resources. Used to compose resource paths returned by + * the {@link BasicFileResourceItem#getSource()} method. + */ + @NotNull private final String myResourcePathPrefix; + /** + * Common prefix of URLs of all file resources. Used to compose resource URLs returned by + * the {@link BasicFileResourceItem#getValue()} method. + */ + @NotNull private final String myResourceUrlPrefix; + /** + * Common prefix of source attachments. Used to compose file paths returned by + * the {@link BasicResourceItem#getOriginalSource()} method. + */ + @Nullable private final String mySourceAttachmentPrefix; + + protected AarProtoResourceRepository(@NotNull Loader loader, @Nullable String libraryName, @Nullable Path sourceJar) { + super(loader.myNamespace, libraryName); + myResApkFile = loader.myResApkFile; + + myResourcePathPrefix = myResApkFile.toString() + URLUtil.JAR_SEPARATOR; + myResourceUrlPrefix = APK_PROTOCOL + "://" + portableFileName(myResApkFile.toString()) + URLUtil.JAR_SEPARATOR; + + mySourceAttachmentPrefix = sourceJar != null && loader.myPackageName != null ? + sourceJar.toString() + URLUtil.JAR_SEPARATOR + getPackageNamePrefix(loader.myPackageName) : null; + } + + @Override + @NotNull + public Path getOrigin() { + return myResApkFile; + } + + @Override + @Nullable + public final String getPackageName() { + return myNamespace.getPackageName(); + } + + /** + * Creates a resource repository for an AAR file. + * + * @param resApkFile the res.apk file + * @param libraryName the name of the library + * @return the created resource repository + */ + @NotNull + public static AarProtoResourceRepository create(@NotNull Path resApkFile, @NotNull String libraryName) { + Loader loader = new Loader(resApkFile, TRIVIAL_CONFIG_FILTER, TRIVIAL_RESOURCE_TYPE_FILTER); + try { + loader.readApkFile(); + } catch (IOException e) { + LOG.error(e); + // Return an empty repository. + return new AarProtoResourceRepository(loader, libraryName, null); + } + + // TODO: Make the source jar a parameter of this method and stop relying on a name convention here. + Path sourceJar = getSourceJarPath(resApkFile); + if (!Files.exists(sourceJar)) { + sourceJar = null; + } + + + AarProtoResourceRepository repository = new AarProtoResourceRepository(loader, libraryName, sourceJar); + loader.loadRepositoryContents(repository); + return repository; + } + + /** + * Returns the path of the source JAR file given the path of res.apk. The name of the source jar is obtained + * by replacing the ".apk" file name suffix with "-src.jar". + */ + private static Path getSourceJarPath(@NotNull Path resApkFile) { + String filename = resApkFile.getFileName().toString(); + int extensionPos = filename.lastIndexOf('.'); + if (extensionPos >= 0) { + filename = filename.substring(0, extensionPos); + } + filename += "-src.jar"; + return resApkFile.resolveSibling(filename); + } + + @Override + @NotNull + public final String getResourceUrl(@NotNull String relativeResourcePath) { + return expandRelativeResourcePath(myResourceUrlPrefix, relativeResourcePath, true); + } + + @Override + @NotNull + public final PathString getSourceFile(@NotNull String relativeResourcePath, boolean forFileResource) { + return new PathString(APK_PROTOCOL, expandRelativeResourcePath(myResourcePathPrefix, relativeResourcePath, forFileResource)); + } + + /** + * Converts a relative resource path to an absolute path or URL pointing inside res.apk by prepending a given + * {@code prefix} to the path. If {@code relativeResourcePath} is a path inside res.apk, the prefix is simply + * prepended to it. If {@code relativeResourcePath} is a path inside a source attachment JAR without a package + * prefix, it is first converted to a path inside res.apk by removing the first, overlay number, segment. Then + * the prefix is prepended to the converted path. Whether the path points inside res.apk or the source + * attachment JAR is determined by result returned by the {@link #hasOverlaySegment(String, boolean)}. + * + * @param prefix the prefix to prepend + * @param relativeResourcePath the relative path of a resource that may or may not start with an overlay number segment + * @param forFileResource true is the resource is a file resource, false if it is a value resource + * @return the converted path + */ + private String expandRelativeResourcePath(@NotNull String prefix, @NotNull String relativeResourcePath, boolean forFileResource) { + int offset = 0; + if (hasOverlaySegment(relativeResourcePath, forFileResource)) { + assert Character.isDigit(relativeResourcePath.charAt(0)); + // relativeResourcePath is the path of the original source that includes an overlay number as the first segment. + // Skip the first segment to convert the source path to the path of proto XML. + offset = relativeResourcePath.indexOf('/') + 1; + } + int prefixLength = prefix.length(); + int pathLength = relativeResourcePath.length(); + char[] result = new char[prefixLength + pathLength - offset]; + prefix.getChars(0, prefixLength, result, 0); + relativeResourcePath.getChars(offset, pathLength, result, prefixLength); + return new String(result); + } + + /** + * Checks if the given relative resource path is expected to contain an overlay segment or not. + * The check is based on how resource items are created by the {@link Loader#createResourceItem} methods. + * + * @param relativeResourcePath the relative path of a resource that may or may not start with an overlay number segment + * @param forFileResource true is the resource is a file resource, false if it is a value resource + * @return true if the resource path is expected to contain an overlay segment + */ + private boolean hasOverlaySegment(@NotNull String relativeResourcePath, boolean forFileResource) { + return forFileResource && mySourceAttachmentPrefix != null && isXml(relativeResourcePath); + } + + @Override + @Nullable + public final PathString getOriginalSourceFile(@NotNull String relativeResourcePath, boolean forFileResource) { + if (isXml(relativeResourcePath)) { + if (mySourceAttachmentPrefix == null) { + return null; + } + return new PathString("jar", mySourceAttachmentPrefix + relativeResourcePath); + } + + return getSourceFile(relativeResourcePath, forFileResource); + } + + private static boolean isXml(@NotNull String filePath) { + return SdkUtils.endsWithIgnoreCase(filePath, DOT_XML); + } + + @NotNull + private static String getPackageNamePrefix(@NotNull String packageName) { + return packageName.replace('.', '/') + '/'; + } + + // For debugging only. + @Override + public String toString() { + return getClass().getSimpleName() + '@' + Integer.toHexString(System.identityHashCode(this)) + " for " + myResApkFile; + } + + protected static class Loader { + @NotNull private final Path myResApkFile; + @NotNull private final Predicate myConfigFilter; + @NotNull private final Predicate myResourceTypeFilter; + @NotNull private final ResourceUrlParser myUrlParser = new ResourceUrlParser(); + @NotNull private final ListMultimap myStyleables = ArrayListMultimap.create(); + @NotNull private final Table mySourceFileCache = HashBasedTable.create(); + @Nullable private Resources.ResourceTable myResourceTableMsg; + @Nullable private String myPackageName; + private ResourceNamespace myNamespace; + + Loader(@NotNull Path resApkFile, @NotNull Predicate configFilter, @NotNull Predicate resourceTypeFilter) { + myResApkFile = resApkFile; + myConfigFilter = configFilter; + myResourceTypeFilter = resourceTypeFilter; + } + + void readApkFile() throws IOException { + try (ZipFile zipFile = new ZipFile(myResApkFile.toFile())) { + myResourceTableMsg = readResourceTableFromResApk(zipFile); + myPackageName = AndroidManifestPackageNameUtils.getPackageNameFromResApk(zipFile); + } finally { + myNamespace = myPackageName == null ? ResourceNamespace.RES_AUTO : ResourceNamespace.fromPackageName(myPackageName); + } + } + + public void loadRepositoryContents(@NotNull AarProtoResourceRepository repository) { + if (myResourceTableMsg != null) { + loadFromResourceTable(repository, myResourceTableMsg); + } + } + + private void loadFromResourceTable(@NotNull AarProtoResourceRepository repository, @NotNull Resources.ResourceTable resourceTableMsg) { + // String pool is only needed if there is a source attachment. + StringPool stringPool = repository.mySourceAttachmentPrefix == null ? + null : new StringPool(resourceTableMsg.getSourcePool(), myNamespace.getPackageName()); + + for (Resources.Package packageMsg : resourceTableMsg.getPackageList()) { + for (Resources.Type typeMsg : packageMsg.getTypeList()) { + String typeName = typeMsg.getName(); + ResourceType resourceType = ResourceType.fromClassName(typeName); + if (resourceType == null) { + // AAPT2 emits "^attr-private" type for all non-public "attr" resources. For reference see http://b/122572805 and + // https://android.googlesource.com/platform/frameworks/base/+/refs/heads/master/tools/aapt2/link/Linkers.h#65. + if (typeName.equals("^attr-private")) { + resourceType = ResourceType.ATTR; + } + else { + LOG.warn("Unexpected resource type: " + typeName); + continue; + } + } + if (myResourceTypeFilter.test(resourceType)) { + for (Resources.Entry entryMsg : typeMsg.getEntryList()) { + String resourceName = entryMsg.getName(); + Resources.Visibility visibilityMsg = entryMsg.getVisibility(); + ResourceVisibility visibility = decodeVisibility(visibilityMsg); + for (Resources.ConfigValue configValueMsg : entryMsg.getConfigValueList()) { + Resources.Value valueMsg = configValueMsg.getValue(); + Resources.Source sourceMsg = valueMsg.getSource(); + String sourcePath = stringPool == null ? null : stringPool.getString(sourceMsg.getPathIdx()); + if (sourcePath != null && sourcePath.isEmpty()) { + sourcePath = null; + } + Configuration configMsg = configValueMsg.getConfig(); + if (myConfigFilter.test(configMsg)) { + ResourceSourceFile sourceFile = getSourceFile(repository, sourcePath, configMsg); + ResourceItem item = createResourceItem(valueMsg, resourceType, resourceName, sourceFile, visibility); + if (item != null) { + addResourceItem(repository, item); + } + } + } + } + } + } + } + + for (BasicStyleableResourceItem styleable : myStyleables.values()) { + repository.addResourceItem(RepositoryLoader.resolveAttrReferences(styleable)); + } + + repository.populatePublicResourcesMap(); + repository.freezeResources(); + } + + private void addResourceItem(@NotNull AarProtoResourceRepository repository, @NotNull ResourceItem item) { + if (item.getType() == ResourceType.STYLEABLE) { + myStyleables.put(item.getName(), (BasicStyleableResourceItem)item); + } + else { + repository.addResourceItem(item); + } + } + + @Nullable + private BasicResourceItem createResourceItem(@NotNull Resources.Value valueMsg, @NotNull ResourceType resourceType, + @NotNull String resourceName, @NotNull ResourceSourceFile sourceFile, + @NotNull ResourceVisibility visibility) { + switch (valueMsg.getValueCase()) { + case ITEM: + return createResourceItem(valueMsg.getItem(), resourceType, resourceName, sourceFile, visibility); + + case COMPOUND_VALUE: + String description = valueMsg.getComment(); + if (CharMatcher.whitespace().matchesAllOf(description)) { + description = null; + } + return createResourceItem(valueMsg.getCompoundValue(), resourceName, sourceFile, visibility, description); + + case VALUE_NOT_SET: + default: + LOG.warn("Unexpected Value message: " + valueMsg); + break; + } + return null; + } + + @Nullable + private BasicResourceItem createResourceItem(@NotNull Resources.Item itemMsg, @NotNull ResourceType resourceType, + @NotNull String resourceName, @NotNull ResourceSourceFile sourceFile, + @NotNull ResourceVisibility visibility) { + switch (itemMsg.getValueCase()) { + case FILE: { + // For XML files, which contain proto XML that is not human-readable, use the source attachment path when available. + // For other resources use the path inside res.apk. + String path = sourceFile.getRelativePath(); + if (path == null || !isXml(path)) { + path = itemMsg.getFile().getPath(); + } + RepositoryConfiguration configuration = sourceFile.getConfiguration(); + if (DensityBasedResourceValue.isDensityBasedResourceType(resourceType)) { + FolderConfiguration folderConfiguration = configuration.getFolderConfiguration(); + DensityQualifier densityQualifier = folderConfiguration.getDensityQualifier(); + if (densityQualifier != null) { + Density densityValue = densityQualifier.getValue(); + if (densityValue != null) { + return new BasicDensityBasedFileResourceItem(resourceType, resourceName, configuration, visibility, path, densityValue); + } + } + } + return new BasicFileResourceItem(resourceType, resourceName, configuration, visibility, path); + } + + case REF: { + String ref = decode(itemMsg.getRef()); + return createResourceItem(resourceType, resourceName, sourceFile, visibility, ref); + } + + case STR: { + String textValue = itemMsg.getStr().getValue(); + return new BasicValueResourceItem(resourceType, resourceName, sourceFile, visibility, textValue); + } + + case RAW_STR: { + String str = itemMsg.getRawStr().getValue(); + return createResourceItem(resourceType, resourceName, sourceFile, visibility, str); + } + + case PRIM: { + String str = decode(itemMsg.getPrim()); + return createResourceItem(resourceType, resourceName, sourceFile, visibility, str); + } + + case STYLED_STR: { + Resources.StyledString styledStrMsg = itemMsg.getStyledStr(); + String textValue = styledStrMsg.getValue(); + String rawXmlValue = ProtoStyledStringDecoder.getRawXmlValue(styledStrMsg); + if (rawXmlValue.equals(textValue)) { + return new BasicValueResourceItem(resourceType, resourceName, sourceFile, visibility, textValue); + } + return new BasicTextValueResourceItem(resourceType, resourceName, sourceFile, visibility, textValue, rawXmlValue); + } + + case ID: { + return createResourceItem(resourceType, resourceName, sourceFile, visibility, null); + } + + case VALUE_NOT_SET: + default: + LOG.warn("Unexpected Item message: " + itemMsg); + break; + } + return null; + } + + @NotNull + private static BasicResourceItem createResourceItem(@NotNull ResourceType resourceType, @NotNull String resourceName, + @NotNull ResourceSourceFile sourceFile, @NotNull ResourceVisibility visibility, + @Nullable String value) { + return new BasicValueResourceItem(resourceType, resourceName, sourceFile, visibility, value); + } + + @Nullable + private BasicResourceItem createResourceItem(@NotNull Resources.CompoundValue compoundValueMsg, @NotNull String resourceName, + @NotNull ResourceSourceFile sourceFile, @NotNull ResourceVisibility visibility, + @Nullable String description) { + switch (compoundValueMsg.getValueCase()) { + case ATTR: + return createAttr(compoundValueMsg.getAttr(), resourceName, sourceFile, visibility, description); + + case STYLE: + return createStyle(compoundValueMsg.getStyle(), resourceName, sourceFile, visibility); + + case STYLEABLE: + return createStyleable(compoundValueMsg.getStyleable(), resourceName, sourceFile, visibility); + + case ARRAY: + return createArray(compoundValueMsg.getArray(), resourceName, sourceFile, visibility); + + case PLURAL: + return createPlurals(compoundValueMsg.getPlural(), resourceName, sourceFile, visibility); + + case VALUE_NOT_SET: + default: + LOG.warn("Unexpected CompoundValue message: " + compoundValueMsg); + return null; + } + } + + @NotNull + private static BasicAttrResourceItem createAttr(@NotNull Resources.Attribute attributeMsg, @NotNull String resourceName, + @NotNull ResourceSourceFile sourceFile, @NotNull ResourceVisibility visibility, + @Nullable String description) { + Set formats = decodeFormatFlags(attributeMsg.getFormatFlags()); + + List symbolList = attributeMsg.getSymbolList(); + Map valueMap = Collections.emptyMap(); + Map valueDescriptionMap = Collections.emptyMap(); + for (Resources.Attribute.Symbol symbolMsg : symbolList) { + String name = symbolMsg.getName().getName(); + // Remove the explicit resource type to match the behavior of AarSourceResourceRepository. + int slashPos = name.lastIndexOf('/'); + if (slashPos >= 0) { + name = name.substring(slashPos + 1); + } + String symbolDescription = symbolMsg.getComment(); + if (CharMatcher.whitespace().matchesAllOf(symbolDescription)) { + symbolDescription = null; + } + if (valueMap.isEmpty()) { + valueMap = new HashMap<>(); + } + valueMap.put(name, symbolMsg.getValue()); + if (symbolDescription != null) { + if (valueDescriptionMap.isEmpty()) { + valueDescriptionMap = new HashMap<>(); + } + valueDescriptionMap.put(name, symbolDescription); + } + } + + String groupName = null; // Attribute group name is not available in a proto resource repository. + return new BasicAttrResourceItem(resourceName, sourceFile, visibility, description, groupName, formats, valueMap, valueDescriptionMap); + } + + @NotNull + private BasicStyleResourceItem createStyle(@NotNull Resources.Style styleMsg, @NotNull String resourceName, + @NotNull ResourceSourceFile sourceFile, @NotNull ResourceVisibility visibility) { + String libraryName = sourceFile.getRepository().getLibraryName(); + myUrlParser.parseResourceUrl(styleMsg.getParent().getName()); + String parentStyle = myUrlParser.getQualifiedName(); + if (StyleResourceValue.isDefaultParentStyleName(parentStyle, resourceName)) { + parentStyle = null; // Don't store a parent style name that can be derived from the name of the style. + } + List styleItems = new ArrayList<>(styleMsg.getEntryCount()); + for (Resources.Style.Entry entryMsg : styleMsg.getEntryList()) { + String url = entryMsg.getKey().getName(); + myUrlParser.parseResourceUrl(url); + String name = myUrlParser.getQualifiedName(); + String value = decode(entryMsg.getItem()); + StyleItemResourceValueImpl itemValue = new StyleItemResourceValueImpl(myNamespace, name, value, libraryName); + styleItems.add(itemValue); + } + + return new BasicStyleResourceItem(resourceName, sourceFile, visibility, parentStyle, styleItems); + } + + @NotNull + private BasicStyleableResourceItem createStyleable(@NotNull Resources.Styleable styleableMsg, @NotNull String resourceName, + @NotNull ResourceSourceFile sourceFile, @NotNull ResourceVisibility visibility) { + List attrs = new ArrayList<>(styleableMsg.getEntryCount()); + for (Resources.Styleable.Entry entryMsg : styleableMsg.getEntryList()) { + String url = entryMsg.getAttr().getName(); + myUrlParser.parseResourceUrl(url); + String packageName = myUrlParser.getNamespacePrefix(); + ResourceNamespace attrNamespace = packageName == null ? myNamespace : ResourceNamespace.fromPackageName(packageName); + String comment = entryMsg.getComment(); + BasicAttrReference attr = + new BasicAttrReference(attrNamespace, myUrlParser.getName(), sourceFile, visibility, comment.isEmpty() ? null : comment, null); + attrs.add(attr); + } + return new BasicStyleableResourceItem(resourceName, sourceFile, visibility, attrs); + } + + @NotNull + private BasicArrayResourceItem createArray(@NotNull Resources.Array arrayMsg, @NotNull String resourceName, + @NotNull ResourceSourceFile sourceFile, @NotNull ResourceVisibility visibility) { + List elements = new ArrayList<>(arrayMsg.getElementCount()); + for (Resources.Array.Element elementMsg : arrayMsg.getElementList()) { + String text = decode(elementMsg.getItem()); + if (text != null) { + elements.add(text); + } + } + return new BasicArrayResourceItem(resourceName, sourceFile, visibility, elements, 0); + } + + @NotNull + private BasicPluralsResourceItem createPlurals(@NotNull Resources.Plural pluralMsg, @NotNull String resourceName, + @NotNull ResourceSourceFile sourceFile, @NotNull ResourceVisibility visibility) { + EnumMap values = new EnumMap<>(Arity.class); + for (Resources.Plural.Entry entryMsg : pluralMsg.getEntryList()) { + values.put(decodeArity(entryMsg.getArity()), decode(entryMsg.getItem())); + } + return new BasicPluralsResourceItem(resourceName, sourceFile, visibility, values, null); + } + + @NotNull + private ResourceSourceFile getSourceFile(@NotNull AarProtoResourceRepository repository, @Nullable String sourcePath, + @NotNull Configuration configMsg) { + String sourcePathKey = sourcePath == null ? "" : sourcePath; + ResourceSourceFile sourceFile = mySourceFileCache.get(sourcePathKey, configMsg); + if (sourceFile != null) { + return sourceFile; + } + + FolderConfiguration configuration = ProtoConfigurationDecoder.getConfiguration(configMsg); + configuration.normalizeByRemovingRedundantVersionQualifier(); + + sourceFile = new ResourceSourceFileImpl(sourcePath, new RepositoryConfiguration(repository, configuration)); + mySourceFileCache.put(sourcePathKey, configMsg, sourceFile); + return sourceFile; + } + + @Nullable + private String decode(@NotNull Resources.Item itemMsg) { + switch (itemMsg.getValueCase()) { + case REF: + return decode(itemMsg.getRef()); + case STR: + return itemMsg.getStr().getValue(); + case RAW_STR: + return itemMsg.getRawStr().getValue(); + case STYLED_STR: + return itemMsg.getStyledStr().getValue(); + case FILE: + return itemMsg.getFile().getPath(); + case ID: + return null; + case PRIM: + return decode(itemMsg.getPrim()); + case VALUE_NOT_SET: + default: + break; + } + return null; + } + + @NotNull + private String decode(@NotNull Resources.Reference referenceMsg) { + String name = referenceMsg.getName(); + if (name.isEmpty()) { + return ""; + } + if (referenceMsg.getType() == Resources.Reference.Type.ATTRIBUTE) { + myUrlParser.parseResourceUrl(name); + if (myUrlParser.hasType(ResourceType.ATTR.getName())) { + name = myUrlParser.getQualifiedName(); + } + return '?' + name; + } + return '@' + name; + } + + @Nullable + private static String decode(@NotNull Resources.Primitive primitiveMsg) { + switch (primitiveMsg.getOneofValueCase()) { + case NULL_VALUE: + return null; + + case EMPTY_VALUE: + return ""; + + case FLOAT_VALUE: + return trimInsignificantZeros(Float.toString(primitiveMsg.getFloatValue())); + + case DIMENSION_VALUE: + return decodeComplexDimensionValue(primitiveMsg.getDimensionValue(), 1., DIMEN_SUFFIXES); + + case FRACTION_VALUE: + return decodeComplexDimensionValue(primitiveMsg.getFractionValue(), 100., FRACTION_SUFFIXES); + + case INT_DECIMAL_VALUE: + return Integer.toString(primitiveMsg.getIntDecimalValue()); + + case INT_HEXADECIMAL_VALUE: + return String.format("0x%X", primitiveMsg.getIntHexadecimalValue()); + + case BOOLEAN_VALUE: + return Boolean.toString(primitiveMsg.getBooleanValue()); + + case COLOR_ARGB8_VALUE: + return String.format("#%08X", primitiveMsg.getColorArgb8Value()); + + case COLOR_RGB8_VALUE: + return String.format("#%06X", primitiveMsg.getColorRgb8Value() & 0xFFFFFF); + + case COLOR_ARGB4_VALUE: + int argb = primitiveMsg.getColorArgb4Value(); + return String.format("#%X%X%X%X", (argb >>> 24) & 0xF, (argb >>> 16) & 0xF, (argb >>> 8) & 0xF, argb & 0xF); + + case COLOR_RGB4_VALUE: + int rgb = primitiveMsg.getColorRgb4Value(); + return String.format("#%X%X%X", (rgb >>> 16) & 0xF, (rgb >>> 8) & 0xF, rgb & 0xF); + + case ONEOFVALUE_NOT_SET: + default: + LOG.warn("Unexpected Primitive message: " + primitiveMsg); + break; + } + return null; + } + + /** + * Decodes a dimension value in the Android binary XML encoding and returns a string suitable for regular XML. + * + * @param bits the encoded value + * @param scaleFactor the scale factor to apply to the result + * @param unitSuffixes the unit suffixes, either {@link #DIMEN_SUFFIXES} or {@link #FRACTION_SUFFIXES} + * @return the decoded value as a string, e.g. "-6.5dp", or "60%" + * @see + * ResourceTypes.h + */ + private static String decodeComplexDimensionValue(int bits, double scaleFactor, @NotNull String[] unitSuffixes) { + int unitCode = bits & COMPLEX_UNIT_MASK; + String unit = unitCode < unitSuffixes.length ? unitSuffixes[unitCode] : " unknown unit: " + unitCode; + int radix = (bits >> COMPLEX_RADIX_SHIFT) & COMPLEX_RADIX_MASK; + int mantissa = bits >> COMPLEX_MANTISSA_SHIFT; + double value = mantissa * RADIX_FACTORS[radix] * scaleFactor; + return trimInsignificantZeros(String.format(Locale.US, "%.5g", value)) + unit; + } + + @NotNull + private static Set decodeFormatFlags(int flags) { + Set result = EnumSet.noneOf(AttributeFormat.class); + if (BitUtil.isSet(flags, Resources.Attribute.FormatFlags.REFERENCE_VALUE)) { + result.add(AttributeFormat.REFERENCE); + } + if (BitUtil.isSet(flags, Resources.Attribute.FormatFlags.STRING_VALUE)) { + result.add(AttributeFormat.STRING); + } + if (BitUtil.isSet(flags, Resources.Attribute.FormatFlags.INTEGER_VALUE)) { + result.add(AttributeFormat.INTEGER); + } + if (BitUtil.isSet(flags, Resources.Attribute.FormatFlags.BOOLEAN_VALUE)) { + result.add(AttributeFormat.BOOLEAN); + } + if (BitUtil.isSet(flags, Resources.Attribute.FormatFlags.COLOR_VALUE)) { + result.add(AttributeFormat.COLOR); + } + if (BitUtil.isSet(flags, Resources.Attribute.FormatFlags.FLOAT_VALUE)) { + result.add(AttributeFormat.FLOAT); + } + if (BitUtil.isSet(flags, Resources.Attribute.FormatFlags.DIMENSION_VALUE)) { + result.add(AttributeFormat.DIMENSION); + } + if (BitUtil.isSet(flags, Resources.Attribute.FormatFlags.FRACTION_VALUE)) { + result.add(AttributeFormat.FRACTION); + } + if (BitUtil.isSet(flags, Resources.Attribute.FormatFlags.ENUM_VALUE)) { + result.add(AttributeFormat.ENUM); + } + if (BitUtil.isSet(flags, Resources.Attribute.FormatFlags.FLAGS_VALUE)) { + result.add(AttributeFormat.FLAGS); + } + return result; + } + + @NotNull + private static Arity decodeArity(@NotNull Resources.Plural.Arity arity) { + switch (arity) { + case ZERO: + return Arity.ZERO; + case ONE: + return Arity.ONE; + case TWO: + return Arity.TWO; + case FEW: + return Arity.FEW; + case MANY: + return Arity.MANY; + case OTHER: + default: + return Arity.OTHER; + } + } + + @NotNull + private static ResourceVisibility decodeVisibility(@NotNull Resources.Visibility visibilityMsg) { + switch (visibilityMsg.getLevel()) { + case UNKNOWN: + return ResourceVisibility.PRIVATE_XML_ONLY; + case PRIVATE: + return ResourceVisibility.PRIVATE; + case PUBLIC: + return ResourceVisibility.PUBLIC; + case UNRECOGNIZED: + default: + return ResourceVisibility.UNDEFINED; + } + } + + /** + * Loads resource table from res.apk file. + * + * @return the resource table proto message + */ + @NotNull + private static Resources.ResourceTable readResourceTableFromResApk(@NotNull ZipFile resApk) throws IOException { + ZipEntry zipEntry = resApk.getEntry(RESOURCE_TABLE_ENTRY); + if (zipEntry == null) { + throw new IOException("\"" + RESOURCE_TABLE_ENTRY + "\" not found in " + resApk.getName()); + } + + try (InputStream stream = new BufferedInputStream(resApk.getInputStream(zipEntry))) { + return Resources.ResourceTable.parseFrom(stream); + } + } + } + + /** + * Extracts strings encoded inside a {@link Resources.StringPool} proto message. + */ + private static class StringPool { + // See definition of the ResStringPool_header structure at + // https://android.googlesource.com/platform/frameworks/base/+/tools_r22.2/include/androidfw/ResourceTypes.h + private static final int STRING_COUNT_OFFSET = 8; + private static final int FLAGS_OFFSET = 16; + private static final int STRINGS_START_INDEX_OFFSET = 20; + private static final int UTF8_FLAG = 1 << 8; + private static final String REPLACEMENT_PREFIX = "0/res/"; + + @NotNull final String[] strings; + private int currentOffset; + + StringPool(@NotNull Resources.StringPool stringPoolMsg, @Nullable String packageName) { + ByteString bytes = stringPoolMsg.getData(); + if ((getInt32(bytes, FLAGS_OFFSET) & UTF8_FLAG) == 0) { + throw new IllegalArgumentException("UTF-16 encoded string pool is not supported"); + } + int stringCount = getInt32(bytes, STRING_COUNT_OFFSET); + strings = new String[stringCount]; + currentOffset = getInt32(bytes, STRINGS_START_INDEX_OFFSET); + for (int i = 0; i < stringCount; i++) { + getByteEncodedLength(bytes); // Skip the number of characters. + int byteCount = getByteEncodedLength(bytes); + int endOffset = currentOffset + byteCount; + strings[i] = bytes.substring(currentOffset, endOffset).toStringUtf8(); + currentOffset = endOffset + 1; // Skip the bytes of the string including the 0x00 terminator. + } + normalizePaths(packageName); + } + + private static int getByte(@NotNull ByteString bytes, int offset) { + return bytes.byteAt(offset) & 0xFF; + } + + private static int getInt32(@NotNull ByteString bytes, int offset) { + return getByte(bytes, offset) | + (getByte(bytes, offset + 1) << 8) | + (getByte(bytes, offset + 2) << 16) | + (getByte(bytes, offset + 3) << 24); + } + + /** + * Decodes a length value encoded using the EncodeLength(char*, size_t) function defined at + * https://android.googlesource.com/platform/frameworks/base/+/master/tools/aapt2/StringPool.cpp + */ + private int getByteEncodedLength(@NotNull ByteString bytes) { + int b = getByte(bytes, currentOffset++); + if ((b & 0x80) == 0) { + return b; + } + return (b & 0x7F) << 8 | getByte(bytes, currentOffset++); + } + + /** + * Source paths in AARv2 are supposed to be relative, but currently AAPT2 inserts absolute paths. + * This method works around this AAPT2 limitation by converting source paths to the form they are + * supposed to have. + */ + private void normalizePaths(@Nullable String packageName) { + String packagePrefix = packageName == null ? null : getPackageNamePrefix(packageName); + String prefix = null; + for (int i = 0, n = strings.length; i < n; i++) { + String str = strings[i]; + if (!str.isEmpty()) { + str = portableFileName(str); + if (str.charAt(0) == '/') { + // The string represents an absolute path. Convert it to a relative path. + if (prefix == null) { + String anchor = "/res/"; + int pos = str.indexOf(anchor); + if (pos >= 0) { + prefix = str.substring(0, pos + anchor.length()); + } + } + if (prefix == null) { + String anchor = "/namespaced_res/"; + int pos = str.indexOf(anchor); + if (pos >= 0) { + // Skip the following directory segment that reflects the name of the library. + pos = str.indexOf('/', pos + anchor.length()); + if (pos >= 0) { + prefix = str.substring(0, pos + 1); + } + } + } + if (prefix != null && str.startsWith(prefix)) { + str = REPLACEMENT_PREFIX + str.substring(prefix.length()); + } + } + else if (packagePrefix != null && str.startsWith(packagePrefix)) { + // The string represents a relative path. Remove the package prefix if present. + str = str.substring(packagePrefix.length()); + } + + strings[i] = str; + } + } + } + + @NotNull + public String getString(int index) { + return strings[index]; + } + } +} diff --git a/src/main/java/com/android/resources/aar/AarResourceRepository.java b/src/main/java/com/android/resources/aar/AarResourceRepository.java new file mode 100644 index 0000000..f1f9eea --- /dev/null +++ b/src/main/java/com/android/resources/aar/AarResourceRepository.java @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.resources.aar; + +import com.android.resources.base.LoadableResourceRepository; + +/** + * Resource repository containing resources of an Android library (AAR). + */ +public interface AarResourceRepository extends LoadableResourceRepository { +} diff --git a/src/main/java/com/android/resources/aar/AarSourceResourceRepository.java b/src/main/java/com/android/resources/aar/AarSourceResourceRepository.java new file mode 100644 index 0000000..664a91a --- /dev/null +++ b/src/main/java/com/android/resources/aar/AarSourceResourceRepository.java @@ -0,0 +1,459 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.resources.aar; + +import static com.android.SdkConstants.FN_ANDROID_MANIFEST_XML; +import static com.android.SdkConstants.FN_PUBLIC_TXT; +import static com.android.SdkConstants.FN_RESOURCE_TEXT; +import static java.nio.charset.StandardCharsets.UTF_8; + +import com.android.ide.common.rendering.api.ResourceNamespace; +import com.android.ide.common.resources.AndroidManifestPackageNameUtils; +import com.android.ide.common.resources.ResourceItem; +import com.android.ide.common.resources.configuration.FolderConfiguration; +import com.android.ide.common.symbols.Symbol; +import com.android.ide.common.symbols.SymbolIo; +import com.android.ide.common.symbols.SymbolTable; +import com.android.ide.common.util.PathString; +import com.android.resources.ResourceType; +import com.android.resources.ResourceVisibility; +import com.android.resources.base.BasicFileResourceItem; +import com.android.resources.base.BasicResourceItem; +import com.android.resources.base.NamespaceResolver; +import com.android.resources.base.RepositoryConfiguration; +import com.android.resources.base.RepositoryLoader; +import com.android.resources.base.ResourceSerializationUtil; +import com.android.resources.base.ResourceSourceFile; +import com.android.resources.base.ResourceSourceFileImpl; +import com.android.utils.Base128InputStream; +import com.android.utils.Base128OutputStream; +import com.google.common.base.Preconditions; +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.Maps; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.progress.ProcessCanceledException; +import com.intellij.openapi.util.NullableLazyValue; +import java.io.BufferedReader; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.file.Files; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.util.Collection; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.Executor; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import java.util.zip.ZipEntry; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.TestOnly; + +/** + * A resource repository representing unpacked contents of a non-namespaced AAR. + * + *

For performance reasons ID resources defined using @+id syntax in layout XML files are + * obtained from R.txt instead, when it is available. This means that + * {@link ResourceItem#getOriginalSource()} method may return null for such ID resources. + */ +public class AarSourceResourceRepository extends AbstractAarResourceRepository { + /** + * Increment when making changes that may affect content of repository cache files. + * Used together with CachingData.codeVersion. Important for developer builds. + */ + static final String CACHE_FILE_FORMAT_VERSION = "3"; + private static final byte[] CACHE_FILE_HEADER = "Resource cache".getBytes(UTF_8); + private static final Logger LOG = Logger.getInstance(AarSourceResourceRepository.class); + + @NotNull protected final Path myResourceDirectoryOrFile; + protected boolean myLoadedFromCache; + /** + * Protocol used for constructing {@link PathString}s returned by the {@link BasicFileResourceItem#getSource()} method. + */ + @NotNull private final String mySourceFileProtocol; + /** + * Common prefix of paths of all file resources. Used to compose resource paths returned by + * the {@link BasicFileResourceItem#getSource()} method. + */ + @NotNull private final String myResourcePathPrefix; + /** + * Common prefix of URLs of all file resources. Used to compose resource URLs returned by + * the {@link BasicFileResourceItem#getValue()} method. + */ + @NotNull private final String myResourceUrlPrefix; + /** The package name read on-demand from the manifest. */ + @NotNull private final NullableLazyValue myManifestPackageName; + + protected AarSourceResourceRepository(@NotNull RepositoryLoader loader, + @Nullable String libraryName) { + super(loader.getNamespace(), libraryName); + myResourceDirectoryOrFile = loader.getResourceDirectoryOrFile(); + mySourceFileProtocol = loader.getSourceFileProtocol(); + myResourcePathPrefix = loader.getResourcePathPrefix(); + myResourceUrlPrefix = loader.getResourceUrlPrefix(); + + myManifestPackageName = NullableLazyValue.lazyNullable(() -> { + try { + PathString manifestPath = getSourceFile("../" + FN_ANDROID_MANIFEST_XML, true); + return AndroidManifestPackageNameUtils.getPackageNameFromManifestFile(manifestPath); + } + catch (FileNotFoundException e) { + return null; + } + catch (IOException e) { + LOG.error("Failed to read manifest " + FN_ANDROID_MANIFEST_XML + " for " + getDisplayName(), e); + return null; + } + }); + } + + /** + * Creates and loads a resource repository. Consider calling AarResourceRepositoryCache.getSourceRepository instead of this + * method. + * + * @param resourceDirectoryOrFile the res directory or an AAR file containing resources + * @param libraryName the name of the library + * @return the created resource repository + */ + @NotNull + public static AarSourceResourceRepository create(@NotNull Path resourceDirectoryOrFile, @NotNull String libraryName) { + return create(resourceDirectoryOrFile, libraryName, null); + } + + /** + * Creates and loads a resource repository. Consider calling AarResourceRepositoryCache.getSourceRepository instead of this + * method. + * + * @param resourceDirectoryOrFile the res directory or an AAR file containing resources + * @param libraryName the name of the library + * @param cachingData data used to validate and create a persistent cache file + * @return the created resource repository + */ + @NotNull + public static AarSourceResourceRepository create(@NotNull Path resourceDirectoryOrFile, @NotNull String libraryName, + @Nullable CachingData cachingData) { + return create(resourceDirectoryOrFile, null, ResourceNamespace.RES_AUTO, libraryName, cachingData); + } + + /** + * Creates and loads a resource repository without using a persistent cache. Consider calling + * AarResourceRepositoryCache.getSourceRepository instead of this method. + * + * @param resourceFolderRoot specifies the resource files to be loaded. The list of files to be loaded can be restricted by providing + * a not null {@code resourceFolderResources} list of files and subdirectories that should be loaded. + * @param resourceFolderResources A null value indicates that all files and subdirectories in {@code resourceFolderRoot} should be loaded. + * Otherwise files and subdirectories specified in {@code resourceFolderResources} are loaded. + * @param libraryName the name of the library + * @param cachingData data used to validate and create a persistent cache file + * @return the created resource repository + */ + @NotNull + public static AarSourceResourceRepository create(@NotNull PathString resourceFolderRoot, + @Nullable Collection resourceFolderResources, + @NotNull String libraryName, + @Nullable CachingData cachingData) { + Path resDir = resourceFolderRoot.toPath(); + Preconditions.checkArgument(resDir != null); + return create(resDir, resourceFolderResources, ResourceNamespace.RES_AUTO, libraryName, cachingData); + } + + @NotNull + private static AarSourceResourceRepository create(@NotNull Path resourceDirectoryOrFile, + @Nullable Collection resourceFilesAndFolders, + @NotNull ResourceNamespace namespace, + @NotNull String libraryName, + @Nullable CachingData cachingData) { + Loader loader = new Loader(resourceDirectoryOrFile, resourceFilesAndFolders, namespace); + AarSourceResourceRepository repository = new AarSourceResourceRepository(loader, libraryName); + + // If loading from an AAR file, try to load from a cache file first. + if (cachingData != null && resourceFilesAndFolders == null && repository.loadFromPersistentCache(cachingData)) { + return repository; + } + + loader.loadRepositoryContents(repository); + + repository.populatePublicResourcesMap(); + repository.freezeResources(); + + if (cachingData != null && resourceFilesAndFolders == null) { + Executor executor = cachingData.getCacheCreationExecutor(); + if (executor != null) { + executor.execute(() -> repository.createPersistentCache(cachingData)); + } + } + return repository; + } + + @Override + @NotNull + public Path getOrigin() { + return myResourceDirectoryOrFile; + } + + @TestOnly + @NotNull + public static AarSourceResourceRepository createForTest( + @NotNull Path resourceDirectoryOrFile, @NotNull ResourceNamespace namespace, @NotNull String libraryName) { + return create(resourceDirectoryOrFile, null, namespace, libraryName, null); + } + + @Override + @Nullable + public String getPackageName() { + String packageName = myNamespace.getPackageName(); + return packageName == null ? myManifestPackageName.getValue() : packageName; + } + + @Override + @NotNull + public PathString getSourceFile(@NotNull String relativeResourcePath, boolean forFileResource) { + return new PathString(mySourceFileProtocol, myResourcePathPrefix + relativeResourcePath); + } + + @Override + @NotNull + public String getResourceUrl(@NotNull String relativeResourcePath) { + return myResourceUrlPrefix + relativeResourcePath; + } + + /** + * Loads the resource repository from a binary cache file on disk. + * + * @return true if the repository was loaded from the cache, or false if the cache does not + * exist or is out of date + * @see #createPersistentCache(CachingData) + */ + private boolean loadFromPersistentCache(@NotNull CachingData cachingData) { + byte[] header = ResourceSerializationUtil.getCacheFileHeader(stream -> writeCacheHeaderContent(cachingData, stream)); + return loadFromPersistentCache(cachingData.getCacheFile(), header); + } + + /** + * Creates persistent cache on disk for faster loading later. + */ + private void createPersistentCache(@NotNull CachingData cachingData) { + byte[] header = ResourceSerializationUtil.getCacheFileHeader(stream -> writeCacheHeaderContent(cachingData, stream)); + ResourceSerializationUtil.createPersistentCache(cachingData.getCacheFile(), header, stream -> writeToStream(stream, config -> true)); + } + + protected void writeCacheHeaderContent(@NotNull CachingData cachingData, @NotNull Base128OutputStream stream) throws IOException { + stream.write(CACHE_FILE_HEADER); + stream.writeString(CACHE_FILE_FORMAT_VERSION); + stream.writeString(myResourceDirectoryOrFile.toString()); + stream.writeString(cachingData.getContentVersion()); + stream.writeString(cachingData.getCodeVersion()); + } + + /** + * Loads contents the repository from a cache file on disk. + * @see ResourceSerializationUtil#createPersistentCache + */ + private boolean loadFromPersistentCache(@NotNull Path cacheFile, byte[] fileHeader) { + try (Base128InputStream stream = new Base128InputStream(cacheFile)) { + if (!stream.validateContents(fileHeader)) { + return false; // Cache file header doesn't match. + } + loadFromStream(stream, Maps.newHashMapWithExpectedSize(1000), null); + + populatePublicResourcesMap(); + freezeResources(); + myLoadedFromCache = true; + return true; + } + catch (NoSuchFileException e) { + return false; // Cache file does not exist. + } + catch (ProcessCanceledException e) { + cleanupAfterFailedLoadingFromCache(); + throw e; + } + catch (Throwable e) { + cleanupAfterFailedLoadingFromCache(); + LOG.warn("Failed to load resources from cache file " + cacheFile, e); + return false; + } + } + + /** + * Called when an attempt to load from persistent cache fails after some data may have already been loaded. + */ + protected void cleanupAfterFailedLoadingFromCache() { + myResources.clear(); // Remove partially loaded data. + } + + /** + * Writes contents of the repository to the given output stream. + * + * @param stream the stream to write to + * @param configFilter only resources belonging to configurations satisfying this filter are written to the stream + */ + void writeToStream(@NotNull Base128OutputStream stream, @NotNull Predicate configFilter) throws IOException { + ResourceSerializationUtil.writeResourcesToStream(myResources, stream, configFilter); + } + + /** + * Loads contents the repository from the given input stream. + * @see #writeToStream(Base128OutputStream, Predicate) + */ + protected void loadFromStream(@NotNull Base128InputStream stream, + @NotNull Map stringCache, + @Nullable Map namespaceResolverCache) throws IOException { + ResourceSerializationUtil.readResourcesFromStream(stream, stringCache, namespaceResolverCache, this, this::addResourceItem); + } + + @TestOnly + boolean isLoadedFromCache() { + return myLoadedFromCache; + } + + // For debugging only. + @Override + @NotNull + public String toString() { + return getClass().getSimpleName() + '@' + Integer.toHexString(System.identityHashCode(this)) + " for " + myResourceDirectoryOrFile; + } + + private static class Loader extends RepositoryLoader { + @NotNull private Set myRTxtIds = ImmutableSet.of(); + + Loader(@NotNull Path resourceDirectoryOrFile, @Nullable Collection resourceFilesAndFolders, + @NotNull ResourceNamespace namespace) { + super(resourceDirectoryOrFile, resourceFilesAndFolders, namespace); + } + + @Override + protected boolean loadIdsFromRTxt() { + if (myZipFile == null) { + Path rDotTxt = myResourceDirectoryOrFile.resolveSibling(FN_RESOURCE_TEXT); + if (Files.exists(rDotTxt)) { + try { + SymbolTable symbolTable = SymbolIo.readFromAaptNoValues(rDotTxt.toFile(), null); + myRTxtIds = computeIds(symbolTable); + return true; + } + catch (ProcessCanceledException e) { + throw e; + } + catch (Exception e) { + LOG.warn("Failed to load id resources from " + rDotTxt, e); + } + } + } + else { + ZipEntry zipEntry = myZipFile.getEntry(FN_RESOURCE_TEXT); + if (zipEntry != null) { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(myZipFile.getInputStream(zipEntry), UTF_8))) { + SymbolTable symbolTable = SymbolIo.readFromAaptNoValues(reader, FN_RESOURCE_TEXT + " in " + myResourceDirectoryOrFile, null); + myRTxtIds = computeIds(symbolTable); + return true; + } + catch (ProcessCanceledException e) { + throw e; + } + catch (Exception e) { + LOG.warn("Failed to load id resources from " + FN_RESOURCE_TEXT + " in " + myResourceDirectoryOrFile, e); + } + } + return false; + } + return false; + } + + @Override + protected void finishLoading(@NotNull AarSourceResourceRepository repository) { + super.finishLoading(repository); + createResourcesForRTxtIds(repository); + } + + /** + * Creates ID resources for the ID names in the R.txt file. + */ + private void createResourcesForRTxtIds(@NotNull AarSourceResourceRepository repository) { + if (!myRTxtIds.isEmpty()) { + RepositoryConfiguration configuration = getConfiguration(repository, ResourceItem.DEFAULT_CONFIGURATION); + ResourceSourceFile sourceFile = new ResourceSourceFileImpl(null, configuration); + for (String name : myRTxtIds) { + addIdResourceItem(name, sourceFile); + } + addValueFileResources(); + } + } + + private static Set computeIds(@NotNull SymbolTable symbolTable) { + return symbolTable.getSymbols() + .row(ResourceType.ID) + .values() + .stream() + .map(Symbol::getCanonicalName) + .collect(Collectors.toSet()); + } + + @Override + protected void loadPublicResourceNames() { + if (myZipFile == null) { + Path file = myResourceDirectoryOrFile.resolveSibling(FN_PUBLIC_TXT); + try (BufferedReader reader = Files.newBufferedReader(file)) { + readPublicResourceNames(reader); + } + catch (NoSuchFileException e) { + myDefaultVisibility = ResourceVisibility.PUBLIC; // The "public.txt" file does not exist - myDefaultVisibility will be PUBLIC. + } + catch (IOException e) { + // Failure to load public resource names is not considered fatal. + LOG.warn("Error reading " + file, e); + } + } else { + ZipEntry zipEntry = myZipFile.getEntry(FN_PUBLIC_TXT); + if (zipEntry == null) { + myDefaultVisibility = ResourceVisibility.PUBLIC; // The "public.txt" file does not exist - myDefaultVisibility will be PUBLIC. + } + else { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(myZipFile.getInputStream(zipEntry), UTF_8))) { + readPublicResourceNames(reader); + } + catch (IOException e) { + // Failure to load public resource names is not considered fatal. + LOG.warn("Error reading " + FN_PUBLIC_TXT + " from " + myResourceDirectoryOrFile, e); + } + } + } + } + + @Override + protected void addResourceItem(@NotNull BasicResourceItem item, @NotNull AarSourceResourceRepository repository) { + repository.addResourceItem(item); + } + + private void readPublicResourceNames(@NotNull BufferedReader reader) throws IOException { + String line; + while ((line = reader.readLine()) != null) { + // Lines in public.txt have the following format: + line = line.trim(); + int delimiterPos = line.indexOf(' '); + if (delimiterPos > 0 && delimiterPos + 1 < line.length()) { + ResourceType type = ResourceType.fromXmlTagName(line.substring(0, delimiterPos)); + if (type != null) { + String name = line.substring(delimiterPos + 1); + addPublicResourceName(type, name); + } + } + } + } + } +} diff --git a/src/main/java/com/android/resources/aar/AbstractAarResourceRepository.java b/src/main/java/com/android/resources/aar/AbstractAarResourceRepository.java new file mode 100644 index 0000000..bc4dbbf --- /dev/null +++ b/src/main/java/com/android/resources/aar/AbstractAarResourceRepository.java @@ -0,0 +1,163 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.resources.aar; + +import com.android.ide.common.rendering.api.ResourceNamespace; +import com.android.ide.common.resources.AbstractResourceRepository; +import com.android.ide.common.resources.ResourceItem; +import com.android.ide.common.resources.ResourceItemWithVisibility; +import com.android.ide.common.resources.ResourceVisitor; +import com.android.resources.ResourceType; +import com.android.resources.ResourceVisibility; +import com.google.common.collect.ArrayListMultimap; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableListMultimap; +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.ListMultimap; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Collection; +import java.util.Collections; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Common superclass for {@link AarSourceResourceRepository} and {@link AarProtoResourceRepository}. + */ +public abstract class AbstractAarResourceRepository extends AbstractResourceRepository implements AarResourceRepository { + @NotNull protected final ResourceNamespace myNamespace; + @NotNull protected final Map> myResources = new EnumMap<>(ResourceType.class); + @NotNull private final Map> myPublicResources = new EnumMap<>(ResourceType.class); + @Nullable protected final String myLibraryName; + + AbstractAarResourceRepository(@NotNull ResourceNamespace namespace, @Nullable String libraryName) { + myNamespace = namespace; + myLibraryName = libraryName; + } + + @Override + @NotNull + protected final ListMultimap getResourcesInternal( + @NotNull ResourceNamespace namespace, @NotNull ResourceType resourceType) { + if (!namespace.equals(myNamespace)) { + return ImmutableListMultimap.of(); + } + return myResources.getOrDefault(resourceType, ImmutableListMultimap.of()); + } + + @NotNull + private ListMultimap getOrCreateMap(@NotNull ResourceType resourceType) { + return myResources.computeIfAbsent(resourceType, type -> ArrayListMultimap.create()); + } + + protected final void addResourceItem(@NotNull ResourceItem item) { + ListMultimap multimap = getOrCreateMap(item.getType()); + multimap.put(item.getName(), item); + } + + /** + * Populates the {@link #myPublicResources} map. Has to be called after {@link #myResources} has been populated. + */ + protected final void populatePublicResourcesMap() { + for (Map.Entry> entry : myResources.entrySet()) { + ResourceType resourceType = entry.getKey(); + ImmutableSet.Builder setBuilder = null; + ListMultimap items = entry.getValue(); + for (ResourceItem item : items.values()) { + if (((ResourceItemWithVisibility)item).getVisibility() == ResourceVisibility.PUBLIC) { + if (setBuilder == null) { + setBuilder = ImmutableSet.builder(); + } + setBuilder.add(item); + } + } + myPublicResources.put(resourceType, setBuilder == null ? ImmutableSet.of() : setBuilder.build()); + } + } + + /** + * Makes resource maps immutable. + */ + protected void freezeResources() { + for (Map.Entry> entry : myResources.entrySet()) { + myResources.put(entry.getKey(), ImmutableListMultimap.copyOf(entry.getValue())); + } + } + + @Override + @NotNull + public ResourceVisitor.VisitResult accept(@NotNull ResourceVisitor visitor) { + if (visitor.shouldVisitNamespace(myNamespace)) { + if (AbstractResourceRepository.acceptByResources(myResources, visitor) == ResourceVisitor.VisitResult.ABORT) { + return ResourceVisitor.VisitResult.ABORT; + } + } + + return ResourceVisitor.VisitResult.CONTINUE; + + } + + @Override + @NotNull + public List getResources(@NotNull ResourceNamespace namespace, @NotNull ResourceType resourceType, + @NotNull String resourceName) { + ListMultimap map = getResourcesInternal(namespace, resourceType); + List items = map.get(resourceName); + return items == null ? ImmutableList.of() : items; + } + + @Override + @NotNull + public ListMultimap getResources(@NotNull ResourceNamespace namespace, @NotNull ResourceType resourceType) { + return getResourcesInternal(namespace, resourceType); + } + + @Override + @NotNull + public Collection getPublicResources(@NotNull ResourceNamespace namespace, @NotNull ResourceType type) { + if (!namespace.equals(myNamespace)) { + return Collections.emptySet(); + } + Set resourceItems = myPublicResources.get(type); + return resourceItems == null ? Collections.emptySet() : resourceItems; + } + + @Override + @NotNull + public final ResourceNamespace getNamespace() { + return myNamespace; + } + + @Override + @Nullable + public final String getLibraryName() { + return myLibraryName; + } + + @Override + @NotNull + public final String getDisplayName() { + return myLibraryName == null ? "Android Framework" : myLibraryName; + } + + @Override + public final boolean containsUserDefinedResources() { + return false; + } +} diff --git a/src/main/java/com/android/resources/aar/CachingData.kt b/src/main/java/com/android/resources/aar/CachingData.kt new file mode 100644 index 0000000..44c00db --- /dev/null +++ b/src/main/java/com/android/resources/aar/CachingData.kt @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.resources.aar + +import java.nio.file.Path +import java.util.concurrent.Executor + +/** + * Externally provided data used by [AarSourceResourceRepository] and [FrameworkResourceRepository] + * to validate and create a persistent cache file. + * + * @param cacheFile The location of the cache file. + * @param contentVersion The version of the content of the resource directory or file. + * @param codeVersion The version of the Android plugin, used to make sure that the cache file is updated + * when the code changes. This version is an additional safety measure on top of + * [AarSourceResourceRepository.CACHE_FILE_FORMAT_VERSION]. + * @param cacheCreationExecutor The executor used for creating a cache file, or null if the cache file + * should not be created if it doesn't exist or is out of date. + */ +class CachingData(val cacheFile: Path, + val contentVersion: String, + val codeVersion: String, + val cacheCreationExecutor: Executor? = null) + +/** + * Directory for the cache files relative to the system path. + */ +const val RESOURCE_CACHE_DIRECTORY = "caches/resources" diff --git a/src/main/java/com/android/resources/aar/FrameworkResJarCreator.java b/src/main/java/com/android/resources/aar/FrameworkResJarCreator.java new file mode 100644 index 0000000..d8366ca --- /dev/null +++ b/src/main/java/com/android/resources/aar/FrameworkResJarCreator.java @@ -0,0 +1,122 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.resources.aar; + +import com.android.utils.Base128OutputStream; +import com.google.common.annotations.VisibleForTesting; +import com.intellij.openapi.util.io.FileUtil; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; +import org.jetbrains.annotations.NotNull; + +/** + * A command-line program for packaging framework resources into framework_res.jar. The jar file + * created by this program contains compressed XML resource files and two binary files, + * resources.bin and resources_light.bin. Format of these binary files is identical to format of + * a framework resource cache file without a header. The resources.bin file contains a list of all + * framework resources. The resources_light.bin file contains a list of resources excluding + * locale-specific ones. + */ +@SuppressWarnings({"UseOfSystemOutOrSystemErr", "CallToPrintStackTrace"}) +public class FrameworkResJarCreator { + public static void main(@NotNull String[] args) { + if (args.length != 2) { + printUsage(FrameworkResJarCreator.class.getName()); + System.exit(1); + } + + Path resDirectory = Paths.get(args[0]).toAbsolutePath().normalize(); + Path jarFile = Paths.get(args[1]).toAbsolutePath().normalize(); + try { + createJar(resDirectory, jarFile); + } + catch (IOException e) { + e.printStackTrace(); + } + } + + @VisibleForTesting + static void createJar(@NotNull Path resDirectory, @NotNull Path jarFile) throws IOException { + FrameworkResourceRepository repository = FrameworkResourceRepository.create(resDirectory, null, null, false); + Set languages = repository.getLanguageGroups(); + + try (ZipOutputStream zip = new ZipOutputStream(Files.newOutputStream(jarFile))) { + for (String language : languages) { + String entryName = FrameworkResourceRepository.getResourceTableNameForLanguage(language); + createZipEntry(entryName, getEncodedResources(repository, language), zip); + } + + Path parentDir = resDirectory.getParent(); + List files = getContainedFiles(resDirectory); + + for (Path file : files) { + // When running on Windows, we need to make sure that the file entries are correctly encoded + // with the Unix path separator since the ZIP file spec only allows for that one. + String relativePath = FileUtil.toSystemIndependentName(parentDir.relativize(file).toString()); + if (!relativePath.equals("res/version") && !relativePath.equals("res/BUILD")) { // Skip "version" and "BUILD" files. + createZipEntry(relativePath, Files.readAllBytes(file), zip); + } + } + } + } + + @NotNull + private static List getContainedFiles(@NotNull Path resDirectory) throws IOException { + List files = new ArrayList<>(); + Files.walkFileTree(resDirectory, new SimpleFileVisitor() { + @Override + @NotNull + public FileVisitResult visitFile(@NotNull Path file, @NotNull BasicFileAttributes attrs) { + files.add(file); + return FileVisitResult.CONTINUE; + } + }); + Collections.sort(files); // Make sure that the files are in canonical order. + return files; + } + + private static void createZipEntry(@NotNull String name, @NotNull byte[] content, @NotNull ZipOutputStream zip) throws IOException { + ZipEntry entry = new ZipEntry(name); + zip.putNextEntry(entry); + zip.write(content); + zip.closeEntry(); + } + + @NotNull + private static byte[] getEncodedResources(@NotNull FrameworkResourceRepository repository, @NotNull String language) throws IOException { + ByteArrayOutputStream byteStream = new ByteArrayOutputStream(); + try (Base128OutputStream stream = new Base128OutputStream(byteStream)) { + repository.writeToStream(stream, config -> language.equals(FrameworkResourceRepository.getLanguageGroup(config))); + } + return byteStream.toByteArray(); + } + + private static void printUsage(@NotNull String programName) { + System.out.printf("Usage: %s %n", programName); + } +} diff --git a/src/main/java/com/android/resources/aar/FrameworkResourceRepository.java b/src/main/java/com/android/resources/aar/FrameworkResourceRepository.java new file mode 100644 index 0000000..070dc83 --- /dev/null +++ b/src/main/java/com/android/resources/aar/FrameworkResourceRepository.java @@ -0,0 +1,634 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.resources.aar; + +import static com.android.SdkConstants.DOT_9PNG; +import static com.android.SdkConstants.FD_RES_RAW; + +import com.android.ide.common.rendering.api.ResourceNamespace; +import com.android.ide.common.resources.ResourceItem; +import com.android.ide.common.resources.configuration.FolderConfiguration; +import com.android.ide.common.resources.configuration.LocaleQualifier; +import com.android.ide.common.util.PathString; +import com.android.io.CancellableFileIo; +import com.android.resources.ResourceType; +import com.android.resources.base.BasicResourceItem; +import com.android.resources.base.BasicResourceItemBase; +import com.android.resources.base.BasicValueResourceItemBase; +import com.android.resources.base.NamespaceResolver; +import com.android.resources.base.RepositoryConfiguration; +import com.android.resources.base.RepositoryLoader; +import com.android.resources.base.ResourceSerializationUtil; +import com.android.utils.Base128InputStream; +import com.android.utils.Base128OutputStream; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.ImmutableSortedSet; +import com.google.common.collect.ListMultimap; +import com.google.common.collect.Maps; +import com.google.common.collect.Sets; +import com.intellij.openapi.application.PathManager; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.progress.ProcessCanceledException; +import com.intellij.openapi.util.TextRange; +import com.intellij.openapi.util.text.StringUtil; +import java.io.IOException; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.concurrent.Executor; +import java.util.stream.Stream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.TestOnly; + +/** + * Repository of resources of the Android framework. Most client code should use + * the ResourceRepositoryManager.getFrameworkResources method to obtain framework resources. + * + *

The repository can be loaded either from a res directory containing XML files, or from + * framework_res.jar file, or from a binary cache file located under the directory returned by + * the {@link PathManager#getSystemPath()} method. This binary cache file can be created as + * a side effect of loading the repository from a res directory. + * + *

Loading from framework_res.jar or a binary cache file is 3-4 times faster than loading + * from res directory. + * + * @see FrameworkResJarCreator + */ +public final class FrameworkResourceRepository extends AarSourceResourceRepository { + private static final ResourceNamespace ANDROID_NAMESPACE = ResourceNamespace.ANDROID; + /** Mapping from languages to language groups, e.g. Romansh is mapped to Italian. */ + private static final Map LANGUAGE_TO_GROUP = ImmutableMap.of("rm", "it"); + private static final String RESOURCES_TABLE_PREFIX = "resources_"; + private static final String RESOURCE_TABLE_SUFFIX = ".bin"; + private static final String COMPILED_9PNG_EXTENSION = ".compiled.9.png"; + + private static final Logger LOG = Logger.getInstance(FrameworkResourceRepository.class); + + private final Set myLanguageGroups = new TreeSet<>(); + private int myNumberOfLanguageGroupsLoadedFromCache; + private final boolean myUseCompiled9Patches; + + private FrameworkResourceRepository(@NotNull RepositoryLoader loader, boolean useCompiled9Patches) { + super(loader, null); + myUseCompiled9Patches = useCompiled9Patches; + } + + /** + * Creates an Android framework resource repository. + * + * @param resourceDirectoryOrFile the res directory or a jar file containing resources of the Android framework + * @param languagesToLoad the set of ISO 639 language codes, or null to load all available languages + * @param cachingData data used to validate and create a persistent cache file + * @param useCompiled9Patches whether to provide the compiled or non-compiled version of the framework 9-patches + * @return the created resource repository + */ + @NotNull + public static FrameworkResourceRepository create(@NotNull Path resourceDirectoryOrFile, @Nullable Set languagesToLoad, + @Nullable CachingData cachingData, boolean useCompiled9Patches) { + long start = LOG.isDebugEnabled() ? System.currentTimeMillis() : 0; + Set languageGroups = languagesToLoad == null ? null : getLanguageGroups(languagesToLoad); + + Loader loader = new Loader(resourceDirectoryOrFile, languageGroups); + FrameworkResourceRepository repository = new FrameworkResourceRepository(loader, useCompiled9Patches); + + repository.load(null, cachingData, loader, languageGroups, loader.myLoadedLanguageGroups); + + if (LOG.isDebugEnabled()) { + String source = repository.getNumberOfLanguageGroupsLoadedFromOrigin() == 0 ? + "cache" : + repository.myNumberOfLanguageGroupsLoadedFromCache == 0 ? + resourceDirectoryOrFile.toString() : + "cache and " + resourceDirectoryOrFile; + LOG.debug("Loaded from " + source + " with " + (repository.myLanguageGroups.size() - 1) + " languages in " + + (System.currentTimeMillis() - start) / 1000. + " sec"); + } + return repository; + } + + /** + * Checks if the repository contains resources for the given set of languages. + * + * @param languages the set of ISO 639 language codes to check + * @return true if the repository contains resources for all requested languages + */ + public boolean containsLanguages(@NotNull Set languages) { + for (String language : languages) { + if (!myLanguageGroups.contains(getLanguageGroup(language))) { + return false; + } + } + return true; + } + + /** + * Loads resources for requested languages that are not present in this resource repository. + * + * @param languagesToLoad the set of ISO 639 language codes, or null to load all available languages + * @param cachingData data used to validate and create a persistent cache file + * @return the new resource repository with additional resources, or this resource repository if it already contained + * all requested languages + */ + @NotNull + public FrameworkResourceRepository loadMissingLanguages(@Nullable Set languagesToLoad, @Nullable CachingData cachingData) { + @Nullable Set languageGroups = languagesToLoad == null ? null : getLanguageGroups(languagesToLoad); + if (languageGroups != null && myLanguageGroups.containsAll(languageGroups)) { + return this; // The repository already contains all requested languages. + } + + long start = LOG.isDebugEnabled() ? System.currentTimeMillis() : 0; + Loader loader = new Loader(this, languageGroups); + FrameworkResourceRepository newRepository = new FrameworkResourceRepository(loader, myUseCompiled9Patches); + + newRepository.load(this, cachingData, loader, languageGroups, loader.myLoadedLanguageGroups); + + if (LOG.isDebugEnabled()) { + String source = newRepository.getNumberOfLanguageGroupsLoadedFromOrigin() == getNumberOfLanguageGroupsLoadedFromOrigin() ? + "cache" : + newRepository.myNumberOfLanguageGroupsLoadedFromCache == myNumberOfLanguageGroupsLoadedFromCache ? + myResourceDirectoryOrFile.toString() : + "cache and " + myResourceDirectoryOrFile; + LOG.debug("Loaded " + (newRepository.myLanguageGroups.size() - myLanguageGroups.size()) + " additional languages from " + source + + " in " + (System.currentTimeMillis() - start) / 1000. + " sec"); + } + return newRepository; + } + + private void load(@Nullable FrameworkResourceRepository sourceRepository, + @Nullable CachingData cachingData, + @NotNull Loader loader, + @Nullable Set languageGroups, + @NotNull Set languageGroupsLoadedFromSourceRepositoryOrCache) { + Map stringCache = Maps.newHashMapWithExpectedSize(10000); + Map namespaceResolverCache = new HashMap<>(); + Set configurationsToTakeOver = + sourceRepository == null ? ImmutableSet.of() : copyFromRepository(sourceRepository, stringCache, namespaceResolverCache); + + // If not loading from a jar file, try to load from a cache file first. A separate cache file is not used + // when loading from framework_res.jar since it already contains data in the cache format. Loading from + // framework_res.jar or a cache file is significantly faster than reading individual resource files. + if (!loader.isLoadingFromZipArchive() && cachingData != null) { + loadFromPersistentCache(cachingData, languageGroups, languageGroupsLoadedFromSourceRepositoryOrCache, stringCache, + namespaceResolverCache); + } + + myLanguageGroups.addAll(languageGroupsLoadedFromSourceRepositoryOrCache); + if (languageGroups == null || !languageGroupsLoadedFromSourceRepositoryOrCache.containsAll(languageGroups)) { + loader.loadRepositoryContents(this); + } + + myLoadedFromCache = myNumberOfLanguageGroupsLoadedFromCache == myLanguageGroups.size(); + + populatePublicResourcesMap(); + freezeResources(); + takeOverConfigurations(configurationsToTakeOver); + + if (!loader.isLoadingFromZipArchive() && cachingData != null) { + Executor executor = cachingData.getCacheCreationExecutor(); + if (executor != null && !languageGroupsLoadedFromSourceRepositoryOrCache.containsAll(myLanguageGroups)) { + executor.execute(() -> createPersistentCache(cachingData, languageGroupsLoadedFromSourceRepositoryOrCache)); + } + } + } + + @Override + @Nullable + public String getPackageName() { + return ANDROID_NAMESPACE.getPackageName(); + } + + @Override + @NotNull + public Set getResourceTypes(@NotNull ResourceNamespace namespace) { + return namespace == ANDROID_NAMESPACE ? Sets.immutableEnumSet(myResources.keySet()) : ImmutableSet.of(); + } + + /** + * Copies resources from another FrameworkResourceRepository. + * + * @param sourceRepository the repository to copy resources from + * @param stringCache the string cache to populate with the names of copied resources + * @param namespaceResolverCache the namespace resolver cache to populate with namespace resolvers referenced by the copied resources + * @return the {@link RepositoryConfiguration} objects referenced by the copied resources + */ + @NotNull + private Set copyFromRepository(@NotNull FrameworkResourceRepository sourceRepository, + @NotNull Map stringCache, + @NotNull Map namespaceResolverCache) { + Collection> resourceMaps = sourceRepository.myResources.values(); + + // Copy resources from the source repository, get AarConfigurations that need to be taken over by this repository, + // and pre-populate string and namespace resolver caches. + Set sourceConfigurations = Sets.newIdentityHashSet(); + for (ListMultimap resourceMap : resourceMaps) { + for (ResourceItem item : resourceMap.values()) { + addResourceItem(item); + + sourceConfigurations.add(((BasicResourceItemBase)item).getRepositoryConfiguration()); + if (item instanceof BasicValueResourceItemBase) { + ResourceNamespace.Resolver resolver = ((BasicValueResourceItemBase)item).getNamespaceResolver(); + NamespaceResolver namespaceResolver = + resolver == ResourceNamespace.Resolver.EMPTY_RESOLVER ? NamespaceResolver.EMPTY : (NamespaceResolver)resolver; + namespaceResolverCache.put(namespaceResolver, namespaceResolver); + } + String name = item.getName(); + stringCache.put(name, name); + } + } + + myNumberOfLanguageGroupsLoadedFromCache += sourceRepository.myNumberOfLanguageGroupsLoadedFromCache; + return sourceConfigurations; + } + + private void loadFromPersistentCache(@NotNull CachingData cachingData, @Nullable Set languagesToLoad, + @NotNull Set loadedLanguages, + @NotNull Map stringCache, + @Nullable Map namespaceResolverCache) { + CacheFileNameGenerator fileNameGenerator = new CacheFileNameGenerator((cachingData)); + Set languages = languagesToLoad == null ? fileNameGenerator.getAllCacheFileLanguages() : languagesToLoad; + + for (String language : languages) { + if (!loadedLanguages.contains(language)) { + Path cacheFile = fileNameGenerator.getCacheFile(language); + try (Base128InputStream stream = new Base128InputStream(cacheFile)) { + byte[] header = ResourceSerializationUtil.getCacheFileHeader(s -> writeCacheHeaderContent(cachingData, language, s)); + if (!stream.validateContents(header)) { + // Cache file header doesn't match. + if (language.isEmpty()) { + break; // Don't try to load language-specific resources if language-neutral ones could not be loaded. + } + continue; + } + loadFromStream(stream, stringCache, namespaceResolverCache); + loadedLanguages.add(language); + myNumberOfLanguageGroupsLoadedFromCache++; + } + catch (NoSuchFileException e) { + // Cache file does not exist. + if (language.isEmpty()) { + break; // Don't try to load language-specific resources if language-neutral ones could not be loaded. + } + } + catch (ProcessCanceledException e) { + cleanupAfterFailedLoadingFromCache(); + loadedLanguages.clear(); + throw e; + } + catch (Throwable e) { + cleanupAfterFailedLoadingFromCache(); + loadedLanguages.clear(); + LOG.warn("Failed to load from cache file " + cacheFile.toString(), e); + break; + } + } + } + } + + @Override + protected void cleanupAfterFailedLoadingFromCache() { + super.cleanupAfterFailedLoadingFromCache(); + myNumberOfLanguageGroupsLoadedFromCache = 0; + } + + private void createPersistentCache(@NotNull CachingData cachingData, @NotNull Set languagesToSkip) { + CacheFileNameGenerator fileNameGenerator = new CacheFileNameGenerator(cachingData); + for (String language : myLanguageGroups) { + if (!languagesToSkip.contains(language)) { + Path cacheFile = fileNameGenerator.getCacheFile(language); + byte[] header = ResourceSerializationUtil.getCacheFileHeader(stream -> writeCacheHeaderContent(cachingData, language, stream)); + ResourceSerializationUtil.createPersistentCache( + cacheFile, header, stream -> writeToStream(stream, config -> language.equals(getLanguageGroup(config)))); + } + } + } + + private void writeCacheHeaderContent(@NotNull CachingData cachingData, @NotNull String language, @NotNull Base128OutputStream stream) + throws IOException { + writeCacheHeaderContent(cachingData, stream); + stream.writeString(language); + } + + /** + * Returns the name of the resource table file containing resources for the given language. + * + * @param language the two-letter language abbreviation, or an empty string for language-neutral resources + * @return the file name + */ + static String getResourceTableNameForLanguage(@NotNull String language) { + return language.isEmpty() ? "resources.bin" : RESOURCES_TABLE_PREFIX + language + RESOURCE_TABLE_SUFFIX; + } + + @NotNull + static String getLanguageGroup(@NotNull FolderConfiguration config) { + LocaleQualifier locale = config.getLocaleQualifier(); + return locale == null ? "" : getLanguageGroup(StringUtil.notNullize(locale.getLanguage())); + } + + /** + * Maps some languages to others effectively grouping languages together. For example, Romansh language + * that has very few framework resources is grouped together with Italian. + * + * @param language the original language + * @return the language representing the corresponding group of languages + */ + @NotNull + private static String getLanguageGroup(@NotNull String language) { + return LANGUAGE_TO_GROUP.getOrDefault(language, language); + } + + @NotNull + private static Set getLanguageGroups(@NotNull Set languages) { + Set result = new TreeSet<>(); + result.add(""); + for (String language : languages) { + result.add(getLanguageGroup(language)); + } + return result; + } + + @NotNull + Set getLanguageGroups() { + Set languages = new TreeSet<>(); + + for (ListMultimap resourceMap : myResources.values()) { + for (ResourceItem item : resourceMap.values()) { + FolderConfiguration config = item.getConfiguration(); + languages.add(getLanguageGroup(config)); + } + } + + return languages; + } + + private int getNumberOfLanguageGroupsLoadedFromOrigin() { + return myLanguageGroups.size() - myNumberOfLanguageGroupsLoadedFromCache; + } + + @TestOnly + int getNumberOfLanguageGroupsLoadedFromCache() { + return myNumberOfLanguageGroupsLoadedFromCache; + } + + @NotNull + private String updateResourcePath(@NotNull String relativeResourcePath) { + if (myUseCompiled9Patches && relativeResourcePath.endsWith(DOT_9PNG)) { + return StringUtil.replaceSubstring(relativeResourcePath, + TextRange.create(relativeResourcePath.length() - DOT_9PNG.length(), relativeResourcePath.length()), + COMPILED_9PNG_EXTENSION); + } + return relativeResourcePath; + } + + @Override + @NotNull + public String getResourceUrl(@NotNull String relativeResourcePath) { + return super.getResourceUrl(updateResourcePath(relativeResourcePath)); + } + + @Override + @NotNull + public PathString getSourceFile(@NotNull String relativeResourcePath, boolean forFileResource) { + return super.getSourceFile(updateResourcePath(relativeResourcePath), forFileResource); + } + + private static class Loader extends RepositoryLoader { + @NotNull private final List myPublicFileNames = ImmutableList.of("public.xml", "public-final.xml", "public-staging.xml"); + @NotNull private final Set myLoadedLanguageGroups; + @Nullable private Set myLanguageGroups; + + Loader(@NotNull Path resourceDirectoryOrFile, @Nullable Set languageGroups) { + super(resourceDirectoryOrFile, null, ANDROID_NAMESPACE); + myLanguageGroups = languageGroups; + myLoadedLanguageGroups = new TreeSet<>(); + } + + Loader(@NotNull FrameworkResourceRepository sourceRepository, @Nullable Set languageGroups) { + super(sourceRepository.myResourceDirectoryOrFile, null, ANDROID_NAMESPACE); + myLanguageGroups = languageGroups; + myLoadedLanguageGroups = new TreeSet<>(sourceRepository.myLanguageGroups); + } + + public List getPublicXmlFileNames() { + return myPublicFileNames; + } + + @Override + protected void loadFromZip(@NotNull FrameworkResourceRepository repository) { + try (ZipFile zipFile = new ZipFile(myResourceDirectoryOrFile.toFile())) { + if (myLanguageGroups == null) { + myLanguageGroups = readLanguageGroups(zipFile); + } + + Map stringCache = Maps.newHashMapWithExpectedSize(10000); + Map namespaceResolverCache = new HashMap<>(); + + for (String language : myLanguageGroups) { + if (!myLoadedLanguageGroups.contains(language)) { + String entryName = getResourceTableNameForLanguage(language); + ZipEntry zipEntry = zipFile.getEntry(entryName); + if (zipEntry == null) { + if (language.isEmpty()) { + throw new IOException("\"" + entryName + "\" not found in " + myResourceDirectoryOrFile.toString()); + } + else { + continue; // Requested language may not be represented in the Android framework resources. + } + } + + try (Base128InputStream stream = new Base128InputStream(zipFile.getInputStream(zipEntry))) { + repository.loadFromStream(stream, stringCache, namespaceResolverCache); + } + } + } + + repository.populatePublicResourcesMap(); + repository.freezeResources(); + } + catch (ProcessCanceledException e) { + throw e; + } + catch (Exception e) { + LOG.error("Failed to load resources from " + myResourceDirectoryOrFile.toString(), e); + } + } + + @NotNull + private static Set readLanguageGroups(@NotNull ZipFile zipFile) { + ImmutableSortedSet.Builder result = ImmutableSortedSet.naturalOrder(); + result.add(""); + zipFile.stream().forEach(entry -> { + String name = entry.getName(); + if (name.startsWith(RESOURCES_TABLE_PREFIX) && name.endsWith(RESOURCE_TABLE_SUFFIX) && + name.length() == RESOURCES_TABLE_PREFIX.length() + RESOURCE_TABLE_SUFFIX.length() + 2 && + Character.isLetter(name.charAt(RESOURCES_TABLE_PREFIX.length())) && + Character.isLetter(name.charAt(RESOURCES_TABLE_PREFIX.length() + 1))) { + result.add(name.substring(RESOURCES_TABLE_PREFIX.length(), RESOURCES_TABLE_PREFIX.length() + 2)); + } + }); + return result.build(); + } + + @Override + public void loadRepositoryContents(@NotNull FrameworkResourceRepository repository) { + super.loadRepositoryContents(repository); + + Set languageGroups = myLanguageGroups == null ? repository.getLanguageGroups() : myLanguageGroups; + repository.myLanguageGroups.addAll(languageGroups); + } + + @Override + public boolean isIgnored(@NotNull Path fileOrDirectory, @NotNull BasicFileAttributes attrs) { + if (fileOrDirectory.equals(myResourceDirectoryOrFile)) { + return false; + } + + if (super.isIgnored(fileOrDirectory, attrs)) { + return true; + } + + String fileName = fileOrDirectory.getFileName().toString(); + if (attrs.isDirectory()) { + if (fileName.startsWith("values-mcc") || + fileName.startsWith(FD_RES_RAW) && (fileName.length() == FD_RES_RAW.length() || fileName.charAt(FD_RES_RAW.length()) == '-')) { + return true; // Mobile country codes and raw resources are not used by LayoutLib. + } + + // Skip folders that don't belong to languages in myLanguageGroups or languages that were loaded earlier. + if (myLanguageGroups != null || !myLoadedLanguageGroups.isEmpty()) { + FolderConfiguration config = FolderConfiguration.getConfigForFolder(fileName); + if (config == null) { + return true; + } + String language = getLanguageGroup(config); + if ((myLanguageGroups != null && !myLanguageGroups.contains(language)) || myLoadedLanguageGroups.contains(language)) { + return true; + } + myFolderConfigCache.put(config.getQualifierString(), config); + } + } + else if ((myPublicFileNames.contains(fileName) || fileName.equals("symbols.xml")) && + "values".equals(new PathString(fileOrDirectory).getParentFileName())) { + return true; // Skip files that don't contain resources. + } + else if (fileName.endsWith(COMPILED_9PNG_EXTENSION)) { + return true; + } + + return false; + } + + @Override + protected final void addResourceItem(@NotNull BasicResourceItem item, @NotNull FrameworkResourceRepository repository) { + repository.addResourceItem(item); + } + + @Override + @NotNull + protected String getKeyForVisibilityLookup(@NotNull String resourceName) { + // This class obtains names of public resources from public.xml where all resource names are preserved + // in their original form. This is different from the superclass that obtains the names from public.txt + // where the names are transformed by replacing dots, colons and dashes with underscores. + return resourceName; + } + } + + /** + * Redirects the {@link RepositoryConfiguration} inherited from another repository to point to this one, so that + * the other repository can be garbage collected. This has to be done after this repository is fully loaded. + * + * @param sourceConfigurations the configurations to reparent + */ + private void takeOverConfigurations(@NotNull Set sourceConfigurations) { + for (RepositoryConfiguration configuration : sourceConfigurations) { + configuration.transferOwnershipTo(this); + } + } + + private static class CacheFileNameGenerator { + private final Path myLanguageNeutralFile; + private final String myPrefix; + private final String mySuffix; + + CacheFileNameGenerator(@NotNull CachingData cachingData) { + myLanguageNeutralFile = cachingData.getCacheFile(); + String fileName = myLanguageNeutralFile.getFileName().toString(); + int dotPos = fileName.lastIndexOf('.'); + myPrefix = dotPos >= 0 ? fileName.substring(0, dotPos) : fileName; + mySuffix = dotPos >= 0 ? fileName.substring(dotPos) : ""; + } + + @NotNull + Path getCacheFile(@NotNull String language) { + return language.isEmpty() ? myLanguageNeutralFile : myLanguageNeutralFile.resolveSibling(myPrefix + '_' + language + mySuffix); + } + + /** + * Determines language from a cache file name. + * + * @param cacheFileName the name of a cache file + * @return the language of resources contained in the cache file, or null if {@code cacheFileName} + * doesn't match the pattern of cache file names. + */ + @Nullable + String getLanguage(@NotNull String cacheFileName) { + if (!cacheFileName.startsWith(myPrefix) || !cacheFileName.endsWith(mySuffix)) { + return null; + } + int baseLength = myPrefix.length() + mySuffix.length(); + if (cacheFileName.length() == baseLength) { + return ""; + } + if (cacheFileName.length() != baseLength + 3 || cacheFileName.charAt(myPrefix.length()) != '_') { + return null; + } + String language = cacheFileName.substring(myPrefix.length() + 1, myPrefix.length() + 3); + if (!isLowerCaseLatinLetter(language.charAt(0)) || !isLowerCaseLatinLetter(language.charAt(1))) { + return null; + } + return language; + } + + @NotNull + public Set getAllCacheFileLanguages() { + Set result = new TreeSet<>(); + try (Stream stream = CancellableFileIo.list(myLanguageNeutralFile.getParent())) { + stream.forEach(file -> { + String language = getLanguage(file.getFileName().toString()); + if (language != null) { + result.add(language); + } + }); + } + catch (IOException ignore) { + } + return result; + } + + private static boolean isLowerCaseLatinLetter(char c) { + return 'a' <= c && c <= 'z'; + } + } +} diff --git a/src/main/java/com/android/resources/aar/ProtoConfigurationDecoder.java b/src/main/java/com/android/resources/aar/ProtoConfigurationDecoder.java new file mode 100644 index 0000000..4763216 --- /dev/null +++ b/src/main/java/com/android/resources/aar/ProtoConfigurationDecoder.java @@ -0,0 +1,381 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.resources.aar; + +import com.android.aapt.ConfigurationOuterClass.Configuration; +import com.android.ide.common.resources.configuration.CountryCodeQualifier; +import com.android.ide.common.resources.configuration.DensityQualifier; +import com.android.ide.common.resources.configuration.FolderConfiguration; +import com.android.ide.common.resources.configuration.HighDynamicRangeQualifier; +import com.android.ide.common.resources.configuration.KeyboardStateQualifier; +import com.android.ide.common.resources.configuration.LayoutDirectionQualifier; +import com.android.ide.common.resources.configuration.LocaleQualifier; +import com.android.ide.common.resources.configuration.NavigationMethodQualifier; +import com.android.ide.common.resources.configuration.NavigationStateQualifier; +import com.android.ide.common.resources.configuration.NetworkCodeQualifier; +import com.android.ide.common.resources.configuration.NightModeQualifier; +import com.android.ide.common.resources.configuration.ScreenHeightQualifier; +import com.android.ide.common.resources.configuration.ScreenOrientationQualifier; +import com.android.ide.common.resources.configuration.ScreenRatioQualifier; +import com.android.ide.common.resources.configuration.ScreenRoundQualifier; +import com.android.ide.common.resources.configuration.ScreenSizeQualifier; +import com.android.ide.common.resources.configuration.ScreenWidthQualifier; +import com.android.ide.common.resources.configuration.SmallestScreenWidthQualifier; +import com.android.ide.common.resources.configuration.TextInputMethodQualifier; +import com.android.ide.common.resources.configuration.TouchScreenQualifier; +import com.android.ide.common.resources.configuration.UiModeQualifier; +import com.android.ide.common.resources.configuration.VersionQualifier; +import com.android.ide.common.resources.configuration.WideGamutColorQualifier; +import com.android.resources.HighDynamicRange; +import com.android.resources.Keyboard; +import com.android.resources.KeyboardState; +import com.android.resources.LayoutDirection; +import com.android.resources.Navigation; +import com.android.resources.NavigationState; +import com.android.resources.NightMode; +import com.android.resources.ScreenOrientation; +import com.android.resources.ScreenRatio; +import com.android.resources.ScreenRound; +import com.android.resources.ScreenSize; +import com.android.resources.TouchScreen; +import com.android.resources.UiMode; +import com.android.resources.WideGamutColor; +import io.johnsonlee.playground.util.DensityKt; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Converts {@code aapt.pb.Configuration} proto message into a {@link FolderConfiguration} object. + */ +class ProtoConfigurationDecoder { + @NotNull + static FolderConfiguration getConfiguration(@NotNull Configuration configMsg) { + FolderConfiguration configuration = new FolderConfiguration(); + + int mcc = configMsg.getMcc(); + if (mcc != 0) { + configuration.setCountryCodeQualifier(new CountryCodeQualifier(mcc)); + } + + int mnc = configMsg.getMnc(); + if (mnc != 0) { + configuration.setNetworkCodeQualifier(new NetworkCodeQualifier(mnc)); + } + + String locale = configMsg.getLocale(); + if (!locale.isEmpty()) { + LocaleQualifier qualifier = LocaleQualifier.getQualifier(locale); + if (qualifier == null) { + locale = "b+" + locale.replace('-', '+'); + qualifier = LocaleQualifier.getQualifier(locale); + } + configuration.setLocaleQualifier(qualifier); + } + + LayoutDirection layoutDirection = getLayoutDirection(configMsg.getLayoutDirection()); + if (layoutDirection != null) { + configuration.setLayoutDirectionQualifier(new LayoutDirectionQualifier(layoutDirection)); + } + + int screenWidthDp = configMsg.getScreenWidthDp(); + if (screenWidthDp != 0) { + configuration.setScreenWidthQualifier(new ScreenWidthQualifier(screenWidthDp)); + } + + int screenHeightDp = configMsg.getScreenHeightDp(); + if (screenHeightDp != 0) { + configuration.setScreenHeightQualifier(new ScreenHeightQualifier(screenHeightDp)); + } + + int smallestScreenWidthDp = configMsg.getSmallestScreenWidthDp(); + if (smallestScreenWidthDp != 0) { + configuration.setSmallestScreenWidthQualifier(new SmallestScreenWidthQualifier(smallestScreenWidthDp)); + } + + ScreenSize screenSize = getScreenSize(configMsg.getScreenLayoutSize()); + if (screenSize != null) { + configuration.setScreenSizeQualifier(new ScreenSizeQualifier(screenSize)); + } + + ScreenRatio screenRatio = getScreenRatio(configMsg.getScreenLayoutLong()); + if (screenRatio != null) { + configuration.setScreenRatioQualifier(new ScreenRatioQualifier(screenRatio)); + } + + ScreenRound screenRound = getScreenRound(configMsg.getScreenRound()); + if (screenRound != null) { + configuration.setScreenRoundQualifier(new ScreenRoundQualifier(screenRound)); + } + + WideGamutColor wideGamutColor = getWideGamutColor(configMsg.getWideColorGamut()); + if (wideGamutColor != null) { + configuration.setWideColorGamutQualifier(new WideGamutColorQualifier(wideGamutColor)); + } + + HighDynamicRange highDynamicRange = getHighDynamicRange(configMsg.getHdr()); + if (highDynamicRange != null) { + configuration.setHighDynamicRangeQualifier(new HighDynamicRangeQualifier(highDynamicRange)); + } + + ScreenOrientation screenOrientation = getScreenOrientation(configMsg.getOrientation()); + if (screenOrientation != null) { + configuration.setScreenOrientationQualifier(new ScreenOrientationQualifier(screenOrientation)); + } + + UiMode uiMode = getUiMode(configMsg.getUiModeType()); + if (uiMode != null) { + configuration.setUiModeQualifier(new UiModeQualifier(uiMode)); + } + + NightMode nightMode = getNightMode(configMsg.getUiModeNight()); + if (nightMode != null) { + configuration.setNightModeQualifier(new NightModeQualifier(nightMode)); + } + + int densityDpi = configMsg.getDensity(); + if (densityDpi != 0) { + configuration.setDensityQualifier(new DensityQualifier(DensityKt.create(densityDpi))); + } + + TouchScreen touchScreen = getTouchScreen(configMsg.getTouchscreen()); + if (touchScreen != null) { + configuration.setTouchTypeQualifier(new TouchScreenQualifier(touchScreen)); + } + + KeyboardState keyboardState = getKeyboardState(configMsg.getKeysHidden()); + if (keyboardState != null) { + configuration.setKeyboardStateQualifier(new KeyboardStateQualifier(keyboardState)); + } + + Keyboard keyboard = getKeyboard(configMsg.getKeyboard()); + if (keyboard != null) { + configuration.setTextInputMethodQualifier(new TextInputMethodQualifier(keyboard)); + } + + NavigationState navigationState = getNavigationState(configMsg.getNavHidden()); + if (navigationState != null) { + configuration.setNavigationStateQualifier(new NavigationStateQualifier(navigationState)); + } + + Navigation navigation = getNavigation(configMsg.getNavigation()); + if (navigation != null) { + configuration.setNavigationMethodQualifier(new NavigationMethodQualifier(navigation)); + } + + int sdkVersion = configMsg.getSdkVersion(); + if (sdkVersion != 0) { + configuration.setVersionQualifier(new VersionQualifier(sdkVersion)); + } + return configuration; + } + + @Nullable + private static LayoutDirection getLayoutDirection(@NotNull Configuration.LayoutDirection protoValue) { + switch (protoValue) { + case LAYOUT_DIRECTION_LTR: + return LayoutDirection.LTR; + case LAYOUT_DIRECTION_RTL: + return LayoutDirection.RTL; + default: + return null; + } + } + + @Nullable + private static ScreenSize getScreenSize(@NotNull Configuration.ScreenLayoutSize protoValue) { + switch (protoValue) { + case SCREEN_LAYOUT_SIZE_SMALL: + return ScreenSize.SMALL; + case SCREEN_LAYOUT_SIZE_NORMAL: + return ScreenSize.NORMAL; + case SCREEN_LAYOUT_SIZE_LARGE: + return ScreenSize.LARGE; + case SCREEN_LAYOUT_SIZE_XLARGE: + return ScreenSize.XLARGE; + default: + return null; + } + } + + @Nullable + private static ScreenRatio getScreenRatio(@NotNull Configuration.ScreenLayoutLong protoValue) { + switch (protoValue) { + case SCREEN_LAYOUT_LONG_NOTLONG: + return ScreenRatio.NOTLONG; + case SCREEN_LAYOUT_LONG_LONG: + return ScreenRatio.LONG; + default: + return null; + } + } + + @Nullable + private static ScreenRound getScreenRound(@NotNull Configuration.ScreenRound protoValue) { + switch (protoValue) { + case SCREEN_ROUND_NOTROUND: + return ScreenRound.NOTROUND; + case SCREEN_ROUND_ROUND: + return ScreenRound.ROUND; + default: + return null; + } + } + + @Nullable + private static WideGamutColor getWideGamutColor(@NotNull Configuration.WideColorGamut protoValue) { + switch (protoValue) { + case WIDE_COLOR_GAMUT_WIDECG: + return WideGamutColor.WIDECG; + case WIDE_COLOR_GAMUT_NOWIDECG: + return WideGamutColor.NOWIDECG; + default: + return null; + } + } + + @Nullable + private static HighDynamicRange getHighDynamicRange(@NotNull Configuration.Hdr protoValue) { + switch (protoValue) { + case HDR_HIGHDR: + return HighDynamicRange.HIGHDR; + case HDR_LOWDR: + return HighDynamicRange.LOWDR; + default: + return null; + } + } + + @Nullable + private static ScreenOrientation getScreenOrientation(@NotNull Configuration.Orientation protoValue) { + switch (protoValue) { + case ORIENTATION_PORT: + return ScreenOrientation.PORTRAIT; + case ORIENTATION_LAND: + return ScreenOrientation.LANDSCAPE; + case ORIENTATION_SQUARE: + return ScreenOrientation.SQUARE; + default: + return null; + } + } + + @Nullable + private static UiMode getUiMode(@NotNull Configuration.UiModeType protoValue) { + switch (protoValue) { + case UI_MODE_TYPE_NORMAL: + return UiMode.NORMAL; + case UI_MODE_TYPE_CAR: + return UiMode.CAR; + case UI_MODE_TYPE_DESK: + return UiMode.DESK; + case UI_MODE_TYPE_TELEVISION: + return UiMode.TELEVISION; + case UI_MODE_TYPE_APPLIANCE: + return UiMode.APPLIANCE; + case UI_MODE_TYPE_WATCH: + return UiMode.WATCH; + case UI_MODE_TYPE_VRHEADSET: + return UiMode.VR_HEADSET; + default: + return null; + } + } + + @Nullable + private static NightMode getNightMode(@NotNull Configuration.UiModeNight protoValue) { + switch (protoValue) { + case UI_MODE_NIGHT_NOTNIGHT: + return NightMode.NOTNIGHT; + case UI_MODE_NIGHT_NIGHT: + return NightMode.NIGHT; + default: + return null; + } + } + + @Nullable + private static TouchScreen getTouchScreen(@NotNull Configuration.Touchscreen protoValue) { + switch (protoValue) { + case TOUCHSCREEN_NOTOUCH: + return TouchScreen.NOTOUCH; + case TOUCHSCREEN_STYLUS: + return TouchScreen.STYLUS; + case TOUCHSCREEN_FINGER: + return TouchScreen.FINGER; + default: + return null; + } + } + + @Nullable + private static KeyboardState getKeyboardState(@NotNull Configuration.KeysHidden protoValue) { + switch (protoValue) { + case KEYS_HIDDEN_KEYSEXPOSED: + return KeyboardState.EXPOSED; + case KEYS_HIDDEN_KEYSHIDDEN: + return KeyboardState.HIDDEN; + case KEYS_HIDDEN_KEYSSOFT: + return KeyboardState.SOFT; + default: + return null; + } + } + + @Nullable + private static Keyboard getKeyboard(@NotNull Configuration.Keyboard protoValue) { + switch (protoValue) { + case KEYBOARD_NOKEYS: + return Keyboard.NOKEY; + case KEYBOARD_QWERTY: + return Keyboard.QWERTY; + case KEYBOARD_TWELVEKEY: + return Keyboard.TWELVEKEY; + default: + return null; + } + } + + @Nullable + private static NavigationState getNavigationState(@NotNull Configuration.NavHidden protoValue) { + switch (protoValue) { + case NAV_HIDDEN_NAVEXPOSED: + return NavigationState.EXPOSED; + case NAV_HIDDEN_NAVHIDDEN: + return NavigationState.HIDDEN; + default: + return null; + } + } + + @Nullable + private static Navigation getNavigation(@NotNull Configuration.Navigation protoValue) { + switch (protoValue) { + case NAVIGATION_NONAV: + return Navigation.NONAV; + case NAVIGATION_DPAD: + return Navigation.DPAD; + case NAVIGATION_TRACKBALL: + return Navigation.TRACKBALL; + case NAVIGATION_WHEEL: + return Navigation.WHEEL; + default: + return null; + } + } + + /** Do not instantiate. All methods are static. */ + private ProtoConfigurationDecoder() {} +} diff --git a/src/main/java/com/android/resources/aar/ProtoStyledStringDecoder.java b/src/main/java/com/android/resources/aar/ProtoStyledStringDecoder.java new file mode 100644 index 0000000..479e482 --- /dev/null +++ b/src/main/java/com/android/resources/aar/ProtoStyledStringDecoder.java @@ -0,0 +1,105 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.resources.aar; + +import com.android.aapt.Resources; +import com.android.utils.XmlUtils; +import org.jetbrains.annotations.NotNull; + +import java.util.ArrayList; +import java.util.List; + +/** + * Static methods for converting {@link Resources.StyledString} proto message back to the original XML string. + */ +class ProtoStyledStringDecoder { + /** + * Decodes the given {@link Resources.StyledString} proto message to obtain the original XML string. + * + * @param styledStringMsg the proto message to decode + * @return the original XML string + */ + @NotNull + public static String getRawXmlValue(@NotNull Resources.StyledString styledStringMsg) { + String text = styledStringMsg.getValue(); + StringBuilder xmlValue = new StringBuilder(text.length() * 2); + List spanList = styledStringMsg.getSpanList(); + List spanStack = new ArrayList<>(spanList.size()); + + int offset = 0; + for (int i = 0; i <= styledStringMsg.getSpanCount(); i++) { + int oldOffset = offset; + Resources.StyledString.Span spanMsg; + if (i < styledStringMsg.getSpanCount()) { + spanMsg = styledStringMsg.getSpan(i); + offset = spanMsg.getFirstChar(); + } else { + spanMsg = null; + offset = text.length(); + } + // Check if there are any tags that need to be closed. + while (!spanStack.isEmpty() && spanStack.get(spanStack.size() - 1).getLastChar() < offset) { + Resources.StyledString.Span span = spanStack.remove(spanStack.size() - 1); + int spanEnd = span.getLastChar() + 1; + if (spanEnd > oldOffset) { + XmlUtils.appendXmlTextValue(xmlValue, text, oldOffset, spanEnd); + oldOffset = spanEnd; + } + String tagText = span.getTag(); + int tagEnd = indexOfOrEnd(tagText, ';', 0); + // Write the closing tag. + xmlValue.append("'); + } + if (offset >= oldOffset) { + // Copy text between tags. + XmlUtils.appendXmlTextValue(xmlValue, text, oldOffset, offset); + // Start a new tag. + if (spanMsg != null) { + String tagText = spanMsg.getTag(); + int pos = indexOfOrEnd(tagText, ';', 0); + if (pos != 0) { + spanStack.add(spanMsg); + xmlValue.append('<').append(tagText, 0, pos); + while (pos < tagText.length()) { + pos++; + int nextPos = indexOfOrEnd(tagText, ';', pos); + int nameEnd = tagText.indexOf('=', pos); + if (nameEnd > pos && nameEnd < nextPos) { + xmlValue.append(' '); + xmlValue.append(tagText, pos, nameEnd + 1); + xmlValue.append('"'); + // Attribute values in the proto message are not escaped. Append with escaping. + XmlUtils.appendXmlAttributeValue(xmlValue, tagText, nameEnd + 1, nextPos); + xmlValue.append('"'); + } + pos = nextPos; + } + xmlValue.append('>'); + } + } + } + } + return xmlValue.toString(); + } + + private static int indexOfOrEnd(@NotNull String str, char ch, int fromIndex) { + int index = str.indexOf(ch, fromIndex); + return index >= 0 ? index : str.length(); + } + + /** Do not instantiate. All methods are static. */ + private ProtoStyledStringDecoder() {} +} diff --git a/src/main/java/com/android/resources/base/BasicArrayResourceItem.java b/src/main/java/com/android/resources/base/BasicArrayResourceItem.java new file mode 100644 index 0000000..4fce3a8 --- /dev/null +++ b/src/main/java/com/android/resources/base/BasicArrayResourceItem.java @@ -0,0 +1,125 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.resources.base; + +import com.android.ide.common.rendering.api.ArrayResourceValue; +import com.android.ide.common.rendering.api.ResourceNamespace; +import com.android.resources.ResourceType; +import com.android.resources.ResourceVisibility; +import com.android.utils.Base128InputStream; +import com.android.utils.Base128OutputStream; +import it.unimi.dsi.fastutil.objects.Object2IntMap; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Resource item representing an array resource. + */ +public final class BasicArrayResourceItem extends BasicValueResourceItemBase implements ArrayResourceValue { + @NotNull private final List myElements; + private final int myDefaultIndex; + + /** + * Initializes the resource. + * + * @param name the name of the resource + * @param sourceFile the source file containing definition of the resource + * @param visibility the visibility of the resource + * @param elements the elements or the array + * @param defaultIndex the default index for the {@link #getValue()} method + */ + public BasicArrayResourceItem(@NotNull String name, + @NotNull ResourceSourceFile sourceFile, + @NotNull ResourceVisibility visibility, + @NotNull List elements, + int defaultIndex) { + super(ResourceType.ARRAY, name, sourceFile, visibility); + myElements = elements; + assert elements.isEmpty() || defaultIndex < elements.size(); + myDefaultIndex = defaultIndex; + } + + @Override + public int getElementCount() { + return myElements.size(); + } + + @Override + @NotNull + public String getElement(int index) { + return myElements.get(index); + } + + @Override + public Iterator iterator() { + return myElements.iterator(); + } + + @Override + @Nullable + public String getValue() { + return myElements.isEmpty() ? null : myElements.get(myDefaultIndex); + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) return true; + if (!super.equals(obj)) return false; + BasicArrayResourceItem other = (BasicArrayResourceItem) obj; + return myElements.equals(other.myElements); + } + + @Override + public void serialize(@NotNull Base128OutputStream stream, + @NotNull Object2IntMap configIndexes, + @NotNull Object2IntMap sourceFileIndexes, + @NotNull Object2IntMap namespaceResolverIndexes) throws IOException { + super.serialize(stream, configIndexes, sourceFileIndexes, namespaceResolverIndexes); + stream.writeInt(myElements.size()); + for (String element : myElements) { + stream.writeString(element); + } + stream.writeInt(myDefaultIndex); + } + + /** + * Creates a BasicArrayResourceItem by reading its contents from the given stream. + */ + @NotNull + static BasicArrayResourceItem deserialize(@NotNull Base128InputStream stream, + @NotNull String name, + @NotNull ResourceVisibility visibility, + @NotNull ResourceSourceFile sourceFile, + @NotNull ResourceNamespace.Resolver resolver) throws IOException { + int n = stream.readInt(); + List elements = n == 0 ? Collections.emptyList() : new ArrayList<>(n); + for (int i = 0; i < n; i++) { + elements.add(stream.readString()); + } + int defaultIndex = stream.readInt(); + if (!elements.isEmpty() && defaultIndex >= elements.size()) { + throw Base128InputStream.StreamFormatException.invalidFormat(); + } + BasicArrayResourceItem item = new BasicArrayResourceItem(name, sourceFile, visibility, elements, defaultIndex); + item.setNamespaceResolver(resolver); + return item; + } +} diff --git a/src/main/java/com/android/resources/base/BasicAttrReference.java b/src/main/java/com/android/resources/base/BasicAttrReference.java new file mode 100644 index 0000000..73c2496 --- /dev/null +++ b/src/main/java/com/android/resources/base/BasicAttrReference.java @@ -0,0 +1,124 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.resources.base; + +import com.android.ide.common.rendering.api.AttrResourceValue; +import com.android.ide.common.rendering.api.AttributeFormat; +import com.android.ide.common.rendering.api.ResourceNamespace; +import com.android.resources.ResourceType; +import com.android.resources.ResourceVisibility; +import com.android.utils.Base128OutputStream; +import com.android.utils.HashCodes; +import it.unimi.dsi.fastutil.objects.Object2IntMap; +import java.io.IOException; +import java.util.Collections; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Resource value representing a reference to an attr resource, but potentially with its own description + * and group name. Unlike {@link BasicAttrResourceItem}, does not contain formats and enum or flag information. + */ +public final class BasicAttrReference extends BasicValueResourceItemBase implements AttrResourceValue { + @NotNull private final ResourceNamespace myNamespace; + @Nullable private final String myDescription; + @Nullable private final String myGroupName; + + /** + * Initializes the attr reference. + * + * @param namespace the namespace of the attr resource + * @param name the name of the resource + * @param sourceFile the source file containing definition of the resource + * @param description the description of the attr resource, if available + * @param groupName the name of the attr group, if available + */ + public BasicAttrReference(@NotNull ResourceNamespace namespace, + @NotNull String name, + @NotNull ResourceSourceFile sourceFile, + @NotNull ResourceVisibility visibility, + @Nullable String description, + @Nullable String groupName) { + super(ResourceType.ATTR, name, sourceFile, visibility); + myNamespace = namespace; + myDescription = description; + myGroupName = groupName; + } + + @Override + @NotNull + public ResourceNamespace getNamespace() { + return myNamespace; + } + + @Override + @NotNull + public final Set getFormats() { + return Collections.emptySet(); + } + + @Override + @NotNull + public final Map getAttributeValues() { + return Collections.emptyMap(); + } + + @Override + @Nullable + public final String getValueDescription(@NotNull String valueName) { + return null; + } + + @Override + @Nullable + public final String getDescription() { + return myDescription; + } + + @Override + @Nullable + public final String getGroupName() { + return myGroupName; + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) return true; + if (!super.equals(obj)) return false; + BasicAttrReference other = (BasicAttrReference) obj; + return myNamespace.equals(other.myNamespace) && + Objects.equals(myDescription, other.myDescription) && + Objects.equals(myGroupName, other.myGroupName); + } + + @Override + public int hashCode() { + // myGroupName is not included in hash code intentionally since it doesn't improve quality of hashing. + return HashCodes.mix(super.hashCode(), myNamespace.hashCode(), Objects.hashCode(myDescription)); + } + + @Override + public void serialize(@NotNull Base128OutputStream stream, + @NotNull Object2IntMap configIndexes, + @NotNull Object2IntMap sourceFileIndexes, + @NotNull Object2IntMap namespaceResolverIndexes) throws IOException { + super.serialize(stream, configIndexes, sourceFileIndexes, namespaceResolverIndexes); + BasicAttrResourceItem.serializeAttrValue(this, getRepository().getNamespace(), stream); + } +} diff --git a/src/main/java/com/android/resources/base/BasicAttrResourceItem.java b/src/main/java/com/android/resources/base/BasicAttrResourceItem.java new file mode 100644 index 0000000..437d218 --- /dev/null +++ b/src/main/java/com/android/resources/base/BasicAttrResourceItem.java @@ -0,0 +1,241 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.resources.base; + +import static com.android.SdkConstants.URI_DOMAIN_PREFIX; + +import com.android.ide.common.rendering.api.AttrResourceValue; +import com.android.ide.common.rendering.api.AttributeFormat; +import com.android.ide.common.rendering.api.ResourceNamespace; +import com.android.resources.ResourceType; +import com.android.resources.ResourceVisibility; +import com.android.utils.Base128InputStream; +import com.android.utils.Base128InputStream.StreamFormatException; +import com.android.utils.Base128OutputStream; +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.Maps; +import it.unimi.dsi.fastutil.objects.Object2IntMap; +import java.io.IOException; +import java.util.Collections; +import java.util.EnumSet; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Resource item representing an attr resource. + */ +public class BasicAttrResourceItem extends BasicValueResourceItemBase implements AttrResourceValue { + @NotNull private Set myFormats; + /** The keys are enum or flag names, the values are corresponding numeric values. */ + @NotNull private final Map myValueMap; + /** The keys are enum or flag names, the values are the value descriptions. */ + @NotNull private final Map myValueDescriptionMap; + @Nullable private final String myDescription; + @Nullable private final String myGroupName; + + /** + * Initializes the resource. + * + * @param name the name of the resource + * @param sourceFile the source file containing definition of the resource + * @param visibility the visibility of the resource + * @param description the description of the attr resource, if available + * @param groupName the name of the attr group, if available + * @param formats the allowed attribute formats + * @param valueMap the enum or flag integer values keyed by the value names. Some of the values in the + * map may be null. The map must contain the names of all declared values, even the ones that don't + * have corresponding numeric values. + * @param valueDescriptionMap the enum or flag value descriptions keyed by the value names + */ + public BasicAttrResourceItem(@NotNull String name, + @NotNull ResourceSourceFile sourceFile, + @NotNull ResourceVisibility visibility, + @Nullable String description, + @Nullable String groupName, + @NotNull Set formats, + @NotNull Map valueMap, + @NotNull Map valueDescriptionMap) { + super(ResourceType.ATTR, name, sourceFile, visibility); + myDescription = description; + myGroupName = groupName; + myFormats = ImmutableSet.copyOf(formats); + // Cannot use ImmutableMap.copyOf() since valueMap may contain null values. + myValueMap = valueMap.isEmpty() ? Collections.emptyMap() : Collections.unmodifiableMap(valueMap); + myValueDescriptionMap = valueDescriptionMap.isEmpty() ? Collections.emptyMap() : Collections.unmodifiableMap(valueDescriptionMap); + } + + @Override + @NotNull + public final Set getFormats() { + return myFormats; + } + + /** + * Replaces the set of the allowed attribute formats. Intended to be called only by the resource repository code. + * + * @param formats the new set of the allowed attribute formats + */ + public final void setFormats(@NotNull Set formats) { + myFormats = ImmutableSet.copyOf(formats); + } + + @Override + @NotNull + public final Map getAttributeValues() { + return myValueMap; + } + + @Override + @Nullable + public final String getValueDescription(@NotNull String valueName) { + return myValueDescriptionMap.get(valueName); + } + + @Override + @Nullable + public final String getDescription() { + return myDescription; + } + + @Override + @Nullable + public final String getGroupName() { + return myGroupName; + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) return true; + if (!super.equals(obj)) return false; + BasicAttrResourceItem other = (BasicAttrResourceItem) obj; + return Objects.equals(myDescription, other.myDescription) && + Objects.equals(myGroupName, other.myGroupName) && + myFormats.equals(other.myFormats) && + myValueMap.equals(other.myValueMap) && + myValueDescriptionMap.equals(other.myValueDescriptionMap); + } + + /** + * Creates and returns an {@link BasicAttrReference} pointing to this attribute. + */ + @NotNull + public BasicAttrReference createReference() { + BasicAttrReference attrReference = + new BasicAttrReference(getNamespace(), getName(), getSourceFile(), getVisibility(), myDescription, myGroupName); + attrReference.setNamespaceResolver(getNamespaceResolver()); + return attrReference; + } + + @Override + public void serialize(@NotNull Base128OutputStream stream, + @NotNull Object2IntMap configIndexes, + @NotNull Object2IntMap sourceFileIndexes, + @NotNull Object2IntMap namespaceResolverIndexes) throws IOException { + super.serialize(stream, configIndexes, sourceFileIndexes, namespaceResolverIndexes); + serializeAttrValue(this, getRepository().getNamespace(), stream); + } + + static void serializeAttrValue(@NotNull AttrResourceValue attr, @NotNull ResourceNamespace defaultNamespace, + @NotNull Base128OutputStream stream) throws IOException { + ResourceNamespace namespace = attr.getNamespace(); + String namespaceSuffix = namespace.equals(defaultNamespace) ? + null : namespace.getXmlNamespaceUri().substring(URI_DOMAIN_PREFIX.length()); + stream.writeString(namespaceSuffix); + + stream.writeString(attr.getDescription()); + stream.writeString(attr.getGroupName()); + + int formatMask = 0; + for (AttributeFormat format : attr.getFormats()) { + formatMask |= 1 << format.ordinal(); + } + stream.writeInt(formatMask); + + Map attributeValues = attr.getAttributeValues(); + stream.writeInt(attributeValues.size()); + for (Map.Entry entry : attributeValues.entrySet()) { + String name = entry.getKey(); + stream.writeString(name); + Integer value = entry.getValue(); + int v = value == null ? Integer.MIN_VALUE : value + 1; // Use value + 1 to reduce length of encoded -1 value. + stream.writeInt(v); + String description = attr.getValueDescription(name); + stream.writeString(description); + } + } + + /** + * Creates a BasicAttrResourceItem by reading its contents from the given stream. + */ + @NotNull + static BasicValueResourceItemBase deserialize(@NotNull Base128InputStream stream, + @NotNull String name, + @NotNull ResourceVisibility visibility, + @NotNull ResourceSourceFile sourceFile, + @NotNull ResourceNamespace.Resolver resolver) throws IOException { + String namespaceSuffix = stream.readString(); + String description = stream.readString(); + String groupName = stream.readString(); + + int formatMask = stream.readInt(); + Set formats = EnumSet.noneOf(AttributeFormat.class); + AttributeFormat[] attributeFormatValues = AttributeFormat.values(); + for (int ordinal = 0; ordinal < attributeFormatValues.length && formatMask != 0; ordinal++, formatMask >>>= 1) { + if ((formatMask & 0x1) != 0) { + formats.add(attributeFormatValues[ordinal]); + } + } + int n = stream.readInt(); + Map valueMap = n == 0 ? Collections.emptyMap() : Maps.newHashMapWithExpectedSize(n); + Map descriptionMap = n == 0 ? Collections.emptyMap() : Maps.newHashMapWithExpectedSize(n); + for (int i = 0; i < n; i++) { + String valueName = stream.readString(); + int value = stream.readInt(); + if (value != Integer.MIN_VALUE) { + valueMap.put(valueName, value - 1); + } + String valueDescription = stream.readString(); + if (valueDescription != null) { + descriptionMap.put(valueName, valueDescription); + } + } + BasicValueResourceItemBase item; + if (formats.isEmpty() && valueMap.isEmpty()) { + ResourceNamespace namespace = namespaceSuffix == null ? + sourceFile.getRepository().getNamespace() : + ResourceNamespace.fromNamespaceUri(URI_DOMAIN_PREFIX + namespaceSuffix); + if (namespace == null) { + throw StreamFormatException.invalidFormat(); + } + item = new BasicAttrReference(namespace, name, sourceFile, visibility, description, groupName); + } + else if (namespaceSuffix == null) { + item = new BasicAttrResourceItem(name, sourceFile, visibility, description, groupName, formats, valueMap, descriptionMap); + } + else { + ResourceNamespace namespace = ResourceNamespace.fromNamespaceUri(URI_DOMAIN_PREFIX + namespaceSuffix); + if (namespace == null) { + throw StreamFormatException.invalidFormat(); + } + item = new BasicForeignAttrResourceItem(namespace, name, sourceFile, description, groupName, formats, valueMap, descriptionMap); + } + item.setNamespaceResolver(resolver); + return item; + } +} diff --git a/src/main/java/com/android/resources/base/BasicDensityBasedFileResourceItem.java b/src/main/java/com/android/resources/base/BasicDensityBasedFileResourceItem.java new file mode 100644 index 0000000..16023e4 --- /dev/null +++ b/src/main/java/com/android/resources/base/BasicDensityBasedFileResourceItem.java @@ -0,0 +1,88 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.resources.base; + +import com.android.ide.common.rendering.api.DensityBasedResourceValue; +import com.android.resources.Density; +import com.android.resources.ResourceType; +import com.android.resources.ResourceVisibility; +import com.android.utils.HashCodes; +import com.google.common.base.MoreObjects; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Resource item representing a density-specific file resource inside an AAR, e.g. a drawable or a layout. + */ +public final class BasicDensityBasedFileResourceItem extends BasicFileResourceItem implements DensityBasedResourceValue { + @NotNull private final Density myDensity; + + /** + * Initializes a file resource. + * + * @param type the type of the resource + * @param name the name of the resource + * @param configuration the configuration the resource belongs to + * @param visibility the visibility of the resource + * @param relativePath defines location of the resource. Exact semantics of the path may vary depending on the resource repository + * @param density the screen density this resource is associated with + */ + public BasicDensityBasedFileResourceItem(@NotNull ResourceType type, + @NotNull String name, + @NotNull RepositoryConfiguration configuration, + @NotNull ResourceVisibility visibility, + @NotNull String relativePath, + @NotNull Density density) { + super(type, name, configuration, visibility, relativePath); + myDensity = density; + } + + @Override + @NotNull + public Density getResourceDensity() { + return myDensity; + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) return true; + if (!super.equals(obj)) return false; + BasicDensityBasedFileResourceItem other = (BasicDensityBasedFileResourceItem) obj; + return myDensity == other.myDensity; + } + + @Override + public int hashCode() { + return HashCodes.mix(super.hashCode(), myDensity.hashCode()); + } + + @Override + protected int getEncodedDensityForSerialization() { + return myDensity.getDpiValue(); + } + + @Override + @NotNull + public String toString() { + return MoreObjects.toStringHelper(this) + .add("name", getName()) + .add("namespace", getNamespace()) + .add("type", getResourceType()) + .add("source", getSource()) + .add("density", getResourceDensity()) + .toString(); + } +} diff --git a/src/main/java/com/android/resources/base/BasicFileResourceItem.java b/src/main/java/com/android/resources/base/BasicFileResourceItem.java new file mode 100644 index 0000000..c70611b --- /dev/null +++ b/src/main/java/com/android/resources/base/BasicFileResourceItem.java @@ -0,0 +1,164 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.resources.base; + +import com.android.ide.common.rendering.api.ResourceNamespace; +import com.android.ide.common.rendering.api.ResourceReference; +import com.android.ide.common.util.PathString; +import com.android.resources.Density; +import com.android.resources.ResourceType; +import com.android.resources.ResourceVisibility; +import com.android.utils.Base128InputStream; +import com.android.utils.Base128InputStream.StreamFormatException; +import com.android.utils.Base128OutputStream; +import com.android.utils.HashCodes; +import io.johnsonlee.playground.util.DensityKt; +import it.unimi.dsi.fastutil.objects.Object2IntMap; +import java.io.IOException; +import java.util.List; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Resource item representing a file resource, e.g. a drawable or a layout. + */ +public class BasicFileResourceItem extends BasicResourceItemBase { + @NotNull private final RepositoryConfiguration myConfiguration; + @NotNull private final String myRelativePath; + + /** + * Initializes the resource. + * + * @param type the type of the resource + * @param name the name of the resource + * @param configuration the configuration the resource belongs to + * @param visibility the visibility of the resource + * @param relativePath defines location of the resource. Exact semantics of the path may vary depending on the resource repository + */ + public BasicFileResourceItem(@NotNull ResourceType type, + @NotNull String name, + @NotNull RepositoryConfiguration configuration, + @NotNull ResourceVisibility visibility, + @NotNull String relativePath) { + super(type, name, visibility); + myConfiguration = configuration; + myRelativePath = relativePath; + } + + @Override + public final boolean isFileBased() { + return true; + } + + @Override + @Nullable + public final ResourceReference getReference() { + return null; + } + + @Override + @NotNull + public RepositoryConfiguration getRepositoryConfiguration() { + return myConfiguration; + } + + @Override + @NotNull + public final ResourceNamespace.Resolver getNamespaceResolver() { + return ResourceNamespace.Resolver.EMPTY_RESOLVER; + } + + @Override + @NotNull + public String getValue() { + return getRepository().getResourceUrl(myRelativePath); + } + + /** + * {@inheritDoc} + * + *

The returned PathString points either to a file on disk, or to a ZIP entry inside a res.apk file. + * In the latter case the filesystem URI part points to res.apk itself, e.g. {@code "zip:///foo/bar/res.apk"}. + * The path part is the path of the ZIP entry containing the resource. + */ + @Override + @NotNull + public final PathString getSource() { + return getRepository().getSourceFile(myRelativePath, true); + } + + @Override + @Nullable + public final PathString getOriginalSource() { + return getRepository().getOriginalSourceFile(myRelativePath, true); + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) return true; + if (!super.equals(obj)) return false; + BasicFileResourceItem other = (BasicFileResourceItem) obj; + return myConfiguration.equals(other.myConfiguration) + && myRelativePath.equals(other.myRelativePath); + } + + @Override + public int hashCode() { + return HashCodes.mix(super.hashCode(), myRelativePath.hashCode()); + } + + @Override + public void serialize(@NotNull Base128OutputStream stream, + @NotNull Object2IntMap configIndexes, + @NotNull Object2IntMap sourceFileIndexes, + @NotNull Object2IntMap namespaceResolverIndexes) throws IOException { + super.serialize(stream, configIndexes, sourceFileIndexes, namespaceResolverIndexes); + stream.writeString(myRelativePath); + String qualifierString = getConfiguration().getQualifierString(); + int index = configIndexes.getInt(qualifierString); + assert index >= 0; + stream.writeInt(index); + stream.writeInt(getEncodedDensityForSerialization()); + } + + /** + * Creates a BasicFileResourceItem by reading its contents from the given stream. + */ + @NotNull + static BasicFileResourceItem deserialize(@NotNull Base128InputStream stream, + @NotNull ResourceType resourceType, + @NotNull String name, + @NotNull ResourceVisibility visibility, + @NotNull List configurations) throws IOException { + String relativePath = stream.readString(); + if (relativePath == null) { + throw StreamFormatException.invalidFormat(); + } + RepositoryConfiguration configuration = configurations.get(stream.readInt()); + int encodedDensity = stream.readInt(); + if (encodedDensity == 0) { + return new BasicFileResourceItem(resourceType, name, configuration, visibility, relativePath); + } else { + Density density = DensityKt.create(encodedDensity); + return new BasicDensityBasedFileResourceItem( + resourceType, name, configuration, visibility, relativePath, density); + } + } + + protected int getEncodedDensityForSerialization() { + return 0; + } +} diff --git a/src/main/java/com/android/resources/base/BasicForeignAttrResourceItem.java b/src/main/java/com/android/resources/base/BasicForeignAttrResourceItem.java new file mode 100644 index 0000000..95add07 --- /dev/null +++ b/src/main/java/com/android/resources/base/BasicForeignAttrResourceItem.java @@ -0,0 +1,65 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.resources.base; + +import com.android.ide.common.rendering.api.AttributeFormat; +import com.android.ide.common.rendering.api.ResourceNamespace; +import com.android.resources.ResourceVisibility; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Map; +import java.util.Set; + +/** + * Resource item representing an attr resource that is defined in a namespace different from the namespace + * of the owning AAR. + */ +public class BasicForeignAttrResourceItem extends BasicAttrResourceItem { + @NotNull private final ResourceNamespace myNamespace; + + /** + * Initializes the resource. + * + * @param namespace the namespace of the attr resource + * @param name the name of the resource + * @param sourceFile the source file containing definition of the resource + * @param description the description of the attr resource, if available + * @param groupName the name of the attr group, if available + * @param formats the allowed attribute formats + * @param valueMap the enum or flag integer values keyed by the value names. Some of the values in the +* map may be null. The map must contain the names of all declared values, even the ones that don't +* have corresponding numeric values. + * @param valueDescriptionMap the enum or flag value descriptions keyed by the value names + */ + public BasicForeignAttrResourceItem(@NotNull ResourceNamespace namespace, + @NotNull String name, + @NotNull ResourceSourceFile sourceFile, + @Nullable String description, + @Nullable String groupName, + @NotNull Set formats, + @NotNull Map valueMap, + @NotNull Map valueDescriptionMap) { + super(name, sourceFile, ResourceVisibility.PUBLIC, description, groupName, formats, valueMap, valueDescriptionMap); + myNamespace = namespace; + } + + @Override + @NotNull + public ResourceNamespace getNamespace() { + return myNamespace; + } +} diff --git a/src/main/java/com/android/resources/base/BasicPluralsResourceItem.java b/src/main/java/com/android/resources/base/BasicPluralsResourceItem.java new file mode 100644 index 0000000..1a4683c --- /dev/null +++ b/src/main/java/com/android/resources/base/BasicPluralsResourceItem.java @@ -0,0 +1,172 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.resources.base; + +import com.android.ide.common.rendering.api.PluralsResourceValue; +import com.android.ide.common.rendering.api.ResourceNamespace; +import com.android.resources.Arity; +import com.android.resources.ResourceType; +import com.android.resources.ResourceVisibility; +import com.android.utils.Base128InputStream; +import com.android.utils.Base128InputStream.StreamFormatException; +import com.android.utils.Base128OutputStream; +import com.intellij.util.ArrayUtil; +import it.unimi.dsi.fastutil.objects.Object2IntMap; +import java.io.IOException; +import java.util.Arrays; +import java.util.Collection; +import java.util.Map; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Resource item representing a plurals resource. + */ +public final class BasicPluralsResourceItem extends BasicValueResourceItemBase implements PluralsResourceValue { + @NotNull private final Arity[] myArities; + @NotNull private final String[] myValues; + private final int myDefaultIndex; + + /** + * Initializes the resource. + * + * @param name the name of the resource + * @param sourceFile the source file containing definition of the resource + * @param visibility the visibility of the resource + * @param quantityValues the values corresponding to quantities + * @param defaultArity the default arity for the {@link #getValue()} method + */ + public BasicPluralsResourceItem(@NotNull String name, + @NotNull ResourceSourceFile sourceFile, + @NotNull ResourceVisibility visibility, + @NotNull Map quantityValues, + @Nullable Arity defaultArity) { + this(name, sourceFile, visibility, + quantityValues.keySet().toArray(Arity.EMPTY_ARRAY), quantityValues.values().toArray(ArrayUtil.EMPTY_STRING_ARRAY), + getIndex(defaultArity, quantityValues.keySet())); + } + + private BasicPluralsResourceItem(@NotNull String name, + @NotNull ResourceSourceFile sourceFile, + @NotNull ResourceVisibility visibility, + @NotNull Arity[] arities, + @NotNull String[] values, + int defaultIndex) { + super(ResourceType.PLURALS, name, sourceFile, visibility); + assert arities.length == values.length; + myArities = arities; + myValues = values; + assert values.length == 0 || defaultIndex < values.length; + myDefaultIndex = defaultIndex; + } + + private static int getIndex(@Nullable Arity arity, @NotNull Collection arities) { + if (arity == null || arities.isEmpty()) { + return 0; + } + int index = 0; + for (Arity ar : arities) { + if (ar == arity) { + return index; + } + index++; + } + throw new IllegalArgumentException(); + } + + @Override + public int getPluralsCount() { + return myArities.length; + } + + @Override + @NotNull + public String getQuantity(int index) { + return myArities[index].getName(); + } + + @Override + @NotNull + public String getValue(int index) { + return myValues[index]; + } + + @Override + @Nullable + public String getValue(@NotNull String quantity) { + for (int i = 0, n = myArities.length; i < n; i++) { + if (quantity.equals(myArities[i].getName())) { + return myValues[i]; + } + } + + return null; + } + + @Override + @Nullable + public String getValue() { + return myValues.length == 0 ? null : myValues[myDefaultIndex]; + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) return true; + if (!super.equals(obj)) return false; + BasicPluralsResourceItem other = (BasicPluralsResourceItem) obj; + return Arrays.equals(myArities, other.myArities) && Arrays.equals(myValues, other.myValues); + } + + @Override + public void serialize(@NotNull Base128OutputStream stream, + @NotNull Object2IntMap configIndexes, + @NotNull Object2IntMap sourceFileIndexes, + @NotNull Object2IntMap namespaceResolverIndexes) throws IOException { + super.serialize(stream, configIndexes, sourceFileIndexes, namespaceResolverIndexes); + int n = myArities.length; + stream.writeInt(n); + for (int i = 0; i < n; i++) { + stream.writeInt(myArities[i].ordinal()); + stream.writeString(myValues[i]); + } + stream.writeInt(myDefaultIndex); + } + + /** + * Creates a BasicPluralsResourceItem by reading its contents from the given stream. + */ + @NotNull + static BasicPluralsResourceItem deserialize(@NotNull Base128InputStream stream, + @NotNull String name, + @NotNull ResourceVisibility visibility, + @NotNull ResourceSourceFile sourceFile, + @NotNull ResourceNamespace.Resolver resolver) throws IOException { + int n = stream.readInt(); + Arity[] arities = n == 0 ? Arity.EMPTY_ARRAY : new Arity[n]; + String[] values = n == 0 ? ArrayUtil.EMPTY_STRING_ARRAY : new String[n]; + for (int i = 0; i < n; i++) { + arities[i] = Arity.values()[stream.readInt()]; + values[i] = stream.readString(); + } + int defaultIndex = stream.readInt(); + if (values.length != 0 && defaultIndex >= values.length) { + throw StreamFormatException.invalidFormat(); + } + BasicPluralsResourceItem item = new BasicPluralsResourceItem(name, sourceFile, visibility, arities, values, defaultIndex); + item.setNamespaceResolver(resolver); + return item; + } +} diff --git a/src/main/java/com/android/resources/base/BasicResourceItem.java b/src/main/java/com/android/resources/base/BasicResourceItem.java new file mode 100644 index 0000000..c88ebe6 --- /dev/null +++ b/src/main/java/com/android/resources/base/BasicResourceItem.java @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.resources.base; + +import com.android.ide.common.rendering.api.ResourceValue; +import com.android.ide.common.resources.ResourceItemWithVisibility; + +/** + * Combination of {@link ResourceItemWithVisibility} and {@link ResourceValue} interfaces. + */ +public interface BasicResourceItem extends ResourceItemWithVisibility, ResourceValue { +} diff --git a/src/main/java/com/android/resources/base/BasicResourceItemBase.java b/src/main/java/com/android/resources/base/BasicResourceItemBase.java new file mode 100644 index 0000000..b14aec8 --- /dev/null +++ b/src/main/java/com/android/resources/base/BasicResourceItemBase.java @@ -0,0 +1,215 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.resources.base; + +import com.android.ide.common.rendering.api.ResourceNamespace; +import com.android.ide.common.rendering.api.ResourceReference; +import com.android.ide.common.rendering.api.ResourceValue; +import com.android.ide.common.resources.configuration.FolderConfiguration; +import com.android.resources.ResourceType; +import com.android.resources.ResourceVisibility; +import com.android.utils.Base128InputStream; +import com.android.utils.Base128InputStream.StreamFormatException; +import com.android.utils.Base128OutputStream; +import com.android.utils.HashCodes; +import com.google.common.base.MoreObjects; +import it.unimi.dsi.fastutil.objects.Object2IntMap; +import java.io.IOException; +import java.util.List; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** Base class for implementations of the {@link BasicResourceItem} interface. */ +public abstract class BasicResourceItemBase implements BasicResourceItem { + @NotNull private final String myName; + // Store enums as their ordinals in byte form to minimize memory footprint. + private final byte myTypeOrdinal; + private final byte myVisibilityOrdinal; + + BasicResourceItemBase(@NotNull ResourceType type, @NotNull String name, @NotNull ResourceVisibility visibility) { + myName = name; + myTypeOrdinal = (byte)type.ordinal(); + myVisibilityOrdinal = (byte)visibility.ordinal(); + } + + @Override + @NotNull + public final ResourceType getType() { + return getResourceType(); + } + + @Override + @NotNull + public ResourceNamespace getNamespace() { + return getRepository().getNamespace(); + } + + @Override + @NotNull + public final String getName() { + return myName; + } + + @Override + @Nullable + public final String getLibraryName() { + return getRepository().getLibraryName(); + } + + @Override + @NotNull + public final ResourceType getResourceType() { + return ResourceType.values()[myTypeOrdinal]; + } + + @Override + @NotNull + public final ResourceVisibility getVisibility() { + return ResourceVisibility.values()[myVisibilityOrdinal]; + } + + @Override + @NotNull + public final ResourceValue getResourceValue() { + return this; + } + + @Override + public final boolean isUserDefined() { + return getRepository().containsUserDefinedResources(); + } + + @Override + @NotNull + public final ResourceReference asReference() { + return new ResourceReference(getNamespace(), getResourceType(), getName()); + } + + /** + * Returns the repository this resource belongs to. + *

+ * Framework resource items may move between repositories with the same origin. + * @see RepositoryConfiguration#transferOwnershipTo(LoadableResourceRepository) + */ + @Override + @NotNull + public final LoadableResourceRepository getRepository() { + return getRepositoryConfiguration().getRepository(); + } + + @Override + @NotNull + public final FolderConfiguration getConfiguration() { + return getRepositoryConfiguration().getFolderConfiguration(); + } + + @NotNull + public abstract RepositoryConfiguration getRepositoryConfiguration(); + + @Override + public boolean isFramework() { + return getNamespace() == ResourceNamespace.ANDROID; + } + + @Override + public ResourceReference getReferenceToSelf() { + return asReference(); + } + + @Override + public void setValue(String value) { + throw new UnsupportedOperationException(); + } + + @Override + public String getKey() { + final String qualifiers = getConfiguration().getQualifierString(); + if (!qualifiers.isEmpty()) { + return getType().getName() + "-" + qualifiers + "/" + getName(); + } + return getType().getName() + "/" + getName(); + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + BasicResourceItemBase other = (BasicResourceItemBase) obj; + return myTypeOrdinal == other.myTypeOrdinal + && myName.equals(other.myName) + && myVisibilityOrdinal == other.myVisibilityOrdinal; + } + + @Override + public int hashCode() { + // The myVisibilityOrdinal field is intentionally not included in hash code because having two resource items + // differing only by visibility in the same hash table is extremely unlikely. + return HashCodes.mix(myTypeOrdinal, myName.hashCode()); + } + + @Override + @NotNull + public String toString() { + return MoreObjects.toStringHelper(this) + .add("namespace", getNamespace()) + .add("type", getResourceType()) + .add("name", getName()) + .add("value", getValue()) + .toString(); + } + + /** + * Serializes the resource item to the given stream. + */ + public void serialize(@NotNull Base128OutputStream stream, + @NotNull Object2IntMap configIndexes, + @NotNull Object2IntMap sourceFileIndexes, + @NotNull Object2IntMap namespaceResolverIndexes) throws IOException { + stream.writeInt((myTypeOrdinal << 1) + (isFileBased() ? 1 : 0)); + stream.writeString(myName); + stream.writeInt(myVisibilityOrdinal); + } + + /** + * Creates a resource item by reading its contents from the given stream. + */ + @NotNull + public static BasicResourceItemBase deserialize(@NotNull Base128InputStream stream, + @NotNull List configurations, + @NotNull List sourceFiles, + @NotNull List namespaceResolvers) throws IOException { + assert !configurations.isEmpty(); + int encodedType = stream.readInt(); + boolean isFileBased = (encodedType & 0x1) != 0; + ResourceType resourceType = ResourceType.values()[encodedType >>> 1]; + String name = stream.readString(); + if (name == null) { + throw StreamFormatException.invalidFormat(); + } + ResourceVisibility visibility = ResourceVisibility.values()[stream.readInt()]; + + if (isFileBased) { + LoadableResourceRepository repository = configurations.get(0).getRepository(); + return repository.deserializeFileResourceItem(stream, resourceType, name, visibility, configurations); + } + + return BasicValueResourceItemBase.deserialize(stream, resourceType, name, visibility, configurations, sourceFiles, namespaceResolvers); + } +} diff --git a/src/main/java/com/android/resources/base/BasicStyleResourceItem.java b/src/main/java/com/android/resources/base/BasicStyleResourceItem.java new file mode 100644 index 0000000..53a0a05 --- /dev/null +++ b/src/main/java/com/android/resources/base/BasicStyleResourceItem.java @@ -0,0 +1,170 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.resources.base; + +import com.android.ide.common.rendering.api.ResourceNamespace; +import com.android.ide.common.rendering.api.ResourceReference; +import com.android.ide.common.rendering.api.StyleItemResourceValue; +import com.android.ide.common.rendering.api.StyleItemResourceValueImpl; +import com.android.ide.common.rendering.api.StyleResourceValue; +import com.android.resources.ResourceType; +import com.android.resources.ResourceVisibility; +import com.android.utils.Base128InputStream; +import com.android.utils.Base128InputStream.StreamFormatException; +import com.android.utils.Base128OutputStream; +import com.google.common.collect.ImmutableTable; +import com.google.common.collect.Table; +import com.intellij.openapi.diagnostic.Logger; +import it.unimi.dsi.fastutil.objects.Object2IntMap; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Resource item representing a style resource. + */ +public final class BasicStyleResourceItem extends BasicValueResourceItemBase implements StyleResourceValue { + private static final Logger LOG = Logger.getInstance(BasicStyleResourceItem.class); + + @Nullable private final String myParentStyle; + /** Style items keyed by the namespace and the name of the attribute they define. */ + @NotNull private final Table myStyleItemTable; + + /** + * Initializes the resource. + * + * @param name the name of the resource + * @param sourceFile the source file containing definition of the resource + * @param visibility the visibility of the resource + * @param parentStyle the parent style reference (package:type/entry) + * @param styleItems the items of the style + */ + public BasicStyleResourceItem(@NotNull String name, + @NotNull ResourceSourceFile sourceFile, + @NotNull ResourceVisibility visibility, + @Nullable String parentStyle, + @NotNull Collection styleItems) { + super(ResourceType.STYLE, name, sourceFile, visibility); + myParentStyle = parentStyle; + ImmutableTable.Builder tableBuilder = ImmutableTable.builder(); + Map duplicateCheckMap = new HashMap<>(); + for (StyleItemResourceValue styleItem : styleItems) { + ResourceReference attr = styleItem.getAttr(); + if (attr != null) { + // Check for duplicate style item definitions. Such duplicate definitions are present in the framework resources. + StyleItemResourceValue previouslyDefined = duplicateCheckMap.put(attr, styleItem); + if (previouslyDefined == null) { + tableBuilder.put(attr.getNamespace(), attr.getName(), styleItem); + } + else if (!previouslyDefined.equals(styleItem)) { + LOG.warn("Conflicting definitions of \"" + styleItem.getAttrName() + "\" in style \"" + name + "\""); + } + } + } + myStyleItemTable = tableBuilder.build(); + } + + @Override + @Nullable + public String getParentStyleName() { + return myParentStyle; + } + + @Override + @Nullable + public StyleItemResourceValue getItem(@NotNull ResourceNamespace namespace, @NotNull String name) { + return myStyleItemTable.get(namespace, name); + } + + @Override + @Nullable + public StyleItemResourceValue getItem(@NotNull ResourceReference attr) { + if (attr.getResourceType() != ResourceType.ATTR) { + return null; + } + return myStyleItemTable.get(attr.getNamespace(), attr.getName()); + } + + @Override + @NotNull + public Collection getDefinedItems() { + return myStyleItemTable.values(); + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) return true; + if (!super.equals(obj)) return false; + BasicStyleResourceItem other = (BasicStyleResourceItem) obj; + return Objects.equals(myParentStyle, other.myParentStyle) && myStyleItemTable.equals(other.myStyleItemTable); + } + + @Override + public void serialize(@NotNull Base128OutputStream stream, + @NotNull Object2IntMap configIndexes, + @NotNull Object2IntMap sourceFileIndexes, + @NotNull Object2IntMap namespaceResolverIndexes) throws IOException { + super.serialize(stream, configIndexes, sourceFileIndexes, namespaceResolverIndexes); + stream.writeString(myParentStyle); + stream.writeInt(myStyleItemTable.size()); + for (StyleItemResourceValue styleItem : myStyleItemTable.values()) { + stream.writeString(styleItem.getAttrName()); + stream.writeString(styleItem.getValue()); + int index = namespaceResolverIndexes.getInt(styleItem.getNamespaceResolver()); + assert index >= 0; + stream.writeInt(index); + } + } + + /** + * Creates a BasicStyleResourceItem by reading its contents from the given stream. + */ + @NotNull + static BasicStyleResourceItem deserialize(@NotNull Base128InputStream stream, + @NotNull String name, + @NotNull ResourceVisibility visibility, + @NotNull ResourceSourceFile sourceFile, + @NotNull ResourceNamespace.Resolver resolver, + @NotNull List namespaceResolvers) throws IOException { + LoadableResourceRepository repository = sourceFile.getRepository(); + ResourceNamespace namespace = repository.getNamespace(); + String libraryName = repository.getLibraryName(); + String parentStyle = stream.readString(); + int n = stream.readInt(); + List styleItems = n == 0 ? Collections.emptyList() : new ArrayList<>(n); + for (int i = 0; i < n; i++) { + String attrName = stream.readString(); + if (attrName == null) { + throw StreamFormatException.invalidFormat(); + } + String value = stream.readString(); + ResourceNamespace.Resolver itemResolver = namespaceResolvers.get(stream.readInt()); + StyleItemResourceValueImpl styleItem = new StyleItemResourceValueImpl(namespace, attrName, value, libraryName); + styleItem.setNamespaceResolver(itemResolver); + styleItems.add(styleItem); + } + BasicStyleResourceItem item = new BasicStyleResourceItem(name, sourceFile, visibility, parentStyle, styleItems); + item.setNamespaceResolver(resolver); + return item; + } +} diff --git a/src/main/java/com/android/resources/base/BasicStyleableResourceItem.java b/src/main/java/com/android/resources/base/BasicStyleableResourceItem.java new file mode 100644 index 0000000..882c57d --- /dev/null +++ b/src/main/java/com/android/resources/base/BasicStyleableResourceItem.java @@ -0,0 +1,137 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.resources.base; + +import com.android.ide.common.rendering.api.AttrResourceValue; +import com.android.ide.common.rendering.api.ResourceNamespace; +import com.android.ide.common.rendering.api.StyleableResourceValue; +import com.android.ide.common.resources.ResourceItem; +import com.android.ide.common.resources.ResourceRepository; +import com.android.resources.ResourceType; +import com.android.resources.ResourceVisibility; +import com.android.utils.Base128InputStream; +import com.android.utils.Base128InputStream.StreamFormatException; +import com.android.utils.Base128OutputStream; +import com.google.common.collect.ImmutableList; +import it.unimi.dsi.fastutil.objects.Object2IntMap; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Resource item representing a styleable resource. + */ +public final class BasicStyleableResourceItem extends BasicValueResourceItemBase implements StyleableResourceValue { + @NotNull private final List myAttrs; + + /** + * Initializes the resource. + * + * @param name the name of the resource + * @param sourceFile the source file containing definition of the resource + * @param visibility the visibility of the resource + * @param attrs the attributes of the styleable + */ + public BasicStyleableResourceItem(@NotNull String name, + @NotNull ResourceSourceFile sourceFile, + @NotNull ResourceVisibility visibility, + @NotNull List attrs) { + super(ResourceType.STYLEABLE, name, sourceFile, visibility); + myAttrs = ImmutableList.copyOf(attrs); + } + + @Override + @NotNull + public List getAllAttributes() { + return myAttrs; + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) return true; + if (!super.equals(obj)) return false; + BasicStyleableResourceItem other = (BasicStyleableResourceItem) obj; + return myAttrs.equals(other.myAttrs); + } + + @Override + public void serialize(@NotNull Base128OutputStream stream, + @NotNull Object2IntMap configIndexes, + @NotNull Object2IntMap sourceFileIndexes, + @NotNull Object2IntMap namespaceResolverIndexes) throws IOException { + super.serialize(stream, configIndexes, sourceFileIndexes, namespaceResolverIndexes); + stream.writeInt(myAttrs.size()); + for (AttrResourceValue attr : myAttrs) { + if (attr instanceof BasicAttrResourceItem && !attr.getFormats().isEmpty()) { + // Don't write redundant format information to the stream. + attr = ((BasicAttrResourceItem)attr).createReference(); + } + ((BasicValueResourceItemBase)attr).serialize(stream, configIndexes, sourceFileIndexes, namespaceResolverIndexes); + } + } + + /** + * Creates a BasicStyleableResourceItem by reading its contents from the given stream. + */ + @NotNull + static BasicStyleableResourceItem deserialize(@NotNull Base128InputStream stream, + @NotNull String name, + @NotNull ResourceVisibility visibility, + @NotNull ResourceSourceFile sourceFile, + @NotNull ResourceNamespace.Resolver resolver, + @NotNull List configurations, + @NotNull List sourceFiles, + @NotNull List namespaceResolvers) throws IOException { + ResourceRepository repository = sourceFile.getRepository(); + int n = stream.readInt(); + List attrs = n == 0 ? Collections.emptyList() : new ArrayList<>(n); + for (int i = 0; i < n; i++) { + BasicResourceItemBase attrItem = deserialize(stream, configurations, sourceFiles, namespaceResolvers); + if (!(attrItem instanceof AttrResourceValue)) { + throw StreamFormatException.invalidFormat(); + } + AttrResourceValue attr = getCanonicalAttr((AttrResourceValue)attrItem, repository); + attrs.add(attr); + } + BasicStyleableResourceItem item = new BasicStyleableResourceItem(name, sourceFile, visibility, attrs); + item.setNamespaceResolver(resolver); + return item; + } + + /** + * For an attr reference that doesn't contain formats tries to find an attr definition the reference is pointing to. + * If such attr definition belongs to this resource repository and has the same description and group name as + * the attr reference, returns the attr definition. Otherwise returns the attr reference passed as the parameter. + */ + @NotNull + public static AttrResourceValue getCanonicalAttr(@NotNull AttrResourceValue attr, @NotNull ResourceRepository repository) { + if (attr.getFormats().isEmpty()) { + List items = repository.getResources(attr.getNamespace(), ResourceType.ATTR, attr.getName()); + for (ResourceItem item : items) { + if (item instanceof AttrResourceValue && + Objects.equals(((AttrResourceValue)item).getDescription(), attr.getDescription()) && + Objects.equals(((AttrResourceValue)item).getGroupName(), attr.getGroupName())) { + return (AttrResourceValue)item; + } + } + } + return attr; + } +} diff --git a/src/main/java/com/android/resources/base/BasicTextValueResourceItem.java b/src/main/java/com/android/resources/base/BasicTextValueResourceItem.java new file mode 100644 index 0000000..a4be25d --- /dev/null +++ b/src/main/java/com/android/resources/base/BasicTextValueResourceItem.java @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.resources.base; + +import com.android.ide.common.rendering.api.ResourceValue; +import com.android.ide.common.rendering.api.TextResourceValue; +import com.android.resources.ResourceType; +import com.android.resources.ResourceVisibility; +import com.android.utils.HashCodes; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Objects; + +/** + * Resource item representing a value resource, e.g. a string or a color. + */ +public class BasicTextValueResourceItem extends BasicValueResourceItem implements TextResourceValue { + private final String myRawXmlValue; + + /** + * Initializes the resource. + * + * @param type the type of the resource + * @param name the name of the resource + * @param sourceFile the source file containing definition of the resource + * @param visibility the visibility of the resource + * @param textValue the text value associated with the resource + * @param rawXmlValue the raw xml value associated with the resource (see {@link ResourceValue#getRawXmlValue()}) + */ + public BasicTextValueResourceItem(@NotNull ResourceType type, + @NotNull String name, + @NotNull ResourceSourceFile sourceFile, + @NotNull ResourceVisibility visibility, + @Nullable String textValue, + @Nullable String rawXmlValue) { + super(type, name, sourceFile, visibility, textValue); + myRawXmlValue = rawXmlValue; + } + + @Override + @Nullable + public String getRawXmlValue() { + return myRawXmlValue == null ? getValue() : myRawXmlValue; + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) return true; + if (!super.equals(obj)) return false; + BasicTextValueResourceItem other = (BasicTextValueResourceItem) obj; + return Objects.equals(myRawXmlValue, other.myRawXmlValue); + } + + @Override + public int hashCode() { + return HashCodes.mix(super.hashCode(), Objects.hashCode(myRawXmlValue)); + } +} diff --git a/src/main/java/com/android/resources/base/BasicValueResourceItem.java b/src/main/java/com/android/resources/base/BasicValueResourceItem.java new file mode 100644 index 0000000..2b20cc7 --- /dev/null +++ b/src/main/java/com/android/resources/base/BasicValueResourceItem.java @@ -0,0 +1,102 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.resources.base; + +import com.android.ide.common.rendering.api.ResourceNamespace; +import com.android.resources.ResourceType; +import com.android.resources.ResourceVisibility; +import com.android.utils.Base128InputStream; +import com.android.utils.Base128OutputStream; +import com.android.utils.HashCodes; +import it.unimi.dsi.fastutil.objects.Object2IntMap; +import java.io.IOException; +import java.util.Objects; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Resource item representing a value resource, e.g. a string or a color. + */ +public class BasicValueResourceItem extends BasicValueResourceItemBase { + @Nullable private final String myValue; + + /** + * Initializes the resource. + * + * @param type the type of the resource + * @param name the name of the resource + * @param sourceFile the source file containing definition of the resource + * @param visibility the visibility of the resource + * @param value the value associated with the resource + */ + public BasicValueResourceItem(@NotNull ResourceType type, + @NotNull String name, + @NotNull ResourceSourceFile sourceFile, + @NotNull ResourceVisibility visibility, + @Nullable String value) { + super(type, name, sourceFile, visibility); + myValue = value; + } + + @Override + @Nullable + public String getValue() { + return myValue; + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) return true; + if (!super.equals(obj)) return false; + BasicValueResourceItem other = (BasicValueResourceItem) obj; + return Objects.equals(myValue, other.myValue); + } + + @Override + public int hashCode() { + return HashCodes.mix(super.hashCode(), Objects.hashCode(myValue)); + } + + @Override + public void serialize(@NotNull Base128OutputStream stream, + @NotNull Object2IntMap configIndexes, + @NotNull Object2IntMap sourceFileIndexes, + @NotNull Object2IntMap namespaceResolverIndexes) throws IOException { + super.serialize(stream, configIndexes, sourceFileIndexes, namespaceResolverIndexes); + stream.writeString(myValue); + String rawXmlValue = getRawXmlValue(); + stream.writeString(Objects.equals(rawXmlValue, myValue) ? null : rawXmlValue); + } + + /** + * Creates a BasicValueResourceItem by reading its contents from the given stream. + */ + @NotNull + static BasicValueResourceItem deserialize(@NotNull Base128InputStream stream, + @NotNull ResourceType resourceType, + @NotNull String name, + @NotNull ResourceVisibility visibility, + @NotNull ResourceSourceFile sourceFile, + @NotNull ResourceNamespace.Resolver resolver) throws IOException { + String value = stream.readString(); + String rawXmlValue = stream.readString(); + BasicValueResourceItem item = rawXmlValue == null ? + new BasicValueResourceItem(resourceType, name, sourceFile, visibility, value) : + new BasicTextValueResourceItem(resourceType, name, sourceFile, visibility, value, rawXmlValue); + item.setNamespaceResolver(resolver); + return item; + } +} diff --git a/src/main/java/com/android/resources/base/BasicValueResourceItemBase.java b/src/main/java/com/android/resources/base/BasicValueResourceItemBase.java new file mode 100644 index 0000000..86faffe --- /dev/null +++ b/src/main/java/com/android/resources/base/BasicValueResourceItemBase.java @@ -0,0 +1,160 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.resources.base; + +import com.android.ide.common.rendering.api.ResourceNamespace; +import com.android.ide.common.util.PathString; +import com.android.resources.ResourceType; +import com.android.resources.ResourceVisibility; +import com.android.utils.Base128InputStream; +import com.android.utils.Base128OutputStream; +import com.android.utils.HashCodes; +import it.unimi.dsi.fastutil.objects.Object2IntMap; +import java.io.IOException; +import java.util.List; +import java.util.Objects; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** Base class for value resource items. */ +public abstract class BasicValueResourceItemBase extends BasicResourceItemBase { + @NotNull private final ResourceSourceFile mySourceFile; + @NotNull private ResourceNamespace.Resolver myNamespaceResolver = ResourceNamespace.Resolver.EMPTY_RESOLVER; + + /** + * Initializes the resource. + * + * @param type the type of the resource + * @param name the name of the resource + * @param sourceFile the source file containing definition of the resource + * @param visibility the visibility of the resource + */ + public BasicValueResourceItemBase(@NotNull ResourceType type, + @NotNull String name, + @NotNull ResourceSourceFile sourceFile, + @NotNull ResourceVisibility visibility) { + super(type, name, visibility); + mySourceFile = sourceFile; + } + + @Override + @Nullable + public String getValue() { + return null; + } + + @Override + public final boolean isFileBased() { + return false; + } + + @Override + @NotNull + public final RepositoryConfiguration getRepositoryConfiguration() { + return mySourceFile.getConfiguration(); + } + + @Override + @NotNull + public final ResourceNamespace.Resolver getNamespaceResolver() { + return myNamespaceResolver; + } + + public final void setNamespaceResolver(@NotNull ResourceNamespace.Resolver resolver) { + myNamespaceResolver = resolver; + } + + @Override + @Nullable + public final PathString getSource() { + return getOriginalSource(); + } + + @Override + @Nullable + public final PathString getOriginalSource() { + String sourcePath = mySourceFile.getRelativePath(); + return sourcePath == null ? null : getRepository().getOriginalSourceFile(sourcePath, false); + } + + @NotNull + public final ResourceSourceFile getSourceFile() { + return mySourceFile; + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) return true; + if (!super.equals(obj)) return false; + BasicValueResourceItemBase other = (BasicValueResourceItemBase)obj; + return Objects.equals(mySourceFile, other.mySourceFile); + } + + @Override + public int hashCode() { + return HashCodes.mix(super.hashCode(), Objects.hashCode(mySourceFile)); + } + + @Override + public void serialize(@NotNull Base128OutputStream stream, + @NotNull Object2IntMap configIndexes, + @NotNull Object2IntMap sourceFileIndexes, + @NotNull Object2IntMap namespaceResolverIndexes) throws IOException { + super.serialize(stream, configIndexes, sourceFileIndexes, namespaceResolverIndexes); + int index = sourceFileIndexes.getInt(mySourceFile); + assert index >= 0; + stream.writeInt(index); + index = namespaceResolverIndexes.getInt(myNamespaceResolver); + assert index >= 0; + stream.writeInt(index); + } + + /** + * Creates a resource item by reading its contents from the given stream. + */ + @NotNull + static BasicValueResourceItemBase deserialize(@NotNull Base128InputStream stream, + @NotNull ResourceType resourceType, + @NotNull String name, + @NotNull ResourceVisibility visibility, + @NotNull List configurations, + @NotNull List sourceFiles, + @NotNull List namespaceResolvers) throws IOException { + ResourceSourceFile sourceFile = sourceFiles.get(stream.readInt()); + ResourceNamespace.Resolver resolver = namespaceResolvers.get(stream.readInt()); + + switch (resourceType) { + case ARRAY: + return BasicArrayResourceItem.deserialize(stream, name, visibility, sourceFile, resolver); + + case ATTR: + return BasicAttrResourceItem.deserialize(stream, name, visibility, sourceFile, resolver); + + case PLURALS: + return BasicPluralsResourceItem.deserialize(stream, name, visibility, sourceFile, resolver); + + case STYLE: + return BasicStyleResourceItem.deserialize(stream, name, visibility, sourceFile, resolver, namespaceResolvers); + + case STYLEABLE: + return BasicStyleableResourceItem.deserialize( + stream, name, visibility, sourceFile, resolver, configurations, sourceFiles, namespaceResolvers); + + default: + return BasicValueResourceItem.deserialize(stream, resourceType, name, visibility, sourceFile, resolver); + } + } +} diff --git a/src/main/java/com/android/resources/base/CommentTrackingXmlPullParser.java b/src/main/java/com/android/resources/base/CommentTrackingXmlPullParser.java new file mode 100644 index 0000000..b9b5cde --- /dev/null +++ b/src/main/java/com/android/resources/base/CommentTrackingXmlPullParser.java @@ -0,0 +1,157 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.resources.base; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.kxml2.io.KXmlParser; +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserException; + +import java.io.IOException; +import java.io.InputStream; +import java.io.Reader; +import java.util.ArrayList; + +import static com.android.SdkConstants.TAG_EAT_COMMENT; + +/** + * An {@link XmlPullParser} that keeps track of the last comment preceding an XML tag and special comments + * that are used in the framework resource files for describing groups of "attr" resources. Here is + * an example of an "attr" group comment: + *

+ *   <!-- =========== -->
+ *   <!-- Text styles -->
+ *   <!-- =========== -->
+ *   <eat-comment/>
+ * 
+ */ +public class CommentTrackingXmlPullParser extends KXmlParser { + // Used for parsing group of attributes, used heuristically to skip long comments before . + private static final int ATTR_GROUP_MAX_CHARACTERS = 40; + + @Nullable String myLastComment; + boolean tagEncounteredAfterComment; + @NotNull final ArrayList myAttrGroupCommentStack = new ArrayList<>(4); + + /** + * Initializes the parser. XML namespaces are supported by default. + */ + public CommentTrackingXmlPullParser() { + try { + setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true); + } + catch (XmlPullParserException e) { + throw new Error(e); // KXmlParser is guaranteed to support FEATURE_PROCESS_NAMESPACES. + } + } + + /** + * Returns the last encountered comment that is not an ASCII art. + */ + @Nullable + public String getLastComment() { + return myLastComment; + } + + /** + * Returns the name of the current "attr" group, e.g. "Button Styles" group for "buttonStyleSmall" "attr" tag. + */ + @Nullable + public String getAttrGroupComment() { + return myAttrGroupCommentStack.get(myAttrGroupCommentStack.size() - 1); + } + + @Override + public int nextToken() throws XmlPullParserException, IOException { + int token = super.nextToken(); + processToken(token); + return token; + } + + @Override + public int next() throws XmlPullParserException, IOException { + throw new UnsupportedOperationException("Use nextToken() instead of next() for comment tracking to work"); + } + + private void processToken(int token) { + switch (token) { + case XmlPullParser.START_TAG: + if (tagEncounteredAfterComment) { + myLastComment = null; + } + tagEncounteredAfterComment = true; + // Duplicate the last element in myAttrGroupCommentStack. + myAttrGroupCommentStack.add(myAttrGroupCommentStack.get(myAttrGroupCommentStack.size() - 1)); + assert myAttrGroupCommentStack.size() == getDepth() + 1; + + if (TAG_EAT_COMMENT.equals(getName()) && getPrefix() == null) { + // The framework attribute file follows a special convention where related attributes are grouped together, + // and there is always a set of comments that indicate these sections which look like this: + // + // + // + // + // These section headers are always immediately followed by an . Not all sections are + // actually attribute headers, some are comments. We identify these by looking at the line length; category comments + // are short, and descriptive comments are longer. + if (myLastComment != null && myLastComment.length() <= ATTR_GROUP_MAX_CHARACTERS && !myLastComment.startsWith("TODO:")) { + String attrGroupComment = myLastComment; + if (attrGroupComment.endsWith(".")) { + attrGroupComment = attrGroupComment.substring(0, attrGroupComment.length() - 1); // Strip the trailing period. + } + // Replace the second to last element in myAttrGroupCommentStack. + myAttrGroupCommentStack.set(myAttrGroupCommentStack.size() - 2, attrGroupComment); + } + } + break; + + case XmlPullParser.END_TAG: + myLastComment = null; + myAttrGroupCommentStack.remove(myAttrGroupCommentStack.size() - 1); + break; + + case XmlPullParser.COMMENT: { + String commentText = getText().trim(); + if (!isEmptyOrAsciiArt(commentText)) { + myLastComment = commentText; + tagEncounteredAfterComment = false; + } + break; + } + } + } + + @Override + public void setInput(@NotNull Reader reader) throws XmlPullParserException { + super.setInput(reader); + myLastComment = null; + myAttrGroupCommentStack.clear(); + myAttrGroupCommentStack.add(null); + } + + @Override + public void setInput(@NotNull InputStream inputStream, @Nullable String encoding) throws XmlPullParserException { + super.setInput(inputStream, encoding); + myLastComment = null; + myAttrGroupCommentStack.clear(); + myAttrGroupCommentStack.add(null); + } + + private static boolean isEmptyOrAsciiArt(@NotNull String commentText) { + return commentText.isEmpty() || commentText.charAt(0) == '*' || commentText.charAt(0) == '='; + } +} diff --git a/src/main/java/com/android/resources/base/FileFilter.java b/src/main/java/com/android/resources/base/FileFilter.java new file mode 100644 index 0000000..006641b --- /dev/null +++ b/src/main/java/com/android/resources/base/FileFilter.java @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.resources.base; + +import org.jetbrains.annotations.NotNull; + +import java.nio.file.Path; +import java.nio.file.attribute.BasicFileAttributes; + +/** A filter used to select files when traversing the file system. */ +interface FileFilter { + /** Returns true to skip the file or directory, or false to accept it. */ + boolean isIgnored(@NotNull Path fileOrDirectory, @NotNull BasicFileAttributes attrs); +} diff --git a/src/main/java/com/android/resources/base/LoadableResourceRepository.java b/src/main/java/com/android/resources/base/LoadableResourceRepository.java new file mode 100644 index 0000000..3294d04 --- /dev/null +++ b/src/main/java/com/android/resources/base/LoadableResourceRepository.java @@ -0,0 +1,122 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.resources.base; + +import com.android.ide.common.resources.SingleNamespaceResourceRepository; +import com.android.ide.common.util.PathString; +import com.android.resources.ResourceType; +import com.android.resources.ResourceVisibility; +import com.android.utils.Base128InputStream; +import java.io.IOException; +import java.nio.file.Path; +import java.util.List; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Repository of resources loaded from a file or a directory on disk. + */ +public interface LoadableResourceRepository extends SingleNamespaceResourceRepository { + /** + * Returns the name of the library, or null if this is not an AAR resource repository. + */ + @Nullable + String getLibraryName(); + + /** + * Returns the name of this resource repository to display in the UI. + */ + @NotNull + String getDisplayName(); + + /** + * Returns the file or directory this resource repository was loaded from. Resource repositories loaded from + * the same file or directory with different file filtering options have the same origin. + */ + @NotNull + Path getOrigin(); + + /** + * Produces a string to be returned by the {@link BasicFileResourceItem#getValue()} method. + * The string represents an URL in one of the following formats: + *
    + *
  • file URL, e.g. "file:///foo/bar/res/layout/my_layout.xml"
  • + *
  • URL of a zipped element inside the res.apk file, e.g. "apk:///foo/bar/res.apk!/res/layout/my_layout.xml"
  • + *
+ * + * @param relativeResourcePath the relative path of a file resource + * @return the URL pointing to the file resource + */ + @NotNull + String getResourceUrl(@NotNull String relativeResourcePath); + + /** + * Produces a {@link PathString} to be returned by the {@link BasicResourceItem#getSource()} method. + * + * @param relativeResourcePath the relative path of the file the resource was created from + * @param forFileResource true is the resource is a file resource, false if it is a value resource + * @return the PathString to be returned by the {@link BasicResourceItem#getSource()} method + */ + @NotNull + PathString getSourceFile(@NotNull String relativeResourcePath, boolean forFileResource); + + /** + * Produces a {@link PathString} to be returned by the {@link BasicResourceItem#getOriginalSource()} method. + * + * @param relativeResourcePath the relative path of the file the resource was created from + * @param forFileResource true is the resource is a file resource, false if it is a value resource + * @return the PathString to be returned by the {@link BasicResourceItem#getOriginalSource()} method + */ + @Nullable + default PathString getOriginalSourceFile(@NotNull String relativeResourcePath, boolean forFileResource) { + return getSourceFile(relativeResourcePath, forFileResource); + } + + /** + * Creates a {@link ResourceSourceFile} by reading its contents from the given stream. + * + * @param stream the stream to read data from + * @param configurations the repository configurations to select from when creating the ResourceSourceFile + * @return the created {@link ResourceSourceFile} + */ + @NotNull + default ResourceSourceFile deserializeResourceSourceFile( + @NotNull Base128InputStream stream, @NotNull List configurations) throws IOException { + return ResourceSourceFileImpl.deserialize(stream, configurations); + } + + /** + * Creates a {@link BasicFileResourceItem} by reading its contents from the given stream. + * + * @param stream the stream to read data from + * @param resourceType the type of the resource + * @param name the name of the resource + * @param visibility the visibility of the resource + * @param configurations the repository configurations to select from when creating the ResourceSourceFile + * @return the created {@link BasicFileResourceItem} + */ + @NotNull + default BasicFileResourceItem deserializeFileResourceItem( + @NotNull Base128InputStream stream, + @NotNull ResourceType resourceType, + @NotNull String name, + @NotNull ResourceVisibility visibility, + @NotNull List configurations) throws IOException { + return BasicFileResourceItem.deserialize(stream, resourceType, name, visibility, configurations); + } + + boolean containsUserDefinedResources(); +} diff --git a/src/main/java/com/android/resources/base/NamespaceResolver.java b/src/main/java/com/android/resources/base/NamespaceResolver.java new file mode 100644 index 0000000..6439337 --- /dev/null +++ b/src/main/java/com/android/resources/base/NamespaceResolver.java @@ -0,0 +1,120 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.resources.base; + +import com.android.ide.common.rendering.api.ResourceNamespace; +import com.android.utils.Base128InputStream; +import com.android.utils.Base128InputStream.StreamFormatException; +import com.android.utils.Base128OutputStream; +import com.intellij.util.ArrayUtil; +import java.io.IOException; +import java.util.Arrays; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserException; + +/** + * Simple implementation of the {@link ResourceNamespace.Resolver} interface intended to be used + * together with {@link XmlPullParser}. + */ +public final class NamespaceResolver implements ResourceNamespace.Resolver { + public static final NamespaceResolver EMPTY = new NamespaceResolver(ArrayUtil.EMPTY_STRING_ARRAY); + + /** Interleaved prefixes and the corresponding URIs in order of descending priority. */ + @NotNull private final String[] prefixesAndUris; + + NamespaceResolver(@NotNull XmlPullParser parser) throws XmlPullParserException { + int namespaceCount = parser.getNamespaceCount(parser.getDepth()); + prefixesAndUris = new String[namespaceCount * 2]; + for (int i = 0, j = prefixesAndUris.length; i < namespaceCount; i++) { + prefixesAndUris[--j] = parser.getNamespaceUri(i); + prefixesAndUris[--j] = parser.getNamespacePrefix(i); + } + } + + private NamespaceResolver(@NotNull String[] prefixesAndUris) { + this.prefixesAndUris = prefixesAndUris; + } + + int getNamespaceCount() { + return prefixesAndUris.length / 2; + } + + @Override + @Nullable + public String prefixToUri(@NotNull String namespacePrefix) { + for (int i = 0; i < prefixesAndUris.length; i += 2) { + if (namespacePrefix.equals(prefixesAndUris[i])) { + return prefixesAndUris[i + 1]; + } + } + return null; + } + + @Override + @Nullable + public String uriToPrefix(@NotNull String namespaceUri) { + for (int i = 0; i < prefixesAndUris.length; i += 2) { + if (namespaceUri.equals(prefixesAndUris[i + 1])) { + return prefixesAndUris[i]; + } + } + return null; + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) return true; + if (obj == null || getClass() != obj.getClass()) return false; + + NamespaceResolver other = (NamespaceResolver)obj; + return Arrays.equals(prefixesAndUris, other.prefixesAndUris); + } + + @Override + public int hashCode() { + return Arrays.hashCode(prefixesAndUris); + } + + /** + * Serializes the namespace resolver to the given stream. + */ + public void serialize(@NotNull Base128OutputStream stream) throws IOException { + stream.writeInt(getNamespaceCount()); + for (String str : prefixesAndUris) { + stream.writeString(str); + } + } + + /** + * Creates a namespace resolver by reading its contents from the given stream. + * @see #serialize(Base128OutputStream) + */ + @NotNull + public static NamespaceResolver deserialize(@NotNull Base128InputStream stream) throws IOException { + int n = stream.readInt() * 2; + String[] prefixesAndUris = new String[n]; + for (int i = 0; i < n; i++) { + String s = stream.readString(); + if (s == null) { + throw StreamFormatException.invalidFormat(); + } + prefixesAndUris[i] = s; + } + return new NamespaceResolver(prefixesAndUris); + } +} diff --git a/src/main/java/com/android/resources/base/RepositoryConfiguration.kt b/src/main/java/com/android/resources/base/RepositoryConfiguration.kt new file mode 100644 index 0000000..6c7df2d --- /dev/null +++ b/src/main/java/com/android/resources/base/RepositoryConfiguration.kt @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.resources.base + +import com.android.ide.common.resources.configuration.FolderConfiguration +import com.android.utils.HashCodes + +/** + * A ([LoadableResourceRepository], [FolderConfiguration]) pair. Instances of [BasicResourceItemBase] contain + * a reference to an `RepositoryConfiguration` instead of two separate references to [LoadableResourceRepository] + * and [FolderConfiguration]. This indirection saves memory because the number of `RepositoryConfiguration` + * instances is tiny fraction of the number of [BasicResourceItemBase] instances. + */ +class RepositoryConfiguration(repository: LoadableResourceRepository, val folderConfiguration: FolderConfiguration) { + var repository = repository + private set + + /** + * Makes [repository] the owner of this `RepositoryConfiguration`. The new owner should be loaded from + * the same file or directory as the previous one, which means that changing the owner does not + * affect {@link #equals} or {@link #hashCode}. + */ + fun transferOwnershipTo(repository: LoadableResourceRepository) { + assert(this.repository.origin == repository.origin) + this.repository = repository + } + + /** + * Overridden to not distinguish between repositories loaded from the same file or folder. + */ + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as RepositoryConfiguration + + if (repository.origin != other.repository.origin) return false + if (folderConfiguration != other.folderConfiguration) return false + + return true + } + + /** + * Overridden to not distinguish between repositories loaded from the same file or folder. + */ + override fun hashCode(): Int { + return HashCodes.mix(repository.origin.hashCode(), folderConfiguration.hashCode()) + } +} diff --git a/src/main/java/com/android/resources/base/RepositoryLoader.java b/src/main/java/com/android/resources/base/RepositoryLoader.java new file mode 100644 index 0000000..d662b0f --- /dev/null +++ b/src/main/java/com/android/resources/base/RepositoryLoader.java @@ -0,0 +1,1402 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.resources.base; + +import static com.android.SdkConstants.ANDROID_NS_NAME; +import static com.android.SdkConstants.ATTR_FORMAT; +import static com.android.SdkConstants.ATTR_ID; +import static com.android.SdkConstants.ATTR_INDEX; +import static com.android.SdkConstants.ATTR_NAME; +import static com.android.SdkConstants.ATTR_PARENT; +import static com.android.SdkConstants.ATTR_QUANTITY; +import static com.android.SdkConstants.ATTR_TYPE; +import static com.android.SdkConstants.ATTR_VALUE; +import static com.android.SdkConstants.DOT_AAR; +import static com.android.SdkConstants.DOT_JAR; +import static com.android.SdkConstants.DOT_XML; +import static com.android.SdkConstants.DOT_ZIP; +import static com.android.SdkConstants.FD_RES_VALUES; +import static com.android.SdkConstants.NEW_ID_PREFIX; +import static com.android.SdkConstants.PREFIX_RESOURCE_REF; +import static com.android.SdkConstants.PREFIX_THEME_REF; +import static com.android.SdkConstants.TAG_ATTR; +import static com.android.SdkConstants.TAG_EAT_COMMENT; +import static com.android.SdkConstants.TAG_ENUM; +import static com.android.SdkConstants.TAG_FLAG; +import static com.android.SdkConstants.TAG_ITEM; +import static com.android.SdkConstants.TAG_PUBLIC; +import static com.android.SdkConstants.TAG_PUBLIC_GROUP; +import static com.android.SdkConstants.TAG_RESOURCES; +import static com.android.SdkConstants.TAG_SKIP; +import static com.android.SdkConstants.TAG_STAGING_PUBLIC_GROUP; +import static com.android.SdkConstants.TAG_STAGING_PUBLIC_GROUP_FINAL; +import static com.android.SdkConstants.TOOLS_URI; +import static com.android.ide.common.resources.AndroidAaptIgnoreKt.ANDROID_AAPT_IGNORE; +import static com.android.ide.common.resources.ResourceItem.ATTR_EXAMPLE; +import static com.android.ide.common.resources.ResourceItem.XLIFF_G_TAG; +import static com.android.ide.common.resources.ResourceItem.XLIFF_NAMESPACE_PREFIX; +import static com.intellij.util.io.URLUtil.JAR_PROTOCOL; +import static java.nio.charset.StandardCharsets.UTF_8; + +import com.android.ide.common.rendering.api.AttrResourceValue; +import com.android.ide.common.rendering.api.AttributeFormat; +import com.android.ide.common.rendering.api.DensityBasedResourceValue; +import com.android.ide.common.rendering.api.ResourceNamespace; +import com.android.ide.common.rendering.api.StyleItemResourceValue; +import com.android.ide.common.rendering.api.StyleItemResourceValueImpl; +import com.android.ide.common.resources.AndroidAaptIgnore; +import com.android.ide.common.resources.PatternBasedFileFilter; +import com.android.ide.common.resources.ResourceItem; +import com.android.ide.common.resources.ResourceRepository; +import com.android.ide.common.resources.ResourcesUtil; +import com.android.ide.common.resources.ValueResourceNameValidator; +import com.android.ide.common.resources.ValueXmlHelper; +import com.android.ide.common.resources.configuration.DensityQualifier; +import com.android.ide.common.resources.configuration.FolderConfiguration; +import com.android.ide.common.util.PathString; +import com.android.io.CancellableFileIo; +import com.android.resources.Arity; +import com.android.resources.Density; +import com.android.resources.FolderTypeRelationship; +import com.android.resources.ResourceFolderType; +import com.android.resources.ResourceType; +import com.android.resources.ResourceVisibility; +import com.android.utils.SdkUtils; +import com.android.utils.XmlUtils; +import com.google.common.base.Preconditions; +import com.google.common.collect.ArrayListMultimap; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.ListMultimap; +import com.google.common.collect.Maps; +import com.google.common.collect.Sets; +import com.google.common.collect.Table; +import com.google.common.collect.Tables; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.progress.ProcessCanceledException; +import com.intellij.openapi.progress.ProgressManager; +import com.intellij.openapi.util.text.StringUtil; +import com.intellij.util.containers.ContainerUtil; +import com.intellij.util.io.URLUtil; +import java.io.BufferedInputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.FileVisitResult; +import java.nio.file.FileVisitor; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Deque; +import java.util.EnumMap; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.kxml2.io.KXmlParser; +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserException; + +public abstract class RepositoryLoader implements FileFilter { + private static final Logger LOG = Logger.getInstance(RepositoryLoader.class); + /** The set of attribute formats that is used when no formats are explicitly specified and the attribute is not a flag or enum. */ + private final Set DEFAULT_ATTR_FORMATS = Sets.immutableEnumSet( + AttributeFormat.BOOLEAN, + AttributeFormat.COLOR, + AttributeFormat.DIMENSION, + AttributeFormat.FLOAT, + AttributeFormat.FRACTION, + AttributeFormat.INTEGER, + AttributeFormat.REFERENCE, + AttributeFormat.STRING); + private final PatternBasedFileFilter myFileFilter + = new PatternBasedFileFilter(new AndroidAaptIgnore(System.getenv(ANDROID_AAPT_IGNORE))); + + @NotNull private final Map> myPublicResources = new EnumMap<>(ResourceType.class); + @NotNull private final ListMultimap myAttrs = ArrayListMultimap.create(); + @NotNull private final ListMultimap myAttrCandidates = ArrayListMultimap.create(); + @NotNull private final ListMultimap myStyleables = ArrayListMultimap.create(); + @NotNull protected ResourceVisibility myDefaultVisibility = ResourceVisibility.PRIVATE; + /** Cache of FolderConfiguration instances, keyed by qualifier strings (see {@link FolderConfiguration#getQualifierString()}). */ + @NotNull protected final Map myFolderConfigCache = new HashMap<>(); + @NotNull private final Map myConfigCache = new HashMap<>(); + @NotNull private final ValueResourceXmlParser myParser = new ValueResourceXmlParser(); + @NotNull private final XmlTextExtractor myTextExtractor = new XmlTextExtractor(); + @NotNull private final ResourceUrlParser myUrlParser = new ResourceUrlParser(); + // Used to keep track of resources defined in the current value resource file. + @NotNull private final Table myValueFileResources = + Tables.newCustomTable(new EnumMap<>(ResourceType.class), LinkedHashMap::new); + @NotNull protected final Path myResourceDirectoryOrFile; + @NotNull private final PathString myResourceDirectoryOrFilePath; + private final boolean myLoadingFromZipArchive; + + @NotNull private final ResourceNamespace myNamespace; + @Nullable private final Collection myResourceFilesAndFolders; + @Nullable protected ZipFile myZipFile; + + public RepositoryLoader(@NotNull Path resourceDirectoryOrFile, @Nullable Collection resourceFilesAndFolders, + @NotNull ResourceNamespace namespace) { + myResourceDirectoryOrFile = resourceDirectoryOrFile; + myResourceDirectoryOrFilePath = new PathString(myResourceDirectoryOrFile); + myLoadingFromZipArchive = isZipArchive(resourceDirectoryOrFile); + myNamespace = namespace; + myResourceFilesAndFolders = resourceFilesAndFolders; + } + + @NotNull + public final Path getResourceDirectoryOrFile() { + return myResourceDirectoryOrFile; + } + + public final boolean isLoadingFromZipArchive() { + return myLoadingFromZipArchive; + } + + @NotNull + public final ResourceNamespace getNamespace() { + return myNamespace; + } + + public void loadRepositoryContents(@NotNull T repository) { + if (myLoadingFromZipArchive) { + loadFromZip(repository); + } + else { + loadFromResFolder(repository); + } + } + + public List getPublicXmlFileNames() { + return ImmutableList.of("public.xml"); + } + + protected void loadFromZip(@NotNull T repository) { + try (ZipFile zipFile = new ZipFile(myResourceDirectoryOrFile.toFile())) { + myZipFile = zipFile; + loadPublicResourceNames(); + boolean shouldParseResourceIds = !loadIdsFromRTxt(); + + zipFile.stream().forEach(zipEntry -> { + if (!zipEntry.isDirectory()) { + PathString path = new PathString(zipEntry.getName()); + loadResourceFile(path, repository, shouldParseResourceIds); + } + }); + } + catch (ProcessCanceledException e) { + throw e; + } + catch (Exception e) { + LOG.error("Failed to load resources from " + myResourceDirectoryOrFile.toString(), e); + } + finally { + myZipFile = null; + } + + finishLoading(repository); + } + + protected void loadFromResFolder(@NotNull T repository) { + try { + if (CancellableFileIo.notExists(myResourceDirectoryOrFile)) { + return; // Don't report errors if the resource directory doesn't exist. This happens in some tests. + } + + loadPublicResourceNames(); + boolean shouldParseResourceIds = !loadIdsFromRTxt(); + + List sourceFilesAndFolders = myResourceFilesAndFolders == null ? + ImmutableList.of(myResourceDirectoryOrFile) : + ContainerUtil.map(myResourceFilesAndFolders, PathString::toPath); + List resourceFiles = findResourceFiles(sourceFilesAndFolders); + for (PathString file : resourceFiles) { + loadResourceFile(file, repository, shouldParseResourceIds); + } + } + catch (ProcessCanceledException e) { + throw e; + } + catch (Exception e) { + LOG.error("Failed to load resources from " + myResourceDirectoryOrFile.toString(), e); + } + + finishLoading(repository); + } + + protected final void loadResourceFile(@NotNull PathString file, @NotNull T repository, boolean shouldParseResourceIds) { + String folderName = file.getParentFileName(); + if (folderName != null) { + FolderInfo folderInfo = FolderInfo.create(folderName, myFolderConfigCache); + if (folderInfo != null) { + RepositoryConfiguration configuration = getConfiguration(repository, folderInfo.configuration); + loadResourceFile(file, folderInfo, configuration, shouldParseResourceIds); + } + } + } + + protected void finishLoading(@NotNull T repository) { + processAttrsAndStyleables(); + } + + @NotNull + public final String getSourceFileProtocol() { + if (myLoadingFromZipArchive) { + return JAR_PROTOCOL; + } + else { + return "file"; + } + } + + @NotNull + public final String getResourcePathPrefix() { + if (myLoadingFromZipArchive) { + return portableFileName(myResourceDirectoryOrFile.toString()) + URLUtil.JAR_SEPARATOR + "res/"; + } + else { + return portableFileName(myResourceDirectoryOrFile.toString()) + '/'; + } + } + + @NotNull + public final String getResourceUrlPrefix() { + if (myLoadingFromZipArchive) { + return JAR_PROTOCOL + "://" + portableFileName(myResourceDirectoryOrFile.toString()) + URLUtil.JAR_SEPARATOR + "res/"; + } + else { + return portableFileName(myResourceDirectoryOrFile.toString()) + '/'; + } + } + + /** + * A hook for loading resource IDs from a R.txt file. This implementation does nothing but subclasses may override. + * + * @return true if the IDs were successfully loaded from R.txt + */ + protected boolean loadIdsFromRTxt() { + return false; + } + + @Override + public boolean isIgnored(@NotNull Path fileOrDirectory, @NotNull BasicFileAttributes attrs) { + if (fileOrDirectory.equals(myResourceDirectoryOrFile)) { + return false; + } + + return myFileFilter.isIgnored(fileOrDirectory.toString(), attrs.isDirectory()); + } + + /** + * Loads names of the public resources and populates {@link #myPublicResources}. + */ + protected void loadPublicResourceNames() { + Path valuesFolder = myResourceDirectoryOrFile.resolve(FD_RES_VALUES); + List fileNames = getPublicXmlFileNames(); + for (String fileName : fileNames) { + Path publicXmlFile = valuesFolder.resolve(fileName); + + try (InputStream stream = new BufferedInputStream(CancellableFileIo.newInputStream(publicXmlFile))) { + CommentTrackingXmlPullParser parser = new CommentTrackingXmlPullParser(); + parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false); + parser.setInput(stream, UTF_8.name()); + + String groupTag = null; + ResourceType groupType = null; + ResourceType lastType = null; + String lastTypeName = ""; + while (true) { + int event = parser.nextToken(); + if (event == XmlPullParser.START_TAG) { + if (parser.getName().equals(TAG_PUBLIC)) { + String name = null; + String typeName = groupType == null ? null : groupType.getName(); + for (int i = 0, n = parser.getAttributeCount(); i < n; i++) { + String attribute = parser.getAttributeName(i); + + if (attribute.equals(ATTR_NAME)) { + name = parser.getAttributeValue(i); + if (typeName != null) { + // Skip attributes other than "type" and "name". + break; + } + } + else if (attribute.equals(ATTR_TYPE)) { + typeName = parser.getAttributeValue(i); + } + } + + if (name != null && !name.startsWith("__removed") && (typeName != null || groupType != null) && + (parser.getLastComment() == null || !containsWord(parser.getLastComment(), "@hide"))) { + ResourceType type; + if (groupType != null) { + type = groupType; + } + else { + if (typeName.equals(lastTypeName)) { + type = lastType; + } + else { + type = ResourceType.fromXmlValue(typeName); + lastType = type; + lastTypeName = typeName; + } + } + + if (type != null) { + addPublicResourceName(type, name); + } + else { + LOG.error("Public resource declaration \"" + name + "\" of type " + typeName + " points to unknown resource type."); + } + } + } + else if (isPublicGroupTag(parser.getName())) { + groupTag = parser.getName(); + String typeName = parser.getAttributeValue(null, ATTR_TYPE); + groupType = typeName == null ? null : ResourceType.fromXmlValue(typeName); + } + } + else if (event == XmlPullParser.END_TAG) { + if (groupTag != null && groupTag.equals(parser.getName())) { + groupTag = null; + groupType = null; + } + } + else if (event == XmlPullParser.END_DOCUMENT) { + break; + } + } + } + catch (ProcessCanceledException e) { + throw e; + } + catch (NoSuchFileException e) { + // There is no public.xml. This not considered an error. + } + catch (Exception e) { + LOG.error("Can't read and parse " + publicXmlFile, e); + } + } + } + + private boolean isPublicGroupTag(@NotNull String tag) { + return tag.equals(TAG_PUBLIC_GROUP) || + tag.equals(TAG_STAGING_PUBLIC_GROUP) || + tag.equals(TAG_STAGING_PUBLIC_GROUP_FINAL); + } + + protected final void addPublicResourceName(ResourceType type, String name) { + Set names = myPublicResources.computeIfAbsent(type, t -> new HashSet<>()); + names.add(name); + } + + /** + * Checks if the given text contains the given word. + */ + private static boolean containsWord(@NotNull String text, @SuppressWarnings("SameParameterValue") @NotNull String word) { + int end = 0; + while (true) { + int start = text.indexOf(word, end); + if (start < 0) { + return false; + } + end = start + word.length(); + if ((start == 0 || Character.isWhitespace(text.charAt(start))) && + (end == text.length() || Character.isWhitespace(text.charAt(end)))) { + return true; + } + } + } + + @NotNull + private List findResourceFiles(@NotNull List filesOrFolders) { + ResourceFileCollector fileCollector = new ResourceFileCollector(this); + for (Path file : filesOrFolders) { + try { + CancellableFileIo.walkFileTree(file, fileCollector); + } + catch (IOException e) { + // All IOExceptions are logged by ResourceFileCollector. + } + } + for (IOException e : fileCollector.ioErrors) { + LOG.error("Error loading resources from " + myResourceDirectoryOrFile.toString(), e); + } + Collections.sort(fileCollector.resourceFiles); // Make sure that the files are in canonical order. + return fileCollector.resourceFiles; + } + + @NotNull + protected final RepositoryConfiguration getConfiguration(@NotNull T repository, @NotNull FolderConfiguration folderConfiguration) { + RepositoryConfiguration repositoryConfiguration = myConfigCache.get(folderConfiguration); + if (repositoryConfiguration != null) { + return repositoryConfiguration; + } + + repositoryConfiguration = new RepositoryConfiguration(repository, folderConfiguration); + myConfigCache.put(folderConfiguration, repositoryConfiguration); + return repositoryConfiguration; + } + + private void loadResourceFile(@NotNull PathString file, @NotNull FolderInfo folderInfo, @NotNull RepositoryConfiguration configuration, + boolean shouldParseResourceIds) { + if (folderInfo.resourceType == null) { + if (isXmlFile(file)) { + parseValueResourceFile(file, configuration); + } + } + else { + if (shouldParseResourceIds && folderInfo.isIdGenerating && isXmlFile(file)) { + parseIdGeneratingResourceFile(file, configuration); + } + + BasicFileResourceItem item = createFileResourceItem(file, folderInfo.resourceType, configuration); + addResourceItem(item); + } + } + + protected static boolean isXmlFile(@NotNull PathString file) { + return isXmlFile(file.getFileName()); + } + + protected static boolean isXmlFile(@NotNull String filename) { + return SdkUtils.endsWithIgnoreCase(filename, DOT_XML); + } + + @SuppressWarnings("unchecked") + private void addResourceItem(@NotNull BasicResourceItemBase item) { + addResourceItem(item, (T)item.getRepository()); + } + + protected abstract void addResourceItem(@NotNull BasicResourceItem item, @NotNull T repository); + + protected final void parseValueResourceFile(@NotNull PathString file, @NotNull RepositoryConfiguration configuration) { + try (InputStream stream = getInputStream(file)) { + ResourceSourceFile sourceFile = createResourceSourceFile(file, configuration); + myParser.setInput(stream, null); + + int event; + do { + event = myParser.nextToken(); + int depth = myParser.getDepth(); + if (event == XmlPullParser.START_TAG) { + if (myParser.getPrefix() != null) { + continue; + } + String tagName = myParser.getName(); + assert depth <= 2; // Deeper tags should be consumed by the createResourceItem method. + if (depth == 1) { + if (!tagName.equals(TAG_RESOURCES)) { + break; + } + } + else if (depth > 1) { + ResourceType resourceType = getResourceType(tagName, file); + if (resourceType != null && resourceType != ResourceType.PUBLIC) { + String resourceName = myParser.getAttributeValue(null, ATTR_NAME); + if (resourceName != null) { + validateResourceName(resourceName, resourceType, file); + BasicValueResourceItemBase item = createResourceItem(resourceType, resourceName, sourceFile); + addValueResourceItem(item); + } else { + // Skip the subtags when the tag of a valid resource type doesn't have a name. + skipSubTags(); + } + } + else { + skipSubTags(); + } + } + } + } while (event != XmlPullParser.END_DOCUMENT); + } + catch (ProcessCanceledException e) { + throw e; + } + // KXmlParser throws RuntimeException for an undefined prefix and an illegal attribute name. + catch (IOException | XmlPullParserException | XmlSyntaxException | RuntimeException e) { + handleParsingError(file, e); + } + + addValueFileResources(); + } + + @NotNull + protected ResourceSourceFile createResourceSourceFile(@NotNull PathString file, @NotNull RepositoryConfiguration configuration) { + return new ResourceSourceFileImpl(getResRelativePath(file), configuration); + } + + private void addValueResourceItem(@NotNull BasicValueResourceItemBase item) { + ResourceType resourceType = item.getType(); + // Add attr and styleable resources to intermediate maps to post-process them in the processAttrsAndStyleables + // method after all resources are loaded. + if (resourceType == ResourceType.ATTR) { + addAttr((BasicAttrResourceItem)item, myAttrs); + } + else if (resourceType == ResourceType.STYLEABLE) { + myStyleables.put(item.getName(), (BasicStyleableResourceItem)item); + } + else { + // For compatibility with resource merger code we add value resources first to a file-specific map, + // then move them to the global resource table. In case when there are multiple definitions of + // the same resource in a single XML file, this algorithm preserves only the last definition. + myValueFileResources.put(resourceType, item.getName(), item); + } + } + + protected final void addValueFileResources() { + for (BasicValueResourceItemBase item : myValueFileResources.values()) { + addResourceItem(item); + } + myValueFileResources.clear(); + } + + protected final void parseIdGeneratingResourceFile(@NotNull PathString file, @NotNull RepositoryConfiguration configuration) { + try (InputStream stream = getInputStream(file)) { + ResourceSourceFile sourceFile = createResourceSourceFile(file, configuration); + XmlPullParser parser = new KXmlParser(); + parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true); + parser.setInput(stream, null); + + int event; + do { + event = parser.nextToken(); + if (event == XmlPullParser.START_TAG) { + int numAttributes = parser.getAttributeCount(); + for (int i = 0; i < numAttributes; i++) { + String idValue = parser.getAttributeValue(i); + if (idValue.startsWith(NEW_ID_PREFIX) && idValue.length() > NEW_ID_PREFIX.length()) { + String resourceName = idValue.substring(NEW_ID_PREFIX.length()); + addIdResourceItem(resourceName, sourceFile); + } + } + } + } while (event != XmlPullParser.END_DOCUMENT); + } + catch (ProcessCanceledException e) { + throw e; + } + // KXmlParser throws RuntimeException for an undefined prefix and an illegal attribute name. + catch (IOException | XmlPullParserException | RuntimeException e) { + handleParsingError(file, e); + } + + addValueFileResources(); + } + + protected void handleParsingError(@NotNull PathString file, @NotNull Exception e) { + LOG.warn("Failed to parse " + file.toString(), e); + } + + @NotNull + protected InputStream getInputStream(@NotNull PathString file) throws IOException { + if (myZipFile == null) { + Path path = file.toPath(); + Preconditions.checkArgument(path != null); + return new BufferedInputStream(CancellableFileIo.newInputStream(path)); + } + else { + ProgressManager.checkCanceled(); + ZipEntry entry = myZipFile.getEntry(file.getPortablePath()); + if (entry == null) { + throw new NoSuchFileException(file.getPortablePath()); + } + return new BufferedInputStream(myZipFile.getInputStream(entry)); + } + } + + protected final void addIdResourceItem(@NotNull String resourceName, @NotNull ResourceSourceFile sourceFile) { + ResourceVisibility visibility = getVisibility(ResourceType.ID, resourceName); + BasicValueResourceItem item = new BasicValueResourceItem(ResourceType.ID, resourceName, sourceFile, visibility, null); + if (!resourceAlreadyDefined(item)) { // Don't create duplicate ID resources. + addValueResourceItem(item); + } + } + + @NotNull + private BasicFileResourceItem createFileResourceItem( + @NotNull PathString file, @NotNull ResourceType resourceType, @NotNull RepositoryConfiguration configuration) { + String resourceName = SdkUtils.fileNameToResourceName(file.getFileName()); + ResourceVisibility visibility = getVisibility(resourceType, resourceName); + Density density = null; + if (DensityBasedResourceValue.isDensityBasedResourceType(resourceType)) { + DensityQualifier densityQualifier = configuration.getFolderConfiguration().getDensityQualifier(); + if (densityQualifier != null) { + density = densityQualifier.getValue(); + } + } + return createFileResourceItem(file, resourceType, resourceName, configuration, visibility, density); + } + + @NotNull + protected final BasicFileResourceItem createFileResourceItem(@NotNull PathString file, + @NotNull ResourceType type, + @NotNull String name, + @NotNull RepositoryConfiguration configuration, + @NotNull ResourceVisibility visibility, + @Nullable Density density) { + String relativePath = getResRelativePath(file); + return density == null ? + new BasicFileResourceItem(type, name, configuration, visibility, relativePath) : + new BasicDensityBasedFileResourceItem(type, name, configuration, visibility, relativePath, density); + } + + @NotNull + private BasicValueResourceItemBase createResourceItem( + @NotNull ResourceType type, @NotNull String name, @NotNull ResourceSourceFile sourceFile) + throws IOException, XmlPullParserException, XmlSyntaxException { + switch (type) { + case ARRAY: + return createArrayItem(name, sourceFile); + + case ATTR: + return createAttrItem(name, sourceFile); + + case PLURALS: + return createPluralsItem(name, sourceFile); + + case STRING: + return createStringItem(type, name, sourceFile, true); + + case STYLE: + return createStyleItem(name, sourceFile); + + case STYLEABLE: + return createStyleableItem(name, sourceFile); + + case ANIMATOR: + case DRAWABLE: + case INTERPOLATOR: + case LAYOUT: + case MENU: + case MIPMAP: + case TRANSITION: + return createFileReferenceItem(type, name, sourceFile); + + default: + return createStringItem(type, name, sourceFile, false); + } + } + + @NotNull + private BasicArrayResourceItem createArrayItem(@NotNull String name, @NotNull ResourceSourceFile sourceFile) + throws IOException, XmlPullParserException, XmlSyntaxException { + String indexValue = myParser.getAttributeValue(TOOLS_URI, ATTR_INDEX); + ResourceNamespace.Resolver namespaceResolver = myParser.getNamespaceResolver(); + List values = new ArrayList<>(); + forSubTags(TAG_ITEM, () -> { + String text = myTextExtractor.extractText(myParser, false); + values.add(text); + }); + int index = 0; + if (indexValue != null) { + try { + index = Integer.parseUnsignedInt(indexValue); + } + catch (NumberFormatException e) { + throw new XmlSyntaxException( + "The value of the " + namespaceResolver.prefixToUri(TOOLS_URI) + ':' + ATTR_INDEX + " attribute is not a valid number.", + myParser, getDisplayName(sourceFile)); + } + if (index >= values.size()) { + throw new XmlSyntaxException( + "The value of the " + namespaceResolver.prefixToUri(TOOLS_URI) + ':' + ATTR_INDEX + " attribute is out of bounds.", + myParser, getDisplayName(sourceFile)); + } + } + ResourceVisibility visibility = getVisibility(ResourceType.ARRAY, name); + BasicArrayResourceItem item = new BasicArrayResourceItem(name, sourceFile, visibility, values, index); + item.setNamespaceResolver(namespaceResolver); + return item; + } + + @NotNull + private BasicAttrResourceItem createAttrItem(@NotNull String name, @NotNull ResourceSourceFile sourceFile) + throws IOException, XmlPullParserException, XmlSyntaxException { + ResourceNamespace.Resolver namespaceResolver = myParser.getNamespaceResolver(); + ResourceNamespace attrNamespace; + myUrlParser.parseResourceUrl(name); + if (myUrlParser.hasNamespacePrefix(ANDROID_NS_NAME)) { + attrNamespace = ResourceNamespace.ANDROID; + } else { + String prefix = myUrlParser.getNamespacePrefix(); + attrNamespace = ResourceNamespace.fromNamespacePrefix(prefix, myNamespace, myParser.getNamespaceResolver()); + if (attrNamespace == null) { + throw new XmlSyntaxException("Undefined prefix of attr resource name \"" + name + "\"", myParser, getDisplayName(sourceFile)); + } + } + name = myUrlParser.getName(); + + String description = myParser.getLastComment(); + String groupName = myParser.getAttrGroupComment(); + String formatString = myParser.getAttributeValue(null, ATTR_FORMAT); + Set formats = + StringUtil.isEmpty(formatString) ? EnumSet.noneOf(AttributeFormat.class) : AttributeFormat.parse(formatString); + + // The average number of enum or flag values is 7 for Android framework, so start with small maps. + Map valueMap = Maps.newHashMapWithExpectedSize(8); + Map descriptionMap = Maps.newHashMapWithExpectedSize(8); + forSubTags(null, () -> { + if (myParser.getPrefix() == null) { + String tagName = myParser.getName(); + AttributeFormat format = + tagName.equals(TAG_ENUM) ? AttributeFormat.ENUM : tagName.equals(TAG_FLAG) ? AttributeFormat.FLAGS : null; + if (format != null) { + formats.add(format); + String valueName = myParser.getAttributeValue(null, ATTR_NAME); + if (valueName != null) { + String valueDescription = myParser.getLastComment(); + if (valueDescription != null) { + descriptionMap.put(valueName, valueDescription); + } + String value = myParser.getAttributeValue(null, ATTR_VALUE); + Integer numericValue = null; + if (value != null) { + try { + // Integer.decode/parseInt can't deal with hex value > 0x7FFFFFFF so we use Long.decode instead. + numericValue = Long.decode(value).intValue(); + } + catch (NumberFormatException ignored) { + } + } + valueMap.put(valueName, numericValue); + } + } + } + }); + + BasicAttrResourceItem item; + if (attrNamespace.equals(myNamespace)) { + ResourceVisibility visibility = getVisibility(ResourceType.ATTR, name); + item = new BasicAttrResourceItem(name, sourceFile, visibility, description, groupName, formats, valueMap, descriptionMap); + } + else { + item = new BasicForeignAttrResourceItem(attrNamespace, name, sourceFile, description, groupName, formats, valueMap, descriptionMap); + } + + item.setNamespaceResolver(namespaceResolver); + return item; + } + + @NotNull + private BasicPluralsResourceItem createPluralsItem(@NotNull String name, @NotNull ResourceSourceFile sourceFile) + throws IOException, XmlPullParserException, XmlSyntaxException { + String defaultQuantity = myParser.getAttributeValue(TOOLS_URI, ATTR_QUANTITY); + ResourceNamespace.Resolver namespaceResolver = myParser.getNamespaceResolver(); + EnumMap values = new EnumMap<>(Arity.class); + forSubTags(TAG_ITEM, () -> { + String quantityValue = myParser.getAttributeValue(null, ATTR_QUANTITY); + if (quantityValue != null) { + Arity quantity = Arity.getEnum(quantityValue); + if (quantity != null) { + String text = myTextExtractor.extractText(myParser, false); + values.put(quantity, text); + } + } + }); + Arity defaultArity = null; + if (defaultQuantity != null) { + defaultArity = Arity.getEnum(defaultQuantity); + if (defaultArity == null || !values.containsKey(defaultArity)) { + throw new XmlSyntaxException( + "Invalid value of the " + namespaceResolver.prefixToUri(TOOLS_URI) + ':' + ATTR_QUANTITY + " attribute.", myParser, + getDisplayName(sourceFile)); + } + } + ResourceVisibility visibility = getVisibility(ResourceType.PLURALS, name); + BasicPluralsResourceItem item = new BasicPluralsResourceItem(name, sourceFile, visibility, values, defaultArity); + item.setNamespaceResolver(namespaceResolver); + return item; + } + + @NotNull + private BasicValueResourceItem createStringItem( + @NotNull ResourceType type, @NotNull String name, @NotNull ResourceSourceFile sourceFile, boolean withRowXml) + throws IOException, XmlPullParserException { + ResourceNamespace.Resolver namespaceResolver = myParser.getNamespaceResolver(); + String text = type == ResourceType.ID ? null : myTextExtractor.extractText(myParser, withRowXml); + String rawXml = type == ResourceType.ID ? null : myTextExtractor.getRawXml(); + assert withRowXml || rawXml == null; // Text extractor doesn't extract raw XML unless asked to do it. + ResourceVisibility visibility = getVisibility(type, name); + BasicValueResourceItem item = rawXml == null ? + new BasicValueResourceItem(type, name, sourceFile, visibility, text) : + new BasicTextValueResourceItem(type, name, sourceFile, visibility, text, rawXml); + item.setNamespaceResolver(namespaceResolver); + return item; + } + + @NotNull + private BasicStyleResourceItem createStyleItem(@NotNull String name, @NotNull ResourceSourceFile sourceFile) + throws IOException, XmlPullParserException { + ResourceNamespace.Resolver namespaceResolver = myParser.getNamespaceResolver(); + String parentStyle = myParser.getAttributeValue(null, ATTR_PARENT); + if (parentStyle != null && !parentStyle.isEmpty()) { + myUrlParser.parseResourceUrl(parentStyle); + parentStyle = myUrlParser.getQualifiedName(); + } + List styleItems = new ArrayList<>(); + forSubTags(TAG_ITEM, () -> { + ResourceNamespace.Resolver itemNamespaceResolver = myParser.getNamespaceResolver(); + String itemName = myParser.getAttributeValue(null, ATTR_NAME); + if (itemName != null) { + String text = myTextExtractor.extractText(myParser, false); + StyleItemResourceValueImpl styleItem = + new StyleItemResourceValueImpl(myNamespace, itemName, text, sourceFile.getRepository().getLibraryName()); + styleItem.setNamespaceResolver(itemNamespaceResolver); + styleItems.add(styleItem); + } + }); + ResourceVisibility visibility = getVisibility(ResourceType.STYLE, name); + BasicStyleResourceItem item = new BasicStyleResourceItem(name, sourceFile, visibility, parentStyle, styleItems); + item.setNamespaceResolver(namespaceResolver); + return item; + } + + @NotNull + private BasicStyleableResourceItem createStyleableItem(@NotNull String name, @NotNull ResourceSourceFile sourceFile) + throws IOException, XmlPullParserException { + ResourceNamespace.Resolver namespaceResolver = myParser.getNamespaceResolver(); + List attrs = new ArrayList<>(); + forSubTags(TAG_ATTR, () -> { + String attrName = myParser.getAttributeValue(null, ATTR_NAME); + if (attrName != null) { + try { + BasicAttrResourceItem attr = createAttrItem(attrName, sourceFile); + // Mimic behavior of AAPT2 and put an attr reference inside a styleable resource. + attrs.add(attr.getFormats().isEmpty() ? attr : attr.createReference()); + + // Don't create top-level attr resources in a foreign namespace, or for attr references in the res-auto namespace. + // The second condition is determined by the fact that the attr in the res-auto namespace may have an explicit definition + // outside of this resource repository. + if (attr.getNamespace().equals(myNamespace) && (myNamespace != ResourceNamespace.RES_AUTO || !attr.getFormats().isEmpty())) { + addAttr(attr, myAttrCandidates); + } + } + catch (XmlSyntaxException e) { + LOG.error(e); + } + } + }); + // AAPT2 treats all styleable resources as public. + // See https://android.googlesource.com/platform/frameworks/base/+/master/tools/aapt2/ResourceParser.cpp#1539 + BasicStyleableResourceItem item = new BasicStyleableResourceItem(name, sourceFile, ResourceVisibility.PUBLIC, attrs); + item.setNamespaceResolver(namespaceResolver); + return item; + } + + private static void addAttr(@NotNull BasicAttrResourceItem attr, @NotNull ListMultimap map) { + List attrs = map.get(attr.getName()); + int i = findResourceWithSameNameAndConfiguration(attr, attrs); + if (i >= 0) { + // Found a matching attr definition. + BasicAttrResourceItem existing = attrs.get(i); + if (!attr.getFormats().isEmpty()) { + if (existing.getFormats().isEmpty()) { + attrs.set(i, attr); // Use the new attr since it contains more information than the existing one. + } + else if (!attr.getFormats().equals(existing.getFormats())) { + // Both, the existing and the new attr contain formats, but they are not the same. + // Assign union of formats to both attr definitions. + if (attr.getFormats().containsAll(existing.getFormats())) { + existing.setFormats(attr.getFormats()); + } + else if (existing.getFormats().containsAll(attr.getFormats())) { + attr.setFormats(existing.getFormats()); + } + else { + Set formats = EnumSet.copyOf(attr.getFormats()); + formats.addAll(existing.getFormats()); + formats = ImmutableSet.copyOf(formats); + attr.setFormats(formats); + existing.setFormats(formats); + } + } + } + if (existing.getFormats().isEmpty() && !attr.getFormats().isEmpty()) { + attrs.set(i, attr); // Use the new attr since it contains more information than the existing one. + } + } + else { + attrs.add(attr); + } + } + + /** + * Adds attr definitions from {@link #myAttrs}, and attr definition candidates from {@link #myAttrCandidates} + * if they don't match the attr definitions present in {@link #myAttrs}. + */ + private void processAttrsAndStyleables() { + for (BasicAttrResourceItem attr : myAttrs.values()) { + addAttrWithAdjustedFormats(attr); + } + + for (BasicAttrResourceItem attr : myAttrCandidates.values()) { + List attrs = myAttrs.get(attr.getName()); + int i = findResourceWithSameNameAndConfiguration(attr, attrs); + if (i < 0) { + addAttrWithAdjustedFormats(attr); + } + } + + // Resolve attribute references where it can be done without loosing any data to reduce resource memory footprint. + for (BasicStyleableResourceItem styleable : myStyleables.values()) { + addResourceItem(resolveAttrReferences(styleable)); + } + } + + /** + * Returns a styleable with attr references replaced by attr definitions returned by + * the {@link BasicStyleableResourceItem#getCanonicalAttr} method. + */ + @NotNull + public static BasicStyleableResourceItem resolveAttrReferences(@NotNull BasicStyleableResourceItem styleable) { + ResourceRepository repository = styleable.getRepository(); + List attributes = styleable.getAllAttributes(); + List resolvedAttributes = null; + for (int i = 0; i < attributes.size(); i++) { + AttrResourceValue attr = attributes.get(i); + AttrResourceValue canonicalAttr = BasicStyleableResourceItem.getCanonicalAttr(attr, repository); + if (canonicalAttr != attr) { + if (resolvedAttributes == null) { + resolvedAttributes = new ArrayList<>(attributes.size()); + for (int j = 0; j < i; j++) { + resolvedAttributes.add(attributes.get(j)); + } + } + resolvedAttributes.add(canonicalAttr); + } + else if (resolvedAttributes != null) { + resolvedAttributes.add(attr); + } + } + + if (resolvedAttributes != null) { + ResourceNamespace.Resolver namespaceResolver = styleable.getNamespaceResolver(); + styleable = + new BasicStyleableResourceItem(styleable.getName(), styleable.getSourceFile(), styleable.getVisibility(), resolvedAttributes); + styleable.setNamespaceResolver(namespaceResolver); + } + return styleable; + } + + private void addAttrWithAdjustedFormats(@NotNull BasicAttrResourceItem attr) { + if (attr.getFormats().isEmpty()) { + attr = new BasicAttrResourceItem(attr.getName(), attr.getSourceFile(), attr.getVisibility(), attr.getDescription(), + attr.getGroupName(), DEFAULT_ATTR_FORMATS, Collections.emptyMap(), Collections.emptyMap()); + } + addResourceItem(attr); + } + + /** + * Checks if resource with the same name, type and configuration has already been defined. + * + * @param resource the resource to check + * @return true if a matching resource already exists + */ + private static boolean resourceAlreadyDefined(@NotNull BasicResourceItemBase resource) { + ResourceRepository repository = resource.getRepository(); + List items = repository.getResources(resource.getNamespace(), resource.getType(), resource.getName()); + return findResourceWithSameNameAndConfiguration(resource, items) >= 0; + } + + private static int findResourceWithSameNameAndConfiguration(@NotNull ResourceItem resource, @NotNull List items) { + for (int i = 0; i < items.size(); i++) { + ResourceItem item = items.get(i); + if (item.getConfiguration().equals(resource.getConfiguration())) { + return i; + } + } + return -1; + } + + @NotNull + private BasicValueResourceItem createFileReferenceItem( + @NotNull ResourceType type, @NotNull String name, @NotNull ResourceSourceFile sourceFile) + throws IOException, XmlPullParserException { + ResourceNamespace.Resolver namespaceResolver = myParser.getNamespaceResolver(); + String text = myTextExtractor.extractText(myParser, false).trim(); + if (!text.isEmpty() && !text.startsWith(PREFIX_RESOURCE_REF) && !text.startsWith(PREFIX_THEME_REF)) { + text = text.replace('/', File.separatorChar); + } + ResourceVisibility visibility = getVisibility(type, name); + BasicValueResourceItem item = new BasicValueResourceItem(type, name, sourceFile, visibility, text); + item.setNamespaceResolver(namespaceResolver); + return item; + } + + @Nullable + private ResourceType getResourceType(@NotNull String tagName, @NotNull PathString file) throws XmlSyntaxException { + ResourceType type = ResourceType.fromXmlTagName(tagName); + + if (type == null) { + if (TAG_EAT_COMMENT.equals(tagName) || TAG_SKIP.equals(tagName)) { + return null; + } + + if ("java-symbol".equals(tagName)) { + // java-symbol is only used within framework and does not provide any public + // information so we can safely ignore it. + return null; + } + + if (tagName.equals(TAG_ITEM)) { + String typeAttr = myParser.getAttributeValue(null, ATTR_TYPE); + if (typeAttr != null) { + type = ResourceType.fromClassName(typeAttr); + if (type != null) { + return type; + } + + LOG.warn("Unrecognized type attribute \"" + typeAttr + "\" at " + getDisplayName(file) + " line " + myParser.getLineNumber()); + } + } + else { + LOG.warn("Unrecognized tag name \"" + tagName + "\" at " + getDisplayName(file) + " line " + myParser.getLineNumber()); + } + } + + return type; + } + + /** + * If {@code tagName} is null, calls {@code subtagVisitor.visitTag()} for every subtag of the current tag. + * If {@code tagName} is not null, calls {@code subtagVisitor.visitTag()} for every subtag of the current tag + * which name doesn't have a prefix and matches {@code tagName}. + */ + private void forSubTags(@Nullable String tagName, @NotNull XmlTagVisitor subtagVisitor) throws IOException, XmlPullParserException { + int elementDepth = myParser.getDepth(); + int event; + do { + event = myParser.nextToken(); + if (event == XmlPullParser.START_TAG && (tagName == null || tagName.equals(myParser.getName()) && myParser.getPrefix() == null)) { + subtagVisitor.visitTag(); + } + } while (event != XmlPullParser.END_DOCUMENT && (event != XmlPullParser.END_TAG || myParser.getDepth() > elementDepth)); + } + + /** + * Skips all subtags of the current tag. When the method returns, the parser is positioned at the end tag + * of the current element. + */ + private void skipSubTags() throws IOException, XmlPullParserException { + int elementDepth = myParser.getDepth(); + int event; + do { + event = myParser.nextToken(); + } while (event != XmlPullParser.END_DOCUMENT && (event != XmlPullParser.END_TAG || myParser.getDepth() > elementDepth)); + } + + private void validateResourceName(@NotNull String resourceName, @NotNull ResourceType resourceType, @NotNull PathString file) + throws XmlSyntaxException { + String error = ValueResourceNameValidator.getErrorText(resourceName, resourceType); + if (error != null) { + throw new XmlSyntaxException(error, myParser, getDisplayName(file)); + } + } + + @NotNull + private String getDisplayName(@NotNull PathString file) { + return file.isAbsolute() ? file.getNativePath() : file.getPortablePath() + " in " + myResourceDirectoryOrFile.toString(); + } + + @NotNull + private String getDisplayName(@NotNull ResourceSourceFile sourceFile) { + String relativePath = sourceFile.getRelativePath(); + Preconditions.checkArgument(relativePath != null); + return getDisplayName(new PathString(relativePath)); + } + + @NotNull + protected final ResourceVisibility getVisibility(@NotNull ResourceType resourceType, @NotNull String resourceName) { + Set names = myPublicResources.get(resourceType); + return names != null && names.contains(getKeyForVisibilityLookup(resourceName)) ? ResourceVisibility.PUBLIC : myDefaultVisibility; + } + + /** + * Transforms the given resource name to a key for lookup in myPublicResources. + */ + @NotNull + protected String getKeyForVisibilityLookup(@NotNull String resourceName) { + // In public.txt all resource names are transformed by replacing dots, colons and dashes with underscores. + return ResourcesUtil.resourceNameToFieldName(resourceName); + } + + @NotNull + protected final String getResRelativePath(@NotNull PathString file) { + if (file.isAbsolute()) { + return myResourceDirectoryOrFilePath.relativize(file).getPortablePath(); + } + + // The path is already relative, drop the first "res" segment. + assert file.getNameCount() != 0; + assert file.segment(0).equals("res"); + return file.subpath(1, file.getNameCount()).getPortablePath(); + } + + private static boolean isZipArchive(@NotNull Path resourceDirectoryOrFile) { + String filename = resourceDirectoryOrFile.getFileName().toString(); + return SdkUtils.endsWithIgnoreCase(filename, DOT_AAR) || + SdkUtils.endsWithIgnoreCase(filename, DOT_JAR) || + SdkUtils.endsWithIgnoreCase(filename, DOT_ZIP); + } + + @NotNull + public static String portableFileName(@NotNull String fileName) { + return fileName.replace(File.separatorChar, '/'); + } + + private interface XmlTagVisitor { + /** Is called when the parser is positioned at a {@link XmlPullParser#START_TAG}. */ + void visitTag() throws IOException, XmlPullParserException; + } + + /** + * Information about a resource folder. + */ + protected static class FolderInfo { + @NotNull public final ResourceFolderType folderType; + @NotNull public final FolderConfiguration configuration; + @Nullable public final ResourceType resourceType; + public final boolean isIdGenerating; + + private FolderInfo(@NotNull ResourceFolderType folderType, + @NotNull FolderConfiguration configuration, + @Nullable ResourceType resourceType, + boolean isIdGenerating) { + this.configuration = configuration; + this.resourceType = resourceType; + this.folderType = folderType; + this.isIdGenerating = isIdGenerating; + } + + /** + * Returns a FolderInfo for the given folder name. + * + * @param folderName the name of a resource folder + * @param folderConfigCache the cache of FolderConfiguration objects keyed by qualifier strings + * @return the FolderInfo object, or null if folderName is not a valid name of a resource folder + */ + @Nullable + public static FolderInfo create(@NotNull String folderName, @NotNull Map folderConfigCache) { + ResourceFolderType folderType = ResourceFolderType.getFolderType(folderName); + if (folderType == null) { + return null; + } + + String qualifier = FolderConfiguration.getQualifier(folderName); + FolderConfiguration config = folderConfigCache.computeIfAbsent(qualifier, FolderConfiguration::getConfigForQualifierString); + if (config == null) { + return null; + } + config.normalizeByRemovingRedundantVersionQualifier(); + + ResourceType resourceType; + boolean isIdGenerating; + if (folderType == ResourceFolderType.VALUES) { + resourceType = null; + isIdGenerating = false; + } + else { + resourceType = FolderTypeRelationship.getNonIdRelatedResourceType(folderType); + isIdGenerating = FolderTypeRelationship.isIdGeneratingFolderType(folderType); + } + + return new FolderInfo(folderType, config, resourceType, isIdGenerating); + } + } + + private static class ResourceFileCollector implements FileVisitor { + @NotNull final List resourceFiles = new ArrayList<>(); + @NotNull final List ioErrors = new ArrayList<>(); + @NotNull final FileFilter fileFilter; + + private ResourceFileCollector(@NotNull FileFilter filter) { + fileFilter = filter; + } + + @Override + @NotNull + public FileVisitResult preVisitDirectory(@NotNull Path dir, @NotNull BasicFileAttributes attrs) { + if (fileFilter.isIgnored(dir, attrs)) { + return FileVisitResult.SKIP_SUBTREE; + } + return FileVisitResult.CONTINUE; + } + + @Override + @NotNull + public FileVisitResult visitFile(@NotNull Path file, @NotNull BasicFileAttributes attrs) { + if (fileFilter.isIgnored(file, attrs)) { + return FileVisitResult.SKIP_SUBTREE; + } + resourceFiles.add(new PathString(file)); + return FileVisitResult.CONTINUE; + } + + @Override + @NotNull + public FileVisitResult visitFileFailed(@NotNull Path file, @NotNull IOException exc) { + ioErrors.add(exc); + return FileVisitResult.CONTINUE; + } + + @Override + @NotNull + public FileVisitResult postVisitDirectory(@NotNull Path dir, @Nullable IOException exc) { + return FileVisitResult.CONTINUE; + } + } + + private static class XmlTextExtractor { + @NotNull private final StringBuilder text = new StringBuilder(); + @NotNull private final StringBuilder rawXml = new StringBuilder(); + @NotNull private final Deque textInclusionState = new ArrayDeque<>(); + private boolean nontrivialRawXml; + + @NotNull + String extractText(@NotNull XmlPullParser parser, boolean withRawXml) throws IOException, XmlPullParserException { + text.setLength(0); + rawXml.setLength(0); + textInclusionState.clear(); + nontrivialRawXml = false; + + int elementDepth = parser.getDepth(); + int event; + loop: + do { + event = parser.nextToken(); + switch (event) { + case XmlPullParser.START_TAG: { + String tagName = parser.getName(); + if (XLIFF_G_TAG.equals(tagName) && isXliffNamespace(parser.getNamespace())) { + boolean includeNestedText = getTextInclusionState(); + String example = parser.getAttributeValue(null, ATTR_EXAMPLE); + if (example != null) { + text.append('(').append(example).append(')'); + includeNestedText = false; + } + else { + String id = parser.getAttributeValue(null, ATTR_ID); + if (id != null && !id.equals("id")) { + text.append('$').append('{').append(id).append('}'); + includeNestedText = false; + } + } + textInclusionState.addLast(includeNestedText); + } + if (withRawXml) { + nontrivialRawXml = true; + rawXml.append('<'); + String prefix = parser.getPrefix(); + if (prefix != null) { + rawXml.append(prefix).append(':'); + } + rawXml.append(tagName); + int numAttr = parser.getAttributeCount(); + for (int i = 0; i < numAttr; i++) { + rawXml.append(' '); + String attributePrefix = parser.getAttributePrefix(i); + if (attributePrefix != null) { + rawXml.append(attributePrefix).append(':'); + } + rawXml.append(parser.getAttributeName(i)).append('=').append('"'); + XmlUtils.appendXmlAttributeValue(rawXml, parser.getAttributeValue(i)); + rawXml.append('"'); + } + rawXml.append('>'); + } + break; + } + + case XmlPullParser.END_TAG: { + if (parser.getDepth() <= elementDepth) { + break loop; + } + String tagName = parser.getName(); + if (withRawXml) { + rawXml.append('<').append('/'); + String prefix = parser.getPrefix(); + if (prefix != null) { + rawXml.append(prefix).append(':'); + } + rawXml.append(tagName).append('>'); + } + if (XLIFF_G_TAG.equals(tagName) && isXliffNamespace(parser.getNamespace())) { + textInclusionState.removeLast(); + } + break; + } + + case XmlPullParser.ENTITY_REF: + case XmlPullParser.TEXT: { + String textPiece = parser.getText(); + if (getTextInclusionState()) { + text.append(textPiece); + } + if (withRawXml) { + rawXml.append(textPiece); + } + break; + } + + case XmlPullParser.CDSECT: { + String textPiece = parser.getText(); + if (getTextInclusionState()) { + text.append(textPiece); + } + if (withRawXml) { + nontrivialRawXml = true; + rawXml.append(""); + } + break; + } + } + } while (event != XmlPullParser.END_DOCUMENT); + + return ValueXmlHelper.unescapeResourceString(text.toString(), false, true); + } + + private boolean getTextInclusionState() { + return textInclusionState.isEmpty() || textInclusionState.getLast(); + } + + @Nullable + String getRawXml() { + return nontrivialRawXml ? rawXml.toString() : null; + } + + private static boolean isXliffNamespace(@Nullable String namespaceUri) { + return namespaceUri != null && namespaceUri.startsWith(XLIFF_NAMESPACE_PREFIX); + } + } + + private static class XmlSyntaxException extends Exception { + XmlSyntaxException(@NotNull String error, @NotNull XmlPullParser parser, @NotNull String filename) { + super(error + " at " + filename + " line " + parser.getLineNumber()); + } + } +} diff --git a/src/main/java/com/android/resources/base/ResourceSerializationUtil.java b/src/main/java/com/android/resources/base/ResourceSerializationUtil.java new file mode 100644 index 0000000..0961adb --- /dev/null +++ b/src/main/java/com/android/resources/base/ResourceSerializationUtil.java @@ -0,0 +1,307 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.resources.base; + +import com.android.ide.common.rendering.api.AttrResourceValue; +import com.android.ide.common.rendering.api.ResourceNamespace; +import com.android.ide.common.rendering.api.ResourceValue; +import com.android.ide.common.rendering.api.StyleItemResourceValue; +import com.android.ide.common.resources.ResourceItem; +import com.android.ide.common.resources.configuration.FolderConfiguration; +import com.android.resources.ResourceType; +import com.android.utils.Base128InputStream; +import com.android.utils.Base128OutputStream; +import com.google.common.collect.ListMultimap; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.progress.ProgressManager; +import com.intellij.openapi.util.io.FileUtilRt; +import it.unimi.dsi.fastutil.objects.Object2IntMap; +import it.unimi.dsi.fastutil.objects.Object2IntMaps; +import it.unimi.dsi.fastutil.objects.Object2IntOpenHashMap; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Predicate; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Static methods for serialization and deserialization of resources implementing {@link BasicResourceItem} interface. + */ +public class ResourceSerializationUtil { + private static final Logger LOG = Logger.getInstance(ResourceSerializationUtil.class); + + /** + * Writes contents of a resource repository to a cache file on disk. + * + * The data is stored as follows: + *
    + *
  1. The header provided by the caller (sequence of bytes)
  2. + *
  3. Number of folder configurations (int)
  4. + *
  5. Qualifier strings of folder configurations (strings)
  6. + *
  7. Number of value resource files (int)
  8. + *
  9. Value resource files (see {@link ResourceSourceFile#serialize})
  10. + *
  11. Number of namespace resolvers (int)
  12. + *
  13. Serialized namespace resolvers (see {@link NamespaceResolver#serialize})
  14. + *
  15. Number of resource items (int)
  16. + *
  17. Serialized resource items (see {@link BasicResourceItemBase#serialize})
  18. + *
+ */ + public static void createPersistentCache(@NotNull Path cacheFile, @NotNull byte[] fileHeader, + @NotNull Base128StreamWriter contentWriter) { + // Try to delete the old cache file. + try { + Files.deleteIfExists(cacheFile); + } + catch (IOException e) { + LOG.warn("Unable to delete " + cacheFile.toString(), e); + } + + // Write to a temporary file first, then rename it to the final name. + Path tempFile; + try { + tempFile = FileUtilRt.createTempFile(cacheFile.getParent().toFile(), cacheFile.getFileName().toString(), ".tmp").toPath(); + } + catch (IOException e) { + LOG.error("Unable to create a temporary file in " + cacheFile.getParent().toString(), e); + return; + } + + try (Base128OutputStream stream = new Base128OutputStream(tempFile)) { + stream.write(fileHeader); + contentWriter.write(stream); + } + catch (Throwable e) { + LOG.error("Unable to create cache file " + tempFile.toString(), e); + deleteIgnoringErrors(tempFile); + return; + } + + try { + Files.move(tempFile, cacheFile, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (NoSuchFileException e) { + // Ignore. This may happen in tests if the "caches" directory was cleaned up by a test tear down. + } catch (IOException e) { + LOG.error("Unable to create cache file " + cacheFile.toString(), e); + deleteIgnoringErrors(tempFile); + } + } + + /** + * Writes resources to the given output stream. + * + * @param resources the resources to write + * @param stream the stream to write to + * @param configFilter only resources belonging to configurations satisfying this filter are written to the stream + */ + public static void writeResourcesToStream(@NotNull Map> resources, + @NotNull Base128OutputStream stream, + @NotNull Predicate configFilter) throws IOException { + Object2IntMap qualifierStringIndexes = new Object2IntOpenHashMap<>(); + qualifierStringIndexes.defaultReturnValue(-1); + Object2IntMap sourceFileIndexes = new Object2IntOpenHashMap<>(); + sourceFileIndexes.defaultReturnValue(-1); + Object2IntMap namespaceResolverIndexes = new Object2IntOpenHashMap<>(); + namespaceResolverIndexes.defaultReturnValue(-1); + int itemCount = 0; + Collection> resourceMaps = resources.values(); + + for (ListMultimap resourceMap : resourceMaps) { + for (ResourceItem item : resourceMap.values()) { + FolderConfiguration configuration = item.getConfiguration(); + if (configFilter.test(configuration)) { + String qualifier = configuration.getQualifierString(); + if (!qualifierStringIndexes.containsKey(qualifier)) { + qualifierStringIndexes.put(qualifier, qualifierStringIndexes.size()); + } + if (item instanceof BasicValueResourceItemBase) { + ResourceSourceFile sourceFile = ((BasicValueResourceItemBase)item).getSourceFile(); + if (!sourceFileIndexes.containsKey(sourceFile)) { + sourceFileIndexes.put(sourceFile, sourceFileIndexes.size()); + } + } + if (item instanceof ResourceValue) { + addToNamespaceResolverIndexes(((ResourceValue)item).getNamespaceResolver(), namespaceResolverIndexes); + } + if (item instanceof BasicStyleResourceItem) { + for (StyleItemResourceValue styleItem : ((BasicStyleResourceItem)item).getDefinedItems()) { + addToNamespaceResolverIndexes(styleItem.getNamespaceResolver(), namespaceResolverIndexes); + } + } + else if (item instanceof BasicStyleableResourceItem) { + for (AttrResourceValue attr : ((BasicStyleableResourceItem)item).getAllAttributes()) { + addToNamespaceResolverIndexes(attr.getNamespaceResolver(), namespaceResolverIndexes); + } + } + itemCount++; + } + } + } + + writeStrings(qualifierStringIndexes, stream); + writeSourceFiles(sourceFileIndexes, stream, qualifierStringIndexes); + writeNamespaceResolvers(namespaceResolverIndexes, stream); + + stream.writeInt(itemCount); + + for (ListMultimap resourceMap : resourceMaps) { + for (ResourceItem item : resourceMap.values()) { + FolderConfiguration configuration = item.getConfiguration(); + if (configFilter.test(configuration)) { + ((BasicResourceItemBase)item).serialize(stream, qualifierStringIndexes, sourceFileIndexes, namespaceResolverIndexes); + } + } + } + } + + private static void addToNamespaceResolverIndexes(@NotNull ResourceNamespace.Resolver resolver, + @NotNull Object2IntMap namespaceResolverIndexes) { + if (!namespaceResolverIndexes.containsKey(resolver)) { + namespaceResolverIndexes.put(resolver, namespaceResolverIndexes.size()); + } + } + + /** + * Loads resources from the given input stream and passes then to the given consumer. + * @see #writeResourcesToStream + */ + public static void readResourcesFromStream(@NotNull Base128InputStream stream, + @NotNull Map stringCache, + @Nullable Map namespaceResolverCache, + @NotNull LoadableResourceRepository repository, + @NotNull Consumer resourceConsumer) throws IOException { + stream.setStringCache(stringCache); // Enable string instance sharing to minimize memory consumption. + + int n = stream.readInt(); + if (n == 0) { + return; // Nothing to load. + } + List configurations = new ArrayList<>(n); + for (int i = 0; i < n; i++) { + String configQualifier = stream.readString(); + if (configQualifier == null) { + throw Base128InputStream.StreamFormatException.invalidFormat(); + } + FolderConfiguration folderConfig = FolderConfiguration.getConfigForQualifierString(configQualifier); + if (folderConfig == null) { + throw Base128InputStream.StreamFormatException.invalidFormat(); + } + configurations.add(new RepositoryConfiguration(repository, folderConfig)); + } + + n = stream.readInt(); + List newSourceFiles = new ArrayList<>(n); + for (int i = 0; i < n; i++) { + ResourceSourceFile sourceFile = repository.deserializeResourceSourceFile(stream, configurations); + newSourceFiles.add(sourceFile); + } + + n = stream.readInt(); + List newNamespaceResolvers = new ArrayList<>(n); + for (int i = 0; i < n; i++) { + NamespaceResolver namespaceResolver = NamespaceResolver.deserialize(stream); + if (namespaceResolverCache != null) { + namespaceResolver = namespaceResolverCache.computeIfAbsent(namespaceResolver, Function.identity()); + } + newNamespaceResolvers.add(namespaceResolver); + } + + n = stream.readInt(); + int cancellationCheckInterval = 500; // For framework repository without locale-specific resources cancellation check happens 32 times. + for (int i = 0; i < n; i++) { + if (i % cancellationCheckInterval == 0) { + ProgressManager.checkCanceled(); + } + BasicResourceItemBase item = BasicResourceItemBase.deserialize(stream, configurations, newSourceFiles, newNamespaceResolvers); + resourceConsumer.accept(item); + } + } + + /** + * Returns contents of a cache file header produced by the given writer code. + * + * @param headerWriter the writer object + * @return the cache file header contents in a byte array + */ + public static @NotNull byte[] getCacheFileHeader(@NotNull Base128StreamWriter headerWriter) { + ByteArrayOutputStream header = new ByteArrayOutputStream(); + try (Base128OutputStream stream = new Base128OutputStream(header)) { + headerWriter.write(stream); + } + catch (IOException e) { + throw new Error("Internal error", e); // An IOException in the try block above indicates a bug. + } + return header.toByteArray(); + } + private static void deleteIgnoringErrors(@NotNull Path file) { + try { + Files.deleteIfExists(file); + } catch (IOException ignored) { + } + } + + private static void writeStrings(@NotNull Object2IntMap qualifierStringIndexes, @NotNull Base128OutputStream stream) + throws IOException { + String[] strings = new String[qualifierStringIndexes.size()]; + for (Object2IntMap.Entry entry : Object2IntMaps.fastIterable(qualifierStringIndexes)) { + strings[entry.getIntValue()] = entry.getKey(); + } + stream.writeInt(strings.length); + for (String str : strings) { + stream.writeString(str); + } + } + + private static void writeSourceFiles(@NotNull Object2IntMap sourceFileIndexes, + @NotNull Base128OutputStream stream, + @NotNull Object2IntMap qualifierStringIndexes) throws IOException { + ResourceSourceFile[] sourceFiles = new ResourceSourceFile[sourceFileIndexes.size()]; + for (Object2IntMap.Entry entry : Object2IntMaps.fastIterable(sourceFileIndexes)) { + sourceFiles[entry.getIntValue()] = entry.getKey(); + } + stream.writeInt(sourceFiles.length); + for (ResourceSourceFile sourceFile : sourceFiles) { + sourceFile.serialize(stream, qualifierStringIndexes); + } + } + + private static void writeNamespaceResolvers(@NotNull Object2IntMap namespaceResolverIndexes, + @NotNull Base128OutputStream stream) throws IOException { + ResourceNamespace.Resolver[] resolvers = new ResourceNamespace.Resolver[namespaceResolverIndexes.size()]; + for (Object2IntMap.Entry entry : Object2IntMaps.fastIterable(namespaceResolverIndexes)) { + resolvers[entry.getIntValue()] = entry.getKey(); + } + stream.writeInt(resolvers.length); + for (ResourceNamespace.Resolver resolver : resolvers) { + NamespaceResolver serializableResolver = + resolver == ResourceNamespace.Resolver.EMPTY_RESOLVER ? NamespaceResolver.EMPTY : (NamespaceResolver)resolver; + serializableResolver.serialize(stream); + } + } + + public interface Base128StreamWriter { + void write(@NotNull Base128OutputStream stream) throws IOException; + } +} diff --git a/src/main/java/com/android/resources/base/ResourceSourceFile.kt b/src/main/java/com/android/resources/base/ResourceSourceFile.kt new file mode 100644 index 0000000..3f6cff9 --- /dev/null +++ b/src/main/java/com/android/resources/base/ResourceSourceFile.kt @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.resources.base + +import com.android.utils.Base128OutputStream +import it.unimi.dsi.fastutil.objects.Object2IntMap +import java.io.IOException + +/** + * Represents an XML file from which an Android resource was created. + */ +interface ResourceSourceFile { + /** + * The path of the file relative to the resource directory, or null if the source file + * of the resource is not available. + */ + val relativePath: String? + + /** + * The configuration the resource file is associated with. + */ + val configuration: RepositoryConfiguration + + val repository : LoadableResourceRepository + get() = configuration.repository + + /** + * Serializes the ResourceSourceFile to the given stream. + */ + @Throws(IOException::class) + fun serialize(stream: Base128OutputStream, configIndexes: Object2IntMap) +} diff --git a/src/main/java/com/android/resources/base/ResourceSourceFileImpl.kt b/src/main/java/com/android/resources/base/ResourceSourceFileImpl.kt new file mode 100644 index 0000000..9f7bc20 --- /dev/null +++ b/src/main/java/com/android/resources/base/ResourceSourceFileImpl.kt @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.resources.base + +import com.android.utils.Base128InputStream +import com.android.utils.Base128OutputStream +import it.unimi.dsi.fastutil.objects.Object2IntMap +import java.io.IOException + +/** + * A simple implementation of the [ResourceSourceFile] interface. + * + * [relativePath] path of the file relative to the resource directory, or null if the source file of the resource is not available + * [configuration] configuration the resource file is associated with + */ +data class ResourceSourceFileImpl( + override val relativePath: String?, + override val configuration: RepositoryConfiguration +): ResourceSourceFile { + @Throws(IOException::class) + override fun serialize(stream: Base128OutputStream, configIndexes: Object2IntMap) { + stream.writeString(relativePath) + stream.writeInt(configIndexes.getInt(configuration.folderConfiguration.qualifierString)) + } + + companion object { + /** + * Creates a ResourceSourceFileImpl by reading its contents from the given stream. + */ + @JvmStatic + @Throws(IOException::class) + fun deserialize(stream: Base128InputStream, configurations: List): ResourceSourceFileImpl { + val relativePath = stream.readString() + val configIndex = stream.readInt() + return ResourceSourceFileImpl(relativePath, configurations[configIndex]) + } + } +} diff --git a/src/main/java/com/android/resources/base/ResourceUrlParser.java b/src/main/java/com/android/resources/base/ResourceUrlParser.java new file mode 100644 index 0000000..e79f083 --- /dev/null +++ b/src/main/java/com/android/resources/base/ResourceUrlParser.java @@ -0,0 +1,150 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.resources.base; + +import com.android.SdkConstants; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Parser of resource URLs. Unlike {@link com.android.resources.ResourceUrl}, this class is resilient to URL syntax + * errors doesn't create any GC overhead. + */ +public final class ResourceUrlParser { + @NotNull private String resourceUrl = ""; + private int colonPos; + private int slashPos; + private int typeStart; + private int namespacePrefixStart; + private int nameStart; + + /** + * Parses resource URL and sets the fields of this object to point to different parts of the URL. + * + * @param resourceUrl the resource URL to parse + */ + public void parseResourceUrl(@NotNull String resourceUrl) { + this.resourceUrl = resourceUrl; + colonPos = -1; + slashPos = -1; + typeStart = -1; + namespacePrefixStart = -1; + + int prefixEnd; + if (resourceUrl.startsWith(SdkConstants.PREFIX_RESOURCE_REF)) { + if (resourceUrl.startsWith("@+")) { + prefixEnd = 2; + } else { + prefixEnd = 1; + } + } else if (resourceUrl.startsWith(SdkConstants.PREFIX_THEME_REF)) { + prefixEnd = 1; + } else { + prefixEnd = 0; + } + if (resourceUrl.startsWith("*", prefixEnd)) { + prefixEnd++; + } + + int len = resourceUrl.length(); + int start = prefixEnd; + loop: for (int i = prefixEnd; i < len; i++) { + char c = resourceUrl.charAt(i); + switch (c) { + case '/': + if (slashPos < 0) { + slashPos = i; + typeStart = start; + start = i + 1; + if (colonPos >= 0) { + break loop; + } + } + break; + + case ':': + if (colonPos < 0) { + colonPos = i; + namespacePrefixStart = start; + start = i + 1; + if (slashPos >= 0) { + break loop; + } + } + break; + } + } + nameStart = start; + } + + /** + * Returns the namespace prefix of the resource URL, or null if the URL doesn't contain a prefix. + */ + @Nullable + public String getNamespacePrefix() { + return colonPos >= 0 ? resourceUrl.substring(namespacePrefixStart, colonPos) : null; + } + + /** + * Returns the type of the resource URL, or null if the URL don't contain a type. + */ + @Nullable + public String getType() { + return slashPos >= 0 ? resourceUrl.substring(typeStart, slashPos) : null; + } + + /** + * Returns the name part of the resource URL. + */ + @NotNull + public String getName() { + return resourceUrl.substring(nameStart); + } + + /** + * Returns the qualified name of the resource without any prefix or type. + */ + @NotNull + public String getQualifiedName() { + if (colonPos < 0) { + return getName(); + } + if (nameStart == colonPos + 1) { + return resourceUrl.substring(namespacePrefixStart); + } + return resourceUrl.substring(namespacePrefixStart, colonPos + 1) + getName(); + } + + /** + * Checks if the resource URL has the given type. + */ + public boolean hasType(@NotNull String type) { + if (slashPos < 0) { + return false; + } + return slashPos == typeStart + type.length() && resourceUrl.startsWith(type, typeStart); + } + + /** + * Checks if the resource URL has the given namespace prefix. + */ + public boolean hasNamespacePrefix(@NotNull String namespacePrefix) { + if (colonPos < 0) { + return false; + } + return colonPos == namespacePrefixStart + namespacePrefix.length() && resourceUrl.startsWith(namespacePrefix, namespacePrefixStart); + } +} diff --git a/src/main/java/com/android/resources/base/ValueResourceXmlParser.java b/src/main/java/com/android/resources/base/ValueResourceXmlParser.java new file mode 100644 index 0000000..35c42c4 --- /dev/null +++ b/src/main/java/com/android/resources/base/ValueResourceXmlParser.java @@ -0,0 +1,102 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.resources.base; + +import com.android.ide.common.rendering.api.ResourceNamespace; +import com.google.common.base.Preconditions; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.xmlpull.v1.XmlPullParserException; + +import java.io.IOException; +import java.io.InputStream; +import java.io.Reader; +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Function; + +/** + * XML pull parser for value resource files. Provides access the resource namespace resolver + * for the current tag. + */ +class ValueResourceXmlParser extends CommentTrackingXmlPullParser { + @NotNull final Map namespaceResolverCache = new HashMap<>(); + @NotNull final Deque resolverStack = new ArrayDeque<>(4); + + /** + * Returns the namespace resolver for the current XML node. The parser has to be positioned on a start tag + * when this method is called. + */ + @NotNull + public ResourceNamespace.Resolver getNamespaceResolver() throws XmlPullParserException { + Preconditions.checkState(getEventType() == START_TAG); + if (resolverStack.isEmpty()) { + return ResourceNamespace.Resolver.EMPTY_RESOLVER; + } + NamespaceResolver resolver = resolverStack.getLast(); + return resolver.getNamespaceCount() == 0 ? ResourceNamespace.Resolver.EMPTY_RESOLVER : resolver; + } + + @Override + public void setInput(@NotNull Reader reader) throws XmlPullParserException { + super.setInput(reader); + resolverStack.clear(); + } + + @Override + public void setInput(@NotNull InputStream inputStream, @Nullable String encoding) throws XmlPullParserException { + super.setInput(inputStream, encoding); + resolverStack.clear(); + } + + @Override + public int nextToken() throws XmlPullParserException, IOException { + int token = super.nextToken(); + processToken(token); + return token; + } + + @Override + public int next() throws XmlPullParserException, IOException { + int token = super.next(); + processToken(token); + return token; + } + + private void processToken(int token) throws XmlPullParserException { + switch (token) { + case START_TAG: { + int namespaceCount = getNamespaceCount(getDepth()); + NamespaceResolver parent = resolverStack.isEmpty() ? null : resolverStack.getLast(); + NamespaceResolver current = parent != null && parent.getNamespaceCount() == namespaceCount ? + parent : getOrCreateResolver(); + resolverStack.add(current); + assert resolverStack.size() == getDepth(); + break; + } + + case END_TAG: + resolverStack.removeLast(); + break; + } + } + + private NamespaceResolver getOrCreateResolver() throws XmlPullParserException { + return namespaceResolverCache.computeIfAbsent(new NamespaceResolver(this), Function.identity()); + } +} diff --git a/src/main/kotlin/io/johnsonlee/playground/databind/BufferedImageSerializer.kt b/src/main/kotlin/io/johnsonlee/playground/databind/BufferedImageSerializer.kt new file mode 100644 index 0000000..0d7e4f9 --- /dev/null +++ b/src/main/kotlin/io/johnsonlee/playground/databind/BufferedImageSerializer.kt @@ -0,0 +1,15 @@ +package io.johnsonlee.playground.databind + +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.databind.SerializerProvider +import com.fasterxml.jackson.databind.ser.std.StdSerializer +import io.johnsonlee.playground.util.toBase64 +import java.awt.image.BufferedImage + +class BufferedImageSerializer : StdSerializer(BufferedImage::class.java) { + + override fun serialize(value: BufferedImage, gen: JsonGenerator, serializers: SerializerProvider) { + gen.writeString(value.toBase64("png")) + } + +} \ No newline at end of file diff --git a/src/main/kotlin/io/johnsonlee/playground/databind/ViewInfoListSerializer.kt b/src/main/kotlin/io/johnsonlee/playground/databind/ViewInfoListSerializer.kt new file mode 100644 index 0000000..e595519 --- /dev/null +++ b/src/main/kotlin/io/johnsonlee/playground/databind/ViewInfoListSerializer.kt @@ -0,0 +1,41 @@ +package io.johnsonlee.playground.databind + +import com.android.ide.common.rendering.api.ViewInfo +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.databind.JsonSerializer +import com.fasterxml.jackson.databind.SerializerProvider +import io.johnsonlee.playground.sandbox.logger + +class ViewInfoListSerializer : JsonSerializer>() { + + override fun serialize(value: List, gen: JsonGenerator, serializers: SerializerProvider) { + with(gen) { + writeStartArray() + value.forEach { viewInfo -> + serializeViewInfo(viewInfo) + } + writeEndArray() + } + } + + private fun JsonGenerator.serializeViewInfo(viewInfo: ViewInfo) { + writeStartObject() + writeObjectField(ViewInfoMixin::className.name, viewInfo.className) + writeObjectField(ViewInfoMixin::left.name, viewInfo.left.takeIf { it != Int.MIN_VALUE } ?: 0) + writeObjectField(ViewInfoMixin::top.name, viewInfo.top.takeIf { it != Int.MIN_VALUE } ?: 0) + writeObjectField(ViewInfoMixin::right.name, viewInfo.right.takeIf { it != Int.MIN_VALUE } ?: 0) + writeObjectField(ViewInfoMixin::bottom.name, viewInfo.bottom.takeIf { it != Int.MIN_VALUE } ?: 0) + writeObjectField(ViewInfoMixin::baseLine.name, viewInfo.baseLine.takeIf { it != Int.MIN_VALUE } ?: 0) + writeObjectField(ViewInfoMixin::leftMargin.name, viewInfo.leftMargin.takeIf { it != Int.MIN_VALUE } ?: 0) + writeObjectField(ViewInfoMixin::topMargin.name, viewInfo.topMargin.takeIf { it != Int.MIN_VALUE } ?: 0) + writeObjectField(ViewInfoMixin::rightMargin.name, viewInfo.rightMargin.takeIf { it != Int.MIN_VALUE } ?: 0) + writeObjectField(ViewInfoMixin::bottomMargin.name, viewInfo.bottomMargin.takeIf { it != Int.MIN_VALUE } ?: 0) + writeArrayFieldStart(ViewInfoMixin::children.name) + viewInfo.children.forEach { child -> + serializeViewInfo(child) + } + writeEndArray() + writeEndObject() + } + +} \ No newline at end of file diff --git a/src/main/kotlin/io/johnsonlee/playground/databind/ViewInfoMixin.kt b/src/main/kotlin/io/johnsonlee/playground/databind/ViewInfoMixin.kt new file mode 100644 index 0000000..ae4cf2b --- /dev/null +++ b/src/main/kotlin/io/johnsonlee/playground/databind/ViewInfoMixin.kt @@ -0,0 +1,41 @@ +package io.johnsonlee.playground.databind + +import com.android.ide.common.rendering.api.ViewInfo +import com.fasterxml.jackson.annotation.JsonProperty + +abstract class ViewInfoMixin { + + @get:JsonProperty("className") + abstract val className: String + + @get:JsonProperty("left") + abstract val left: Int + + @get:JsonProperty("top") + abstract val top: Int + + @get:JsonProperty("right") + abstract val right: Int + + @get:JsonProperty("bottom") + abstract val bottom: Int + + @get:JsonProperty("baseLine") + abstract val baseLine: Int + + @get:JsonProperty("leftMargin") + abstract val leftMargin: Int + + @get:JsonProperty("rightMargin") + abstract val rightMargin: Int + + @get:JsonProperty("topMargin") + abstract val topMargin: Int + + @get:JsonProperty("bottomMargin") + abstract val bottomMargin: Int + + @get:JsonProperty("children") + abstract val children: List + +} diff --git a/src/main/kotlin/io/johnsonlee/playground/sandbox/BridgeContextExtension.kt b/src/main/kotlin/io/johnsonlee/playground/sandbox/BridgeContextExtension.kt new file mode 100644 index 0000000..9ee49ee --- /dev/null +++ b/src/main/kotlin/io/johnsonlee/playground/sandbox/BridgeContextExtension.kt @@ -0,0 +1,7 @@ +package io.johnsonlee.playground.sandbox + +import android.view.BridgeInflater +import com.android.layoutlib.bridge.android.BridgeContext + +val BridgeContext.layoutInflater: BridgeInflater + get() = getSystemService("layout_inflater") as BridgeInflater \ No newline at end of file diff --git a/src/main/kotlin/io/johnsonlee/playground/sandbox/DeviceModel.kt b/src/main/kotlin/io/johnsonlee/playground/sandbox/DeviceModel.kt new file mode 100644 index 0000000..1e486b8 --- /dev/null +++ b/src/main/kotlin/io/johnsonlee/playground/sandbox/DeviceModel.kt @@ -0,0 +1,196 @@ +package io.johnsonlee.playground.sandbox + +import android.content.res.Configuration +import com.android.ide.common.rendering.api.HardwareConfig +import com.android.ide.common.resources.configuration.CountryCodeQualifier +import com.android.ide.common.resources.configuration.DensityQualifier +import com.android.ide.common.resources.configuration.FolderConfiguration +import com.android.ide.common.resources.configuration.KeyboardStateQualifier +import com.android.ide.common.resources.configuration.LayoutDirectionQualifier +import com.android.ide.common.resources.configuration.LocaleQualifier +import com.android.ide.common.resources.configuration.NavigationMethodQualifier +import com.android.ide.common.resources.configuration.NetworkCodeQualifier +import com.android.ide.common.resources.configuration.NightModeQualifier +import com.android.ide.common.resources.configuration.ResourceQualifier +import com.android.ide.common.resources.configuration.ScreenDimensionQualifier +import com.android.ide.common.resources.configuration.ScreenOrientationQualifier +import com.android.ide.common.resources.configuration.ScreenRatioQualifier +import com.android.ide.common.resources.configuration.ScreenRoundQualifier +import com.android.ide.common.resources.configuration.ScreenSizeQualifier +import com.android.ide.common.resources.configuration.TextInputMethodQualifier +import com.android.ide.common.resources.configuration.TouchScreenQualifier +import com.android.ide.common.resources.configuration.UiModeQualifier +import com.android.ide.common.resources.configuration.VersionQualifier +import com.android.resources.Density +import com.android.resources.Keyboard +import com.android.resources.KeyboardState +import com.android.resources.LayoutDirection +import com.android.resources.Navigation +import com.android.resources.NightMode +import com.android.resources.ScreenOrientation +import com.android.resources.ScreenRatio +import com.android.resources.ScreenRound +import com.android.resources.ScreenSize +import com.android.resources.TouchScreen +import com.android.resources.UiMode +import com.fasterxml.jackson.annotation.JsonFormat +import com.fasterxml.jackson.annotation.JsonIgnore +import org.xmlpull.v1.XmlPullParser +import org.xmlpull.v1.XmlPullParserFactory +import java.io.File +import java.io.FileInputStream +import java.util.Properties + +@JsonFormat(shape = JsonFormat.Shape.OBJECT) +enum class DeviceModel( + val screenHeight: Int, + val screenWidth: Int, + val xdpi: Int, + val ydpi: Int, + val released: String, + val orientation: ScreenOrientation = ScreenOrientation.PORTRAIT, + val uiMode: UiMode = UiMode.NORMAL, + val nightMode: NightMode = NightMode.NOTNIGHT, + val density: Density = Density.XHIGH, + val fontScale: Float = 1.0f, + val layoutDirection: LayoutDirection = LayoutDirection.LTR, + val locale: String? = null, + val ratio: ScreenRatio = ScreenRatio.NOTLONG, + val size: ScreenSize = ScreenSize.NORMAL, + val keyboard: Keyboard = Keyboard.NOKEY, + val touchScreen: TouchScreen = TouchScreen.FINGER, + val keyboardState: KeyboardState = KeyboardState.SOFT, + val softButtons: Boolean = true, + val navigation: Navigation = Navigation.NONAV, + val screenRound: ScreenRound = ScreenRound.NOTROUND +) { + + PIXEL_5( + screenHeight = 2340, + screenWidth = 1080, + xdpi = 442, + ydpi = 444, + density = Density.DPI_440, + ratio = ScreenRatio.LONG, + released = "October 15, 2020" + ) + + ; + + val resourceQualifiers = resourceConfiguration.qualifiers.sorted() + .map(ResourceQualifier::getFolderSegment) + .filterNot(String::isNullOrBlank) + + @get:JsonIgnore + val hardwareConfig: HardwareConfig + get() = HardwareConfig( + screenWidth, + screenHeight, + density, + xdpi.toFloat(), + ydpi.toFloat(), + size, + orientation, + screenRound, + softButtons + ) + + @get:JsonIgnore + val uiModeMask: Int + get() { + val nightMask = if (nightMode == NightMode.NIGHT) { + Configuration.UI_MODE_NIGHT_YES + } else { + Configuration.UI_MODE_NIGHT_NO + } + val typeMask = when (uiMode) { + UiMode.NORMAL -> Configuration.UI_MODE_TYPE_NORMAL + UiMode.DESK -> Configuration.UI_MODE_TYPE_DESK + UiMode.CAR -> Configuration.UI_MODE_TYPE_CAR + UiMode.TELEVISION -> Configuration.UI_MODE_TYPE_TELEVISION + UiMode.APPLIANCE -> Configuration.UI_MODE_TYPE_APPLIANCE + UiMode.WATCH -> Configuration.UI_MODE_TYPE_WATCH + UiMode.VR_HEADSET -> Configuration.UI_MODE_TYPE_VR_HEADSET + } + return nightMask or typeMask + } + + companion object { + fun loadProperties(path: File): Map { + val p = Properties() + path.inputStream().use(p::load) + return p.stringPropertyNames().associateWith(p::getProperty) + } + + private const val TAG_ATTR = "attr" + private const val TAG_ENUM = "enum" + private const val TAG_FLAG = "flag" + private const val ATTR_NAME = "name" + private const val ATTR_VALUE = "value" + + fun getEnumMap(path: File): Map> { + val map = mutableMapOf>() + val xmlPullParser = XmlPullParserFactory.newInstance().newPullParser().apply { + setInput(FileInputStream(path), null) + } + var eventType = xmlPullParser.eventType + var attr: String? = null + + while (eventType != XmlPullParser.END_DOCUMENT) { + when (eventType) { + XmlPullParser.START_TAG -> { + if (TAG_ATTR == xmlPullParser.name) { + attr = xmlPullParser.getAttributeValue(null, ATTR_NAME) + } else if (TAG_ENUM == xmlPullParser.name || TAG_FLAG == xmlPullParser.name) { + val name = xmlPullParser.getAttributeValue(null, ATTR_NAME) + val value = xmlPullParser.getAttributeValue(null, ATTR_VALUE) + val i = (java.lang.Long.decode(value) as Long).toInt() + + require(attr != null) + map.getOrPut(attr) { mutableMapOf() } + var attributeMap: MutableMap? = map[attr] + if (attributeMap == null) { + attributeMap = hashMapOf() + map[attr] = attributeMap + } + attributeMap[name] = i + } + } + XmlPullParser.END_TAG -> { + if (TAG_ATTR == xmlPullParser.name) { + attr = null + } + } + } + eventType = xmlPullParser.next() + } + + return map + } + } +} + +val DeviceModel.resourceConfiguration: FolderConfiguration + get() = FolderConfiguration.createDefault().apply { + densityQualifier = DensityQualifier(density) + navigationMethodQualifier = NavigationMethodQualifier(navigation) + screenDimensionQualifier = when { + screenWidth > screenHeight -> ScreenDimensionQualifier(screenWidth, screenHeight) + else -> ScreenDimensionQualifier(screenHeight, screenWidth) + } + screenRatioQualifier = ScreenRatioQualifier(ratio) + screenSizeQualifier = ScreenSizeQualifier(size) + textInputMethodQualifier = TextInputMethodQualifier(keyboard) + touchTypeQualifier = TouchScreenQualifier(touchScreen) + keyboardStateQualifier = KeyboardStateQualifier(keyboardState) + screenOrientationQualifier = ScreenOrientationQualifier(orientation) + screenRoundQualifier = ScreenRoundQualifier(screenRound) + updateScreenWidthAndHeight() + uiModeQualifier = UiModeQualifier(uiMode) + nightModeQualifier = NightModeQualifier(nightMode) + countryCodeQualifier = CountryCodeQualifier() + layoutDirectionQualifier = LayoutDirectionQualifier(layoutDirection) + networkCodeQualifier = NetworkCodeQualifier() + localeQualifier = locale?.let(LocaleQualifier::getQualifier) ?: LocaleQualifier() + versionQualifier = VersionQualifier() + } \ No newline at end of file diff --git a/src/main/kotlin/io/johnsonlee/playground/sandbox/DynamicResourceIdManager.kt b/src/main/kotlin/io/johnsonlee/playground/sandbox/DynamicResourceIdManager.kt new file mode 100644 index 0000000..3e90aa8 --- /dev/null +++ b/src/main/kotlin/io/johnsonlee/playground/sandbox/DynamicResourceIdManager.kt @@ -0,0 +1,54 @@ +package io.johnsonlee.playground.sandbox + +import com.android.ide.common.rendering.api.ResourceNamespace +import com.android.ide.common.rendering.api.ResourceReference +import com.android.resources.ResourceType + +class DynamicResourceIdManager { + + private class IdProvider(private val packageByte: Byte) { + private val counters: ShortArray = ShortArray(ResourceType.values().size) { 0xffff.toShort() } + + fun getNext(type: ResourceType): Int { + return buildResourceId(packageByte, (type.ordinal + 1).toByte(), --counters[type.ordinal]) + } + } + + private var nextPackageId = FIRST_PACKAGE_ID + + private val mutex = Any() + private val perNamespaceProviders = hashMapOf() + private val dynamicToIdMap = hashMapOf() + private val dynamicFromIdMap = hashMapOf() + + init { + perNamespaceProviders[ResourceNamespace.ANDROID] = IdProvider(0x01) + perNamespaceProviders[ResourceNamespace.RES_AUTO] = IdProvider(0x7f) + } + + fun findById(id: Int): ResourceReference? = dynamicFromIdMap[id] + + fun getOrGenerateId(resource: ResourceReference): Int { + val dynamicId = dynamicToIdMap[resource] + if (dynamicId != null) { + return dynamicId + } + + return synchronized(mutex) { + val provider = perNamespaceProviders.getOrPut(resource.namespace) { + IdProvider(nextPackageId++) + } + val newId = provider.getNext(resource.resourceType) + dynamicToIdMap[resource] = newId + dynamicFromIdMap[newId] = resource + newId + } + } + +} + +private const val FIRST_PACKAGE_ID: Byte = 0x02 + +private fun buildResourceId(packageId: Byte, typeId: Byte, entryId: Short): Int { + return (packageId.toInt() shl 24) or (typeId.toInt() shl 16) or (entryId.toInt() and 0xffff) +} \ No newline at end of file diff --git a/src/main/kotlin/io/johnsonlee/playground/sandbox/Environment.kt b/src/main/kotlin/io/johnsonlee/playground/sandbox/Environment.kt new file mode 100644 index 0000000..77ccb21 --- /dev/null +++ b/src/main/kotlin/io/johnsonlee/playground/sandbox/Environment.kt @@ -0,0 +1,224 @@ +package io.johnsonlee.playground.sandbox + +import android.os._Original_Build +import com.android.SdkConstants +import com.android.ide.common.rendering.api.LayoutlibCallback +import com.android.ide.common.xml.AndroidManifestParser +import com.android.layoutlib.bridge.Bridge +import com.android.resources.aar.AarSourceResourceRepository +import com.android.resources.aar.FrameworkResourceRepository +import com.fasterxml.jackson.annotation.JsonIgnore +import com.google.common.base.Objects +import io.johnsonlee.playground.sandbox.resources.AppResourceRepository +import io.johnsonlee.playground.util.getFieldReflectively +import io.johnsonlee.playground.util.setStaticValue +import io.johnsonlee.playground.util.toCanonicalFile +import okio.withLock +import org.slf4j.LoggerFactory +import java.io.File +import java.io.FileNotFoundException +import java.nio.file.Paths + +data class Environment( + val appDir: File = System.getProperty("user.dir").toCanonicalFile(), + val libDir: File = appDir.resolve("libs"), + val resourcePackageNames: List = getResourcePackageNames(libDir), + val localResourceDirs: List = emptyList(), + val moduleResourceDirs: List = emptyList(), + val libraryResourceDirs: List = getLibraryResourceDirs(libDir), + val allModuleAssetDirs: List = emptyList(), + val libraryAssetDirs: List = getLibraryAssetDirs(libDir), + val androidHome: File = Environment.androidHome, + val compileSdkVersion: Int = 31, + val platformDir: File = androidHome.resolve("platforms").resolve("android-${compileSdkVersion}") +) { + + val resDir: File = appDir.resolve("res") + + val assetsDir: File = appDir.resolve("assets") + + @JsonIgnore + val layoutlibCallback: LayoutlibCallback = LayoutlibCallbackImpl(this) + + val platformDataResDir = platformDir.resolve("data").resolve("res") + + @JsonIgnore + val frameworkResources = FrameworkResourceRepository.create(platformDataResDir.toPath(), emptySet(), null, false) + + @JsonIgnore + val projectResources = AppResourceRepository.create( + localResourceDirectories = localResourceDirs.map(::File), + moduleResourceDirectories = moduleResourceDirs.map(::File), + libraryRepositories = libraryResourceDirs.map { dir -> + val resourceDirPath = Paths.get(dir) + AarSourceResourceRepository.create(resourceDirPath, resourceDirPath.parent.toFile().name) + } + ) + + init { + if (!platformDir.exists()) { + val platformVersion = platformDir.name.substringAfterLast('-') + throw FileNotFoundException("Missing platform version ${platformVersion}. Install with sdkmanager --install \"platforms;android-${platformVersion}\"") + } + } + + fun newBridge(): Bridge = run { + val platformDataRoot = File(System.getProperty("user.dir")) + val platformDataDir = platformDataRoot.resolve("data") + val fontLocation = platformDataDir.resolve("fonts") + val nativeLibLocation = platformDataDir.resolve(Environment.nativeLibDir) + val icuLocation = platformDataDir.resolve("icu").resolve("icudt70l.dat") + val keyboardLocation = platformDataDir.resolve("keyboards").resolve("Generic.kcm") + val buildProp = platformDir.resolve("build.prop") + val attrs = platformDataResDir.resolve("values").resolve("attrs.xml") + val systemProperties = DeviceModel.loadProperties(buildProp) + mapOf("debug.choreographer.frametime" to "false") + + Bridge().apply { + check( + init( + systemProperties, + fontLocation, + nativeLibLocation.path, + icuLocation.path, + arrayOf(keyboardLocation.path), + DeviceModel.getEnumMap(attrs), + LayoutLogger + ) + ) + + configureBuildProperties() + forcePlatformSdkVersion(compileSdkVersion) + + Bridge.getLock().withLock { + Bridge.setLog(LayoutLogger) + } + } + } + + override fun hashCode(): Int = Objects.hashCode( + appDir, + libDir, + resourcePackageNames, + localResourceDirs.map(String::toCanonicalFile), + moduleResourceDirs.map(String::toCanonicalFile), + libraryResourceDirs.map(String::toCanonicalFile), + allModuleAssetDirs.map(String::toCanonicalFile), + libraryAssetDirs.map(String::toCanonicalFile), + androidHome, + compileSdkVersion, + platformDir, + resDir, + assetsDir + ) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Environment) return false + return appDir == other.appDir + && libDir == other.libDir + && resourcePackageNames == other.resourcePackageNames + && localResourceDirs.map(String::toCanonicalFile) == other.localResourceDirs.map(String::toCanonicalFile) + && moduleResourceDirs.map(String::toCanonicalFile) == other.moduleResourceDirs.map(String::toCanonicalFile) + && libraryResourceDirs.map(String::toCanonicalFile) == other.libraryResourceDirs.map(String::toCanonicalFile) + && allModuleAssetDirs.map(String::toCanonicalFile) == other.allModuleAssetDirs.map(String::toCanonicalFile) + && libraryAssetDirs.map(String::toCanonicalFile) == other.libraryAssetDirs.map(String::toCanonicalFile) + && androidHome == other.androidHome + && compileSdkVersion == other.compileSdkVersion + && platformDir == other.platformDir + && resDir == other.resDir + && assetsDir == other.assetsDir + } + + companion object { + + val androidHome: File + get() = System.getenv("ANDROID_SDK_ROOT")?.toCanonicalFile() + ?: System.getenv("ANDROID_HOME")?.toCanonicalFile() + ?: androidSdkPath.toCanonicalFile() + + val nativeLibDir: String + get() { + val osName = System.getProperty("os.name").lowercase() + val osLabel = when { + osName.startsWith("windows") -> "win" + osName.startsWith("mac") -> { + val arch = System.getProperty("os.arch").lowercase() + if (arch.startsWith("x86")) "mac" else "mac-arm" + } + + else -> "linux" + } + return "${osLabel}/lib64" + } + + private val androidSdkPath: String = run { + val osName = System.getProperty("os.name").lowercase() + if (osName.startsWith("windows")) { + "${System.getProperty("user.home")}\\AppData\\Local\\Android\\Sdk" + } else if (osName.startsWith("mac")) { + "${System.getProperty("user.home")}/Library/Android/sdk" + } else { + "/usr/local/share/android-sdk" + } + } + } +} + +@JvmSynthetic +internal val logger = LoggerFactory.getLogger("sandbox") + +private fun getLibraries(libDir: File): List { + return libDir.listFiles { f -> + f.resolve(SdkConstants.FN_ANDROID_MANIFEST_XML).exists() + }?.toList() ?: emptyList() +} + +private fun getResourcePackageNames(libDir: File): List { + return getLibraries(libDir).map { + AndroidManifestParser.parse(it.resolve(SdkConstants.FN_ANDROID_MANIFEST_XML).toPath()).`package` + } +} + +private fun getLibraryResourceDirs(libDir: File): List { + return getLibraries(libDir).map { + it.resolve("res") + }.filter(File::exists).map(File::getPath) +} + +private fun getLibraryAssetDirs(libDir: File): List { + return getLibraries(libDir).map { + it.resolve("assets") + }.filter(File::exists).map(File::getPath) +} + +private fun configureBuildProperties() { + val buildClass = Class.forName("android.os.Build") + val originalBuildClass = _Original_Build::class.java + + copyFieldsValue(originalBuildClass, buildClass) + + buildClass.classes.forEach { inner -> + val originalInnerClass = originalBuildClass.classes.single { it.simpleName == inner.simpleName } + copyFieldsValue(originalInnerClass, inner) + } +} + +private fun copyFieldsValue(from: Class<*>, to: Class<*>) { + to.fields.forEach { + try { + val originalField = from.getField(it.name) + to.getFieldReflectively(it.name).setStaticValue(originalField.get(null)) + } catch (e: Throwable) { + logger.warn("Failed to set ${to.name}.${it.name}") + } + } +} + +private fun forcePlatformSdkVersion(compileSdkVersion: Int) { + val buildVersionClass = try { + Class.forName("android.os.Build\$VERSION") + } catch (e: ClassNotFoundException) { + return + } + buildVersionClass.getFieldReflectively("SDK_INT").setStaticValue(compileSdkVersion) +} diff --git a/src/main/kotlin/io/johnsonlee/playground/sandbox/LayoutLogger.kt b/src/main/kotlin/io/johnsonlee/playground/sandbox/LayoutLogger.kt new file mode 100644 index 0000000..485417f --- /dev/null +++ b/src/main/kotlin/io/johnsonlee/playground/sandbox/LayoutLogger.kt @@ -0,0 +1,29 @@ +package io.johnsonlee.playground.sandbox + +import com.android.ide.common.rendering.api.ILayoutLog +import org.slf4j.LoggerFactory + +object LayoutLogger: ILayoutLog { + + private val logger = LoggerFactory.getLogger(LayoutLogger::class.java) + + override fun warning(tag: String?, message: String?, viewCookie: Any?, data: Any?) { + logger.warn("${tag}: ${message}") + } + + override fun fidelityWarning(tag: String?, message: String?, throwable: Throwable?, viewCookie: Any?, data: Any?) { + logger.warn("${tag}: ${message}", throwable) + } + + override fun error(tag: String?, message: String?, viewCookie: Any?, data: Any?) { + logger.error("${tag}: ${message}") + } + + override fun error(tag: String?, message: String?, throwable: Throwable?, viewCookie: Any?, data: Any?) { + logger.error("${tag}: ${message}", throwable) + } + + override fun logAndroidFramework(priority: Int, tag: String?, message: String?) { + logger.info("${tag} [${priority}]: ${message}") + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/johnsonlee/playground/sandbox/LayoutlibCallbackImpl.kt b/src/main/kotlin/io/johnsonlee/playground/sandbox/LayoutlibCallbackImpl.kt new file mode 100644 index 0000000..5818294 --- /dev/null +++ b/src/main/kotlin/io/johnsonlee/playground/sandbox/LayoutlibCallbackImpl.kt @@ -0,0 +1,178 @@ +package io.johnsonlee.playground.sandbox + +import com.android.AndroidXConstants +import com.android.SdkConstants +import com.android.ide.common.rendering.api.ActionBarCallback +import com.android.ide.common.rendering.api.AdapterBinding +import com.android.ide.common.rendering.api.ILayoutPullParser +import com.android.ide.common.rendering.api.LayoutlibCallback +import com.android.ide.common.rendering.api.ResourceNamespace +import com.android.ide.common.rendering.api.ResourceReference +import com.android.ide.common.rendering.api.ResourceValue +import com.android.ide.common.xml.AndroidManifestParser +import com.android.resources.ResourceType +import io.johnsonlee.playground.sandbox.parsers.LayoutPullParser +import io.johnsonlee.playground.sandbox.parsers.TagSnapshot +import org.kxml2.io.KXmlParser +import org.slf4j.LoggerFactory +import org.xmlpull.v1.XmlPullParser +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.FileInputStream +import java.io.FileNotFoundException +import java.lang.reflect.Modifier + +internal class LayoutlibCallbackImpl( + private val environment: Environment +) : LayoutlibCallback() { + + private val logger = LoggerFactory.getLogger(LayoutlibCallbackImpl::class.java) + + private val projectResources = mutableMapOf() + private val resources = mutableMapOf() + private val actionBarCallback = ActionBarCallback() + private val aaptDeclaredResources = mutableMapOf() + private val dynamicResourceIdManager = DynamicResourceIdManager() + private val loadedClasses = mutableMapOf>() + + private val appId: String by lazy { + environment.appDir.resolve(SdkConstants.FN_ANDROID_MANIFEST_XML).takeIf { + it.exists() + }?.let { manifest -> + AndroidManifestParser.parse(FileInputStream(manifest)).`package` + } ?: environment.appDir.name + } + + init { + initResources() + } + + override fun createXmlParserForPsiFile(fileName: String): XmlPullParser { + return createXmlParserForFile(fileName) + } + + override fun createXmlParserForFile(fileName: String): XmlPullParser { + return FileInputStream(fileName).use { input -> + val out = ByteArrayOutputStream() + input.copyTo(out) + KXmlParser().apply { + setInput(out.toByteArray().inputStream(), null) + setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true) + } + } + } + + override fun createXmlParser(): XmlPullParser = KXmlParser() + + override fun getApplicationId(): String = appId + + override fun loadView( + name: String, + constructorSignature: Array>, + constructorArgs: Array + ): Any? = createNewInstance(name, constructorSignature, constructorArgs) + + override fun loadClass( + name: String, + constructorSignature: Array>?, + constructorArgs: Array? + ): Any? = try { + when (name) { + AndroidXConstants.CLASS_RECYCLER_VIEW_ADAPTER.newName() -> createNewInstance( + CLASS_ANDROIDX_CUSTOM_ADAPTER, EMPTY_CLASS_ARRAY, EMPTY_OBJECT_ARRAY + ) + + else -> super.loadClass(name, constructorSignature, constructorArgs) + } + } catch (e: Throwable) { + logger.error("Failed to load class $name", e) + null + } + + override fun resolveResourceId(id: Int): ResourceReference? { + return projectResources[id] ?: dynamicResourceIdManager.findById(id) + } + + override fun getOrGenerateResourceId(resource: ResourceReference): Int { + val ref = if (resource.resourceType == ResourceType.STYLE) { + resource.transformStyleResource() + } else resource + return resources[ref] ?: dynamicResourceIdManager.getOrGenerateId(ref) + } + + override fun getParser(layoutResource: ResourceValue): ILayoutPullParser? { + try { + val value = layoutResource.value ?: return null + if (aaptDeclaredResources.isNotEmpty() && layoutResource.resourceType == ResourceType.AAPT) { + val aaptResource = aaptDeclaredResources.getValue(value) + return LayoutPullParser.createFromAaptResource(aaptResource) + } + return LayoutPullParser.createFromFile(File(layoutResource.value)).also { + aaptDeclaredResources += it.getAaptDeclaredAttrs() + } + } catch (e: FileNotFoundException) { + return null + } + } + + override fun getAdapterBinding(viewObject: Any?, attributes: MutableMap?): AdapterBinding? = null + + override fun getActionBarCallback(): ActionBarCallback = actionBarCallback + + private fun initResources() { + for (packageName in environment.resourcePackageNames) { + val rClass = try { + Class.forName("$packageName.R") + } catch (e: ClassNotFoundException) { + logger.error("class ${packageName}.R not found") + continue + } + + for (resourceClass in rClass.declaredClasses) { + val resourceType = ResourceType.fromClassName(resourceClass.simpleName) ?: continue + for (field in resourceClass.declaredFields) { + if (!Modifier.isStatic(field.modifiers)) continue + + val type = field.type + + try { + if (type == Int::class.javaPrimitiveType) { + val value = field.get(null) as Int + val reference = ResourceReference(ResourceNamespace.RES_AUTO, resourceType, field.name) + projectResources[value] = reference + resources[reference] = value + } else if (type.isArray && type.componentType == Int::class.javaPrimitiveType) { + // ignore + } else { + logger.error("Unknown resource type (${type}): ${resourceClass.canonicalName}.${field.name}") + } + } catch (e: Throwable) { + logger.error("Malformed R class: ${packageName}.R") + } + } + } + } + } + + private fun createNewInstance( + name: String, + constructorSignature: Array>, + constructorArgs: Array + ): Any? { + val clazz = Class.forName(name) + val constructor = clazz.getConstructor(*constructorSignature) + constructor.isAccessible = true + return constructor.newInstance(*constructorArgs) + } + + private fun ResourceReference.transformStyleResource(): ResourceReference { + return ResourceReference.style(namespace, name.replace('.', '_')) + } + +} + +private val EMPTY_CLASS_ARRAY = emptyArray>() + +private val EMPTY_OBJECT_ARRAY = emptyArray() + +private const val CLASS_ANDROIDX_CUSTOM_ADAPTER = "com.android.layoutlib.bridge.android.androidx.Adapter" diff --git a/src/main/kotlin/io/johnsonlee/playground/sandbox/RenderActionExtension.kt b/src/main/kotlin/io/johnsonlee/playground/sandbox/RenderActionExtension.kt new file mode 100644 index 0000000..275af6e --- /dev/null +++ b/src/main/kotlin/io/johnsonlee/playground/sandbox/RenderActionExtension.kt @@ -0,0 +1,16 @@ +package io.johnsonlee.playground.sandbox + +import android.view.BridgeInflater +import com.android.ide.common.rendering.api.RenderParams +import com.android.layoutlib.bridge.android.BridgeContext +import com.android.layoutlib.bridge.impl.RenderAction + +fun RenderAction.getContext(): BridgeContext { + return RenderAction::class.java.getDeclaredMethod("getContext").apply { + isAccessible = true + }.invoke(this) as BridgeContext +} + +fun RenderAction.getLayoutInflater(): BridgeInflater { + return this.getContext().getSystemService("layout_inflater") as BridgeInflater +} diff --git a/src/main/kotlin/io/johnsonlee/playground/sandbox/RenderData.kt b/src/main/kotlin/io/johnsonlee/playground/sandbox/RenderData.kt new file mode 100644 index 0000000..2ce713e --- /dev/null +++ b/src/main/kotlin/io/johnsonlee/playground/sandbox/RenderData.kt @@ -0,0 +1,16 @@ +package io.johnsonlee.playground.sandbox + +import com.android.ide.common.rendering.api.ViewInfo +import com.fasterxml.jackson.databind.annotation.JsonSerialize +import io.johnsonlee.playground.databind.BufferedImageSerializer +import io.johnsonlee.playground.databind.ViewInfoListSerializer +import java.awt.image.BufferedImage + +data class RenderData( + @get:JsonSerialize(using = ViewInfoListSerializer::class) + val systemViews: List, + @get:JsonSerialize(using = ViewInfoListSerializer::class) + val rootViews: List, + @get:JsonSerialize(using = BufferedImageSerializer::class) + val image: BufferedImage +) diff --git a/src/main/kotlin/io/johnsonlee/playground/sandbox/Sandbox.kt b/src/main/kotlin/io/johnsonlee/playground/sandbox/Sandbox.kt new file mode 100644 index 0000000..6af8f45 --- /dev/null +++ b/src/main/kotlin/io/johnsonlee/playground/sandbox/Sandbox.kt @@ -0,0 +1,191 @@ +package io.johnsonlee.playground.sandbox + +import android.animation.AnimationHandler +import android.content.Context +import android.graphics.Bitmap +import android.os.Handler_Delegate +import android.os.SystemClock_Delegate +import android.util.AttributeSet +import android.util.DisplayMetrics +import android.view.Choreographer +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.android.ide.common.rendering.api.Result +import com.android.ide.common.rendering.api.SessionParams +import com.android.internal.lang.System_Delegate +import com.android.layoutlib.bridge.Bridge +import com.android.layoutlib.bridge.BridgeRenderSession +import com.android.layoutlib.bridge.android.BridgeContext +import com.android.layoutlib.bridge.impl.RenderSessionImpl +import io.johnsonlee.playground.util.Gc +import io.johnsonlee.playground.util.check +import io.johnsonlee.playground.util.getFieldReflectively +import java.io.Closeable +import java.util.concurrent.TimeUnit + +class Sandbox(private val environment: Environment) : Closeable { + + private val bridge = environment.newBridge() + + fun run( + deviceModel: DeviceModel = DeviceModel.PIXEL_5, + theme: String = DEFAULT_THEME, + showLayoutBounds: Boolean = true, + inflate: ((BridgeContext, ViewGroup) -> Unit)? = null + ): Result { + val sessionParams = SessionParamsBuilder.from(environment) + .copy(deviceModel = deviceModel) + .withTheme(theme) + .build() + val renderSession = createRenderSession(sessionParams) + Bridge.prepareThread() + renderSession.init(sessionParams.timeout).check() + + val context = renderSession.getContext() + Bitmap.setDefaultDensity(DisplayMetrics.DENSITY_DEVICE_STABLE) + initializeAppCompatIfPresent(context.layoutInflater) + + val inflateResult = renderSession.inflate().check() + val bridgeRenderSession = createBridgeRenderSession(renderSession, inflateResult) + val root = bridgeRenderSession.rootViews.first().viewObject as ViewGroup + System_Delegate.setBootTimeNanos(0L) + + return try { + context.withTime(0L) { + } + + inflate?.invoke(context, root) + + (0 until root.childCount).map(root::getChildAt).forEach { + it.isShowingLayoutBounds = showLayoutBounds + } + + context.withTime(0) { + renderSession.render(true).check() + } + + val data = RenderData( + systemViews = bridgeRenderSession.systemRootViews.toList(), + rootViews = bridgeRenderSession.rootViews.toList(), + image = bridgeRenderSession.image, + ) + bridgeRenderSession.result.getCopyWithData(data) + } catch (e: Throwable) { + bridgeRenderSession.result.takeIf { it.isSuccess } ?: Result.Status.ERROR_RENDER.createResult(e.message, e) + } finally { + root.removeAllViews() + AnimationHandler.sAnimatorHandler.set(null) + renderSession.release() + bridgeRenderSession.dispose() + Bridge.cleanupThread() + } + } + + private fun createRenderSession(sessionParams: SessionParams): RenderSessionImpl { + return RenderSessionImpl(sessionParams).apply { + setElapsedFrameTimeNanos(0L) + RenderSessionImpl::class.java.getFieldReflectively("mFirstFrameExecuted").set(this, true) + } + } + + private fun createBridgeRenderSession(renderSession: RenderSessionImpl, result: Result): BridgeRenderSession { + return Class.forName("com.android.layoutlib.bridge.BridgeRenderSession").getDeclaredConstructor( + RenderSessionImpl::class.java, + com.android.ide.common.rendering.api.Result::class.java + ).apply { + isAccessible = true + }.newInstance(renderSession, result) as BridgeRenderSession + } + + private fun BridgeContext.withTime(timeNanos: Long, block: () -> R): R { + val frameNanos = TIME_OFFSET_NANOS + timeNanos + + System_Delegate.setNanosTime(frameNanos) + + val choreographer = Choreographer.getInstance() + val mCallbacksRunning = choreographer.javaClass.getFieldReflectively("mCallbacksRunning") + + return try { + mCallbacksRunning.setBoolean(choreographer, true) + + synchronized(sessionInteractiveData) { + Handler_Delegate.executeCallbacks() + } + + val currentTimeMs = SystemClock_Delegate.uptimeMillis() + sessionInteractiveData.choreographerCallbacks.execute(currentTimeMs, Bridge.getLog()) + block() + } finally { + mCallbacksRunning.setBoolean(choreographer, false) + } + } + + override fun close() { + bridge.dispose() + Gc.gc() + } + + companion object { + private val TIME_OFFSET_NANOS = TimeUnit.HOURS.toNanos(1L) + } + +} + +@JvmSynthetic +internal const val DEFAULT_THEME = "Theme.AppCompat.Light.NoActionBar" + + +private fun initializeAppCompatIfPresent(inflater: LayoutInflater) { + lateinit var appCompatDelegateClass: Class<*> + + try { + Class.forName("androidx.appcompat.widget.AppCompatDrawableManager").run { + getMethod("preload").invoke(null) + } + appCompatDelegateClass = Class.forName("androidx.appcompat.app.AppCompatDelegate") + } catch (e: ClassNotFoundException) { + logger.debug("AppCompat not found on classpath") + return + } + + if (inflater.factory == null) { + inflater.factory2 = object : LayoutInflater.Factory2 { + val appCompatViewInflaterClass = Class.forName("androidx.appcompat.app.AppCompatViewInflater") + val createViewMethod = appCompatViewInflaterClass.getDeclaredMethod( + "createView", + View::class.java, + String::class.java, + Context::class.java, + AttributeSet::class.java, + Boolean::class.javaPrimitiveType, + Boolean::class.javaPrimitiveType, + Boolean::class.javaPrimitiveType, + Boolean::class.javaPrimitiveType + ).apply { isAccessible = true } + + override fun onCreateView(parent: View?, name: String, context: Context, attrs: AttributeSet): View? { + return createViewMethod.invoke( + appCompatViewInflaterClass.getConstructor().newInstance(), + parent, + name, + context, + attrs, + true, + true, + true, + true + ) as? View + } + + override fun onCreateView(name: String, context: Context, attrs: AttributeSet): View? { + return onCreateView(null, name, context, attrs) + } + + } + } else { + if (!appCompatDelegateClass.isAssignableFrom(inflater.factory2.javaClass)) { + throw IllegalStateException("The LayoutInflater already has a Factory installed so we can not install AppCompat's") + } + } +} diff --git a/src/main/kotlin/io/johnsonlee/playground/sandbox/SandboxAssetRepository.kt b/src/main/kotlin/io/johnsonlee/playground/sandbox/SandboxAssetRepository.kt new file mode 100644 index 0000000..6c0f62e --- /dev/null +++ b/src/main/kotlin/io/johnsonlee/playground/sandbox/SandboxAssetRepository.kt @@ -0,0 +1,34 @@ +package io.johnsonlee.playground.sandbox + +import com.android.ide.common.rendering.api.AssetRepository +import java.io.File +import java.io.FileInputStream +import java.io.InputStream + +class SandboxAssetRepository( + private val assetPath: String, + private val assetDirs: List = emptyList() +) : AssetRepository() { + + override fun isSupported(): Boolean = true + + override fun openAsset(path: String?, mode: Int): InputStream? = if (assetDirs.isEmpty()) { + open("$assetPath/$path") + } else { + assetDirs.asSequence() + .map { "$it/$assetPath/$path" } + .map(::open) + .firstOrNull { it != null } + } + + override fun openNonAsset(cookie: Int, path: String, mode: Int): InputStream? = open(path) + + private fun open(path: String): InputStream? { + val asset = File(path) + return when { + asset.isFile -> FileInputStream(asset) + else -> null + } + } + +} \ No newline at end of file diff --git a/src/main/kotlin/io/johnsonlee/playground/sandbox/SessionParamsBuilder.kt b/src/main/kotlin/io/johnsonlee/playground/sandbox/SessionParamsBuilder.kt new file mode 100644 index 0000000..bdb9862 --- /dev/null +++ b/src/main/kotlin/io/johnsonlee/playground/sandbox/SessionParamsBuilder.kt @@ -0,0 +1,129 @@ +package io.johnsonlee.playground.sandbox + +import com.android.SdkConstants +import com.android.ide.common.rendering.api.AssetRepository +import com.android.ide.common.rendering.api.LayoutlibCallback +import com.android.ide.common.rendering.api.ResourceNamespace +import com.android.ide.common.rendering.api.ResourceReference +import com.android.ide.common.rendering.api.SessionParams +import com.android.ide.common.resources.ResourceRepository +import com.android.ide.common.resources.ResourceResolver +import com.android.ide.common.resources.getConfiguredResources +import com.android.layoutlib.bridge.Bridge +import com.android.layoutlib.bridge.android.RenderParamsFlags +import com.android.resources.LayoutDirection +import com.android.resources.ResourceType +import io.johnsonlee.playground.sandbox.parsers.LayoutPullParser + +data class SessionParamsBuilder( + val layoutlibCallback: LayoutlibCallback, + val frameworkResources: ResourceRepository, + val assetRepository: AssetRepository, + val projectResources: ResourceRepository, + val deviceModel: DeviceModel = DeviceModel.PIXEL_5, + val renderingMode: SessionParams.RenderingMode = SessionParams.RenderingMode.SHRINK, + val targetSdkVersion: Int = 21, + val flags: Map, Any> = DEFAULT_FLAGS, + val themeName: String? = DEFAULT_THEME, + val isProjectTheme: Boolean = false, + val layoutPullParser: LayoutPullParser = LayoutPullParser.createFromString(XML_FRAMELAYOUT), + val projectKey: Any? = null, + val minSdkVersion: Int = 0, + val decor: Boolean = false, + val supportsRtl: Boolean = true +) { + + fun withTheme( + themeName: String, + isProjectTheme: Boolean = false + ) = copy(themeName = themeName, isProjectTheme = isProjectTheme) + + fun withTheme(themeName: String) = when { + themeName.startsWith(SdkConstants.PREFIX_ANDROID) -> { + withTheme(themeName.substring(SdkConstants.PREFIX_ANDROID.length), false) + } + + else -> withTheme(themeName, true) + } + + fun plusFlag(flag: SessionParams.Key<*>, value: Any) = copy(flags = flags + (flag to value)) + + fun build(): SessionParams { + require(themeName != null) + + val folderConfiguration = deviceModel.resourceConfiguration + val resourceResolver = ResourceResolver.create( + mapOf( + ResourceNamespace.ANDROID to frameworkResources.getConfiguredResources(folderConfiguration) + .row(ResourceNamespace.ANDROID), + *projectResources.getConfiguredResources(folderConfiguration).rowMap() + .map { (key, value) -> key to value }.toTypedArray() + ), + ResourceReference(ResourceNamespace.ANDROID, ResourceType.STYLE, themeName), + ) + val sessionParams = SessionParams( + layoutPullParser, + renderingMode, + projectKey, + deviceModel.hardwareConfig, + resourceResolver, + layoutlibCallback, + minSdkVersion, + targetSdkVersion, + LayoutLogger + ) + + sessionParams.fontScale = deviceModel.fontScale + sessionParams.uiMode = deviceModel.uiModeMask + + val localeQualifier = folderConfiguration.localeQualifier + val layoutDirectionQualifier = folderConfiguration.layoutDirectionQualifier + + if (LayoutDirection.RTL == layoutDirectionQualifier.value && !Bridge.isLocaleRtl(localeQualifier.tag)) { + sessionParams.locale = "ur" + } else { + sessionParams.locale = localeQualifier.tag + } + + sessionParams.setRtlSupport(supportsRtl) + + flags.forEach { (key, value) -> + @Suppress("UNCHECKED_CAST") + sessionParams.setFlag(key as SessionParams.Key, value) + } + sessionParams.setAssetRepository(assetRepository) + if (!decor) { + sessionParams.setForceNoDecor() + } + + return sessionParams + } + + companion object { + + private val DEFAULT_FLAGS: Map, Any> = mapOf( + RenderParamsFlags.FLAG_DO_NOT_RENDER_ON_CREATE to true, + ) + + private val XML_FRAMELAYOUT = """ + | + | + """.trimMargin() + + fun from(environment: Environment): SessionParamsBuilder = SessionParamsBuilder( + layoutlibCallback = environment.layoutlibCallback, + frameworkResources = environment.frameworkResources, + assetRepository = SandboxAssetRepository( + assetPath = environment.assetsDir.path, + assetDirs = environment.allModuleAssetDirs + environment.libraryAssetDirs + ), + projectResources = environment.projectResources, + ) + + } + +} diff --git a/src/main/kotlin/io/johnsonlee/playground/sandbox/parsers/AaptAttrParser.kt b/src/main/kotlin/io/johnsonlee/playground/sandbox/parsers/AaptAttrParser.kt new file mode 100644 index 0000000..8a1975c --- /dev/null +++ b/src/main/kotlin/io/johnsonlee/playground/sandbox/parsers/AaptAttrParser.kt @@ -0,0 +1,16 @@ +package io.johnsonlee.playground.sandbox.parsers + +/** + * Interface for parsers that support declaration of inlined {@code aapt:attr} attributes + * + * @link https://cs.android.com/android-studio/platform/tools/adt/idea/+/mirror-goog-studio-main:rendering/src/com/android/tools/rendering/parsers/AaptAttrParser.java + */ +interface AaptAttrParser { + + /** + * Returns a {@link ImmutableMap} that contains all the {@code aapt:attr} elements declared in this or any children parsers. This list + * can be used to resolve {@code @aapt/_aapt} references into this parser. + */ + fun getAaptDeclaredAttrs(): Map + +} \ No newline at end of file diff --git a/src/main/kotlin/io/johnsonlee/playground/sandbox/parsers/AaptAttrSnapshot.kt b/src/main/kotlin/io/johnsonlee/playground/sandbox/parsers/AaptAttrSnapshot.kt new file mode 100644 index 0000000..3b836fe --- /dev/null +++ b/src/main/kotlin/io/johnsonlee/playground/sandbox/parsers/AaptAttrSnapshot.kt @@ -0,0 +1,11 @@ +package io.johnsonlee.playground.sandbox.parsers + +import com.android.SdkConstants + +class AaptAttrSnapshot( + override val namespace: String, + override val prefix: String, + override val name: String, + val id: String, + val bundledTag: TagSnapshot +) : AttributeSnapshot(namespace, prefix, name, "${SdkConstants.AAPT_ATTR_PREFIX}${SdkConstants.AAPT_PREFIX}${id}") \ No newline at end of file diff --git a/src/main/kotlin/io/johnsonlee/playground/sandbox/parsers/AttributeSnapshot.kt b/src/main/kotlin/io/johnsonlee/playground/sandbox/parsers/AttributeSnapshot.kt new file mode 100644 index 0000000..1defeec --- /dev/null +++ b/src/main/kotlin/io/johnsonlee/playground/sandbox/parsers/AttributeSnapshot.kt @@ -0,0 +1,34 @@ +package io.johnsonlee.playground.sandbox.parsers + +open class AttributeSnapshot( + open val namespace: String, + open val prefix: String, + open val name: String, + open val value: String +) { + + override fun toString() = "${name}: ${value}" + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as AttributeSnapshot + + if (namespace != other.namespace) return false + if (prefix != other.prefix) return false + if (name != other.name) return false + if (value != other.value) return false + + return true + } + + override fun hashCode(): Int { + var result = namespace.hashCode() + result = 31 * result + prefix.hashCode() + result = 31 * result + name.hashCode() + result = 31 * result + value.hashCode() + return result + } + +} diff --git a/src/main/kotlin/io/johnsonlee/playground/sandbox/parsers/InMemoryParser.kt b/src/main/kotlin/io/johnsonlee/playground/sandbox/parsers/InMemoryParser.kt new file mode 100644 index 0000000..1080de1 --- /dev/null +++ b/src/main/kotlin/io/johnsonlee/playground/sandbox/parsers/InMemoryParser.kt @@ -0,0 +1,118 @@ +package io.johnsonlee.playground.sandbox.parsers + +import org.kxml2.io.KXmlParser +import org.xmlpull.v1.XmlPullParserException + +abstract class InMemoryParser : KXmlParser() { + + abstract fun rootTag(): TagSnapshot + + private val nodeStack = mutableListOf() + private var parsingState = START_DOCUMENT + + override fun getAttributeCount(): Int { + return currentNode?.attributes?.size ?: 0 + } + + override fun getAttributeName(index: Int): String? { + return currentNode?.attributes?.get(index)?.name + } + + override fun getAttributeNamespace(index: Int): String { + return getAttribute(index)?.namespace ?: "" + } + + override fun getAttributePrefix(index: Int): String? { + return getAttribute(index)?.prefix + } + + override fun getAttributeValue(index: Int): String? { + return getAttribute(index)?.value + } + + override fun getAttributeValue(namespace: String?, name: String?): String? { + return currentNode?.attributes?.find { it.namespace == namespace && it.name == name }?.value + } + + override fun getDepth(): Int = nodeStack.size + + override fun getName(): String? { + if (parsingState == START_TAG || parsingState == END_TAG) { + return currentNode!!.name + } + return null + } + + override fun next(): Int { + when (parsingState) { + END_DOCUMENT -> throw XmlPullParserException("Nothing after the end") + START_DOCUMENT -> onNextFromStartDocument() + START_TAG -> onNextFromStartTag() + END_TAG -> onNextFromEndTag() + } + return parsingState + } + + private fun push(node: TagSnapshot) { + nodeStack.add(node) + } + + private fun pop(): TagSnapshot { + return nodeStack.removeLast() + } + + private fun onNextFromStartDocument() { + val rootTag = rootTag() + parsingState = if (rootTag != null) { + push(rootTag) + START_TAG + } else { + END_DOCUMENT + } + } + + private fun onNextFromStartTag() { + val node = currentNode!! + val children = node.children + parsingState = if (children.isNotEmpty()) { + push(children[0]) + START_TAG + } else { + if (parsingState == START_DOCUMENT) { + END_DOCUMENT + } else { + END_TAG + } + } + } + + private fun onNextFromEndTag() { + var node = currentNode!! + val sibling = node.next + if (sibling != null) { + node = sibling + pop() + push(node) + parsingState = START_TAG + } else { + pop() + parsingState = if (nodeStack.isEmpty()) { + END_DOCUMENT + } else { + END_TAG + } + } + } + + private fun getAttribute(index: Int): AttributeSnapshot? { + if (parsingState != START_TAG) { + throw IndexOutOfBoundsException() + } + + return currentNode?.attributes?.get(index) + } + + private val currentNode: TagSnapshot? + get() = nodeStack.lastOrNull() + +} \ No newline at end of file diff --git a/src/main/kotlin/io/johnsonlee/playground/sandbox/parsers/LayoutPullParser.kt b/src/main/kotlin/io/johnsonlee/playground/sandbox/parsers/LayoutPullParser.kt new file mode 100644 index 0000000..74452fc --- /dev/null +++ b/src/main/kotlin/io/johnsonlee/playground/sandbox/parsers/LayoutPullParser.kt @@ -0,0 +1,104 @@ +package io.johnsonlee.playground.sandbox.parsers + +import com.android.SdkConstants +import com.android.ide.common.rendering.api.ILayoutPullParser +import com.android.ide.common.rendering.api.ResourceNamespace +import okio.buffer +import okio.source +import java.io.File +import java.io.FileInputStream +import java.io.FileNotFoundException +import java.io.IOException +import java.io.InputStream +import java.nio.charset.StandardCharsets + +class LayoutPullParser : InMemoryParser, AaptAttrParser, ILayoutPullParser { + + private val root: TagSnapshot + private val declaredAaptAttrs: Map + + private var layoutNamespace: ResourceNamespace = ResourceNamespace.RES_AUTO + + constructor(input: InputStream) { + val buffer = input.source().buffer() + setFeature(FEATURE_PROCESS_NAMESPACES, true) + setInput(buffer.peek().inputStream(), null) + + val resourceParser = ResourceParser(buffer.inputStream()) + root = resourceParser.createTagSnapshot() + declaredAaptAttrs = findDeclaredAaptAttrs(root) + } + + constructor(aaptResource: TagSnapshot) { + root = aaptResource + declaredAaptAttrs = emptyMap() + } + + override fun rootTag(): TagSnapshot = root + + override fun getViewCookie(): Any? { + val name = super.getName() ?: return null + + return if ( + SdkConstants.LIST_VIEW == name + || SdkConstants.EXPANDABLE_LIST_VIEW == name + || SdkConstants.GRID_VIEW == name + || SdkConstants.SPINNER == name + ) { + (0 until attributeCount).filter { + getAttributeNamespace(it) == SdkConstants.TOOLS_URI && getAttributeName(it) != SdkConstants.ATTR_IGNORE + }.associate { + getAttributeName(it)!! to getAttributeValue(it)!! + } + } else null + } + + override fun getLayoutNamespace(): ResourceNamespace = layoutNamespace + + override fun getAaptDeclaredAttrs(): Map = declaredAaptAttrs + + fun setLayoutNamespace(namespace: ResourceNamespace) { + this.layoutNamespace = namespace + } + + private fun findDeclaredAaptAttrs(tag: TagSnapshot): Map { + if (!tag.hasDeclaredAaptAttrs) { + return emptyMap() + } + + return buildMap { + tag.attributes.filterIsInstance().forEach { attr -> + val bundledTag = attr.bundledTag + put(attr.id, bundledTag) + for (child in bundledTag.children) { + putAll(findDeclaredAaptAttrs(child)) + } + } + for (child in tag.children) { + putAll(findDeclaredAaptAttrs(child)) + } + } + } + + companion object { + fun createFromFile(layout: File) = LayoutPullParser(FileInputStream(layout)) + + fun createFromClasspath(layout: String): LayoutPullParser { + var path = layout + if (path.startsWith("/")) { + path = path.substring(1) + } + return LayoutPullParser::class.java.classLoader.getResourceAsStream(path)?.let(::LayoutPullParser) + ?: throw IOException("Resource not found: $layout") + } + + fun createFromString(xml: String): LayoutPullParser { + return LayoutPullParser(xml.byteInputStream(StandardCharsets.UTF_8)) + } + + fun createFromAaptResource(aaptResource: TagSnapshot): LayoutPullParser { + return LayoutPullParser(aaptResource) + } + } + +} \ No newline at end of file diff --git a/src/main/kotlin/io/johnsonlee/playground/sandbox/parsers/ResourceParser.kt b/src/main/kotlin/io/johnsonlee/playground/sandbox/parsers/ResourceParser.kt new file mode 100644 index 0000000..b77fbb6 --- /dev/null +++ b/src/main/kotlin/io/johnsonlee/playground/sandbox/parsers/ResourceParser.kt @@ -0,0 +1,113 @@ +package io.johnsonlee.playground.sandbox.parsers + +import com.android.SdkConstants +import org.kxml2.io.KXmlParser +import java.io.InputStream +import java.util.concurrent.atomic.AtomicInteger + +class ResourceParser(input: InputStream) : KXmlParser() { + + init { + setFeature(FEATURE_PROCESS_NAMESPACES, true) + setInput(input, null) + require(START_DOCUMENT, null, null) + next() + } + + fun createTagSnapshot(): TagSnapshot { + require(START_TAG, null, null) + + val tagName = name + val tagNamespace = namespace + val prefix = prefix + val attributes = createAttributesForTag() + val children = mutableListOf() + + var hasDeclaredAaptAttrs = false + var last: TagSnapshot? = null + + while (eventType != END_DOCUMENT) { + when (next()) { + START_TAG -> { + if (SdkConstants.AAPT_URI == namespace) { + if (SdkConstants.TAG_ATTR == name) { + val attr = createAttrTagSnapshot() + if (attr != null) { + attributes += attr + hasDeclaredAaptAttrs = true + } + } + } else { + val child = createTagSnapshot() + hasDeclaredAaptAttrs = hasDeclaredAaptAttrs || child.hasDeclaredAaptAttrs + children += child + if (last != null) { + last.next = child + } + last = child + } + } + END_TAG -> { + return TagSnapshot(tagName, tagNamespace, prefix, attributes, children.toList(), hasDeclaredAaptAttrs) + } + } + } + + throw IllegalStateException("Unexpected end of document") + } + + private fun createAttrTagSnapshot(): AaptAttrSnapshot? { + require(START_TAG, null, "attr") + + val name = getAttributeValue(null, "name") ?: return null + val prefix = findPrefixByQualifiedName(name) + val namespace = getNamespace(prefix) + val localName = findLocalNameByQualifiedName(name) + val id = nextId.incrementAndGet().toString() + var bundleTagSnapshot: TagSnapshot? = null + + while (eventType != END_TAG) { + when (nextTag()) { + START_TAG -> { + bundleTagSnapshot = createTagSnapshot() + } + END_TAG -> { + break + } + } + } + + return if (bundleTagSnapshot != null) { + nextTag() + require(END_TAG, null, "attr") + AaptAttrSnapshot(namespace, prefix, localName, id, bundleTagSnapshot) + } else null + } + + private fun findPrefixByQualifiedName(qualifiedName: String): String { + return qualifiedName.substringBefore(':').takeIf(String::isNotEmpty) ?: "" + } + + private fun findLocalNameByQualifiedName(qualifiedName: String): String { + return qualifiedName.substringAfterLast(':') + } + + private fun createAttributesForTag(): MutableList { + return buildList { + (0 until attributeCount).forEach { i -> + add( + AttributeSnapshot( + getAttributeNamespace(i), + getAttributePrefix(i), + getAttributeName(i), + getAttributeValue(i) + ) + ) + } + }.toMutableList() + } + + companion object { + private val nextId = AtomicInteger(0) + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/johnsonlee/playground/sandbox/parsers/TagSnapshot.kt b/src/main/kotlin/io/johnsonlee/playground/sandbox/parsers/TagSnapshot.kt new file mode 100644 index 0000000..cc306af --- /dev/null +++ b/src/main/kotlin/io/johnsonlee/playground/sandbox/parsers/TagSnapshot.kt @@ -0,0 +1,15 @@ +package io.johnsonlee.playground.sandbox.parsers + +data class TagSnapshot( + val name: String, + val namespace: String, + val prefix: String?, + val attributes: List, + val children: List, + val hasDeclaredAaptAttrs: Boolean = false +) { + + @JvmSynthetic + internal var next: TagSnapshot? = null + +} \ No newline at end of file diff --git a/src/main/kotlin/io/johnsonlee/playground/sandbox/resources/AppResourceRepository.kt b/src/main/kotlin/io/johnsonlee/playground/sandbox/resources/AppResourceRepository.kt new file mode 100644 index 0000000..806dbb9 --- /dev/null +++ b/src/main/kotlin/io/johnsonlee/playground/sandbox/resources/AppResourceRepository.kt @@ -0,0 +1,30 @@ +package io.johnsonlee.playground.sandbox.resources + +import com.android.resources.aar.AarSourceResourceRepository +import java.io.File + +/** + * @link https://cs.android.com/android-studio/platform/tools/adt/idea/+/mirror-goog-studio-main:android/src/com/android/tools/idea/res/AppResourceRepository.java + */ +class AppResourceRepository private constructor( + displayName: String, + localResources: List, + libraryResources: Collection +) : MultiResourceRepository("${displayName} with modules and libraries") { + + init { + setChildren(localResources, libraryResources) + } + + companion object { + fun create( + localResourceDirectories: List, + moduleResourceDirectories: List, + libraryRepositories: Collection + ): AppResourceRepository = AppResourceRepository( + displayName = "", + listOf(ProjectResourceRepository.create(localResourceDirectories, moduleResourceDirectories)), + libraryRepositories + ) + } +} diff --git a/src/main/kotlin/io/johnsonlee/playground/sandbox/resources/LocalResourceRepository.kt b/src/main/kotlin/io/johnsonlee/playground/sandbox/resources/LocalResourceRepository.kt new file mode 100644 index 0000000..97494ac --- /dev/null +++ b/src/main/kotlin/io/johnsonlee/playground/sandbox/resources/LocalResourceRepository.kt @@ -0,0 +1,119 @@ +package io.johnsonlee.playground.sandbox.resources + +import com.android.ide.common.rendering.api.ResourceNamespace +import com.android.ide.common.resources.AbstractResourceRepository +import com.android.ide.common.resources.ResourceItem +import com.android.resources.ResourceType +import com.google.common.collect.ImmutableListMultimap +import com.google.common.collect.ListMultimap + +/** + * Repository for Android application resources, e.g. those that show up in `R, not `android.R` + * (which are referred to as framework resources.). Note that this includes resources from Gradle libraries + * too, even though you may not think of these as "local" (they do however (a) end up in the application + * namespace, and (b) get extracted by Gradle into the project's build folder where they are merged with + * the other resources.) + * + * For a given Android module, you can obtain either the resources for the module itself, or for a module and all + * its libraries. Most clients should use the module with all its dependencies included; when a user is + * using code completion for example, they expect to be offered not just the drawables in this module, but + * all the drawables available in this module which includes the libraries. + * + * The module repository is implemented using several layers. Consider a Gradle project where the main module has + * two flavors, and depends on a library module. In this case, the [LocalResourceRepository] for + * the module with dependencies will contain these components: + * - A [AppResourceRepository] which contains a [com.android.resources.aar.AarResourceRepository] wrapping each AAR + * library dependency, and merges this with the project resource repository + * - A [ProjectResourceRepository] representing the collection of module repositories + * - For each module (e.g. the main module and library module), a [ModuleResourceRepository] + * - For each resource directory in each module, a [ResourceFolderRepository] + * + * These different repositories are merged together by the [MultiResourceRepository] class, + * which represents a repository that just combines the resources from each of its children. + * All of [AppResourceRepository], [ModuleResourceRepository] and + * [ProjectResourceRepository] are instances of a [MultiResourceRepository]. + * + * The [ResourceFolderRepository] is the lowest level of repository. It is associated with just + * a single resource folder. Therefore, it does not have to worry about trying to mask resources between + * different flavors; that task is done by the [ModuleResourceRepository] which combines + * [ResourceFolderRepository] instances. Instead, the [ResourceFolderRepository] just + * needs to compute the resource items for the resource folders, including qualifier variations. + * + * The resource repository automatically stays up to date. You can call `getModificationCount()` + * to see whether anything has changed since your last data fetch. This is for example how the resource + * string folding in the source editors work; they fetch the current values of the resource strings, and + * store those along with the current project resource modification count into the folding data structures. + * When the editor wants to see if the folding sections are up to date, those are compared with the current + * `getModificationCount()` version, and only if they differ is the folding structure updated. + * + * Only the [ResourceFolderRepository] needs to listen for user edits and file changes. It + * uses [AndroidFileChangeListener], a single listener which is shared by all repositories in the + * same project, to get notified when something in one of its resource files changes, and it uses the + * PSI change event to selectively update the repository data structures, if possible. + * + * The [ResourceFolderRepository] can also have a pointer to its parent. This is possible + * since a resource folder can only be in a single module. The parent reference is used to quickly + * invalidate the cache of the parent [MultiResourceRepository]. For example, let's say the + * project has two flavors. When the PSI change event is used to update the name of a string resource, + * the repository will also notify the parent that its [ResourceType.ID] map is out of date. + * The [MultiResourceRepository] will use this to null out its map cache of strings, and + * on the next read, it will merge in the string maps from all its [ResourceFolderRepository] + * children. + * + * One common type of "update" is changing the current variant in the IDE. With the above scheme, + * this just means reordering the [ResourceFolderRepository] instances in the + * [ModuleResourceRepository]; it does not have to rescan the resources as it did in the + * previous implementation. + * + * The [ProjectResourceRepository] is similar, but it combines [ModuleResourceRepository] + * instances rather than [ResourceFolderRepository] instances. Note also that the way these + * resource repositories work is slightly different from the way the resource items are used by + * the builder: The builder will bail if it encounters duplicate declarations unless they are in alternative + * folders of the same flavor. For the resource repository we never want to bail on merging; the repository + * is kept up to date and live as the user is editing, so it is normal for the repository to sometimes + * reflect invalid user edits (in the same way a Java editor in an IDE sometimes is showing uncompilable + * source code) and it needs to be able to handle this case and offer a state that is as close to possible + * as the intended meaning. Error handling is done by another part of the IDE. + * + * Finally, note that the resource repository is showing the current state of the resources for the + * currently selected variant. Note however that the above approach also lets us query resources for + * example for all flavors, not just the currently selected flavor. We can offer APIs to iterate + * through all available [ResourceFolderRepository] instances, not just the set of instances for + * the current module's current flavor. This will allow us to for example preview the string translations + * for a given resource name not just for the current flavor but for all other flavors as well. + * + * @link https://cs.android.com/android-studio/platform/tools/adt/idea/+/mirror-goog-studio-main:render-resources/src/com/android/tools/res/LocalResourceRepository.java + */ +abstract class LocalResourceRepository protected constructor( + @JvmField + val displayName: String +) : AbstractResourceRepository() { + + fun getDisplayName(): String = displayName + + protected abstract fun getMap( + namespace: ResourceNamespace, + resourceType: ResourceType + ): ListMultimap? + + override fun getResourcesInternal( + namespace: ResourceNamespace, + resourceType: ResourceType + ): ListMultimap { + val map = getMap(namespace, resourceType) + return map ?: ImmutableListMultimap.of() + } + + override fun getPublicResources( + namespace: ResourceNamespace?, + type: ResourceType? + ): MutableCollection { + TODO("Not yet implemented") + } + + open fun getMapPackageAccessible( + namespace: ResourceNamespace, + resourceType: ResourceType + ): ListMultimap? = getMap(namespace, resourceType) + +} \ No newline at end of file diff --git a/src/main/kotlin/io/johnsonlee/playground/sandbox/resources/ModuleResourceRepository.kt b/src/main/kotlin/io/johnsonlee/playground/sandbox/resources/ModuleResourceRepository.kt new file mode 100644 index 0000000..47d9385 --- /dev/null +++ b/src/main/kotlin/io/johnsonlee/playground/sandbox/resources/ModuleResourceRepository.kt @@ -0,0 +1,45 @@ +package io.johnsonlee.playground.sandbox.resources + +import com.android.ide.common.rendering.api.ResourceNamespace +import com.android.ide.common.resources.SingleNamespaceResourceRepository +import java.io.File + +/** + * @link https://cs.android.com/android-studio/platform/tools/adt/idea/+/mirror-goog-studio-main:android/src/com/android/tools/idea/res/ModuleResourceRepository.java + */ +class ModuleResourceRepository private constructor( + displayName: String, + private val namespace: ResourceNamespace, + delegate: List +): MultiResourceRepository(displayName), SingleNamespaceResourceRepository { + + init { + setChildren(delegate, emptyList()) + } + + override fun getNamespace(): ResourceNamespace = namespace + + override fun getPackageName(): String = namespace.packageName + + override fun getNamespaces(): Set = super.getNamespaces() + + override fun getLeafResourceRepositories(): Collection { + return super.getLeafResourceRepositories() + } + + companion object { + fun forMainResources( + namespace: ResourceNamespace, + resourceDirectories: List + ): ModuleResourceRepository = ModuleResourceRepository( + displayName = "main", + namespace = namespace, + delegate = buildList { + resourceDirectories.asReversed().forEach { + add(ResourceFolderRepository(it, namespace)) + } + } + ) + } + +} \ No newline at end of file diff --git a/src/main/kotlin/io/johnsonlee/playground/sandbox/resources/MultiResourceRepository.kt b/src/main/kotlin/io/johnsonlee/playground/sandbox/resources/MultiResourceRepository.kt new file mode 100644 index 0000000..ac5ad27 --- /dev/null +++ b/src/main/kotlin/io/johnsonlee/playground/sandbox/resources/MultiResourceRepository.kt @@ -0,0 +1,393 @@ +package io.johnsonlee.playground.sandbox.resources + +import com.android.ide.common.rendering.api.ResourceNamespace +import com.android.ide.common.resources.ResourceItem +import com.android.ide.common.resources.ResourceRepository +import com.android.ide.common.resources.ResourceTable +import com.android.ide.common.resources.ResourceVisitor +import com.android.ide.common.resources.SingleNamespaceResourceRepository +import com.android.resources.ResourceType +import com.android.resources.aar.AarSourceResourceRepository +import com.google.common.collect.ArrayListMultimap +import com.google.common.collect.ImmutableListMultimap +import com.google.common.collect.ListMultimap +import com.google.common.collect.Maps +import com.google.common.collect.Multimap +import com.google.common.collect.Multiset +import com.google.common.collect.Table +import com.google.common.collect.Tables + +/** + * @link https://cs.android.com/android-studio/platform/tools/adt/idea/+/mirror-goog-studio-main:render-resources/src/com/android/tools/res/MultiResourceRepository.java + */ +abstract class MultiResourceRepository protected constructor( + displayName: String +) : LocalResourceRepository(displayName) { + + private var localResources = listOf() + + private var libraryResources = listOf() + + private var children = listOf() + + private var leafsByNamespace = ImmutableListMultimap.of() + + private var repositoriesByNamespace = + ImmutableListMultimap.of() + + private var resourceComparator = ResourceItemComparator(ResourcePriorityComparator(emptyList())) + + private val cachedMaps = ResourceTable() + + private val resourceNames: Table> = + Tables.newCustomTable(HashMap()) { + Maps.newEnumMap(ResourceType::class.java) + } + + fun setChildren( + localResources: List, + libraryResources: Collection + ) { + this.localResources = localResources.toList() + this.libraryResources = libraryResources.toList() + this.children = this.localResources + this.libraryResources + this.leafsByNamespace = ImmutableListMultimap.builder() + .computeLeafs(this) + .build() + this.repositoriesByNamespace = + ImmutableListMultimap.builder() + .computeNamespaceMap(this) + .build() + this.resourceComparator = ResourceItemComparator(ResourcePriorityComparator(this.leafsByNamespace.values())) + this.cachedMaps.clear() + } + + override fun getNamespaces(): Set = repositoriesByNamespace.keySet() + + override fun accept(visitor: ResourceVisitor): ResourceVisitor.VisitResult { + for (namespace in namespaces) { + if (visitor.shouldVisitNamespace(namespace)) { + for (type in ResourceType.values()) { + if (visitor.shouldVisitResourceType(type)) { + val map = getMap(namespace, type) + if (map != null) { + for (item in map.values()) { + if (visitor.visit(item) == ResourceVisitor.VisitResult.ABORT) { + return ResourceVisitor.VisitResult.ABORT + } + } + } + } + } + } + } + return ResourceVisitor.VisitResult.CONTINUE + } + + override fun getMap(namespace: ResourceNamespace, resourceType: ResourceType): ListMultimap? { + val repositoriesForNamespace = leafsByNamespace[namespace] + if (repositoriesForNamespace.size == 1) { + val repository = repositoriesForNamespace[0] + return getResources(repository, namespace, resourceType) + } + + val cachedMap = cachedMaps[namespace, resourceType] + if (null != cachedMap) { + return cachedMap + } + + val map = if (resourceType == ResourceType.STYLEABLE || resourceType == ResourceType.ID) { + ArrayListMultimap.create() + } else { + PerConfigResourceMap(resourceComparator) + } + + cachedMaps.put(namespace, resourceType, map) + + for (repository in repositoriesForNamespace) { + val items = getResources(repository, namespace, resourceType) + if (items.isEmpty) continue + map.putAll(items) + if (repository is LocalResourceRepository) { + resourceNames.put(repository, resourceType, items.keySet().toSet()) + } + } + + return map + } + + override fun getLeafResourceRepositories(): Collection { + return leafsByNamespace.values() + } + + companion object { + private fun ImmutableListMultimap.Builder.computeLeafs( + repository: ResourceRepository + ): ImmutableListMultimap.Builder = apply { + when (repository) { + is MultiResourceRepository -> { + repository.children.forEach { + computeLeafs(it) + } + } + + else -> { + repository.leafResourceRepositories.forEach { + this.put(it.namespace, it) + } + } + } + } + + private fun ImmutableListMultimap.Builder.computeNamespaceMap( + repository: ResourceRepository + ): ImmutableListMultimap.Builder = apply { + when (repository) { + is MultiResourceRepository -> { + repository.children.forEach { + computeNamespaceMap(it) + } + } + + is SingleNamespaceResourceRepository -> { + this.put(repository.namespace, repository) + } + } + } + + private fun getResources( + repository: SingleNamespaceResourceRepository, + namespace: ResourceNamespace, + type: ResourceType + ): ListMultimap = when (repository) { + is LocalResourceRepository -> { + repository.getMapPackageAccessible(namespace, type) ?: ImmutableListMultimap.of() + } + + else -> repository.getResources(namespace, type) + } + } + + private class ResourceItemComparator( + val priorityComparator: Comparator + ) : Comparator { + override fun compare(l: ResourceItem, r: ResourceItem): Int { + return l.configuration.compareTo(r.configuration).takeIf { + it != 0 + } ?: priorityComparator.compare(l, r) + } + } + + private class ResourcePriorityComparator( + repositories: Collection + ) : Comparator { + + private val repositoryOrdering = buildMap { + repositories.forEachIndexed { index, repository -> + put(repository, index) + } + } + + override fun compare(l: ResourceItem, r: ResourceItem): Int { + return getOrdering(l).compareTo(getOrdering(r)) + } + + private fun getOrdering(item: ResourceItem): Int { + val repository = item.repository + return repositoryOrdering[repository] ?: 0 + } + } + + private class PerConfigResourceMap( + private val comparator: ResourceItemComparator + ) : ListMultimap { + private val map = HashMap>() + + private var size = 0 + + private var values: Values? = null + + override fun get(key: String?): List { + return key?.let(map::get) ?: emptyList() + } + + override fun keySet(): MutableSet = map.keys + + override fun keys(): Multiset = throw UnsupportedOperationException() + + override fun values(): Collection { + var values = this.values + if (values == null) { + values = Values(size) + this.values = values + } + + return values + } + + override fun entries(): Collection> { + throw UnsupportedOperationException() + } + + override fun removeAll(key: Any?): List { + val removed = key?.let(map::remove) + if (null != removed) { + size -= removed.size + } + return removed ?: emptyList() + } + + override fun clear() { + map.clear() + size = 0 + } + + override fun size(): Int = size + + override fun isEmpty(): Boolean = size == 0 + + override fun containsKey(key: Any?): Boolean = key?.let(map::containsKey) ?: false + + override fun containsValue(value: Any?): Boolean { + throw UnsupportedOperationException() + } + + override fun containsEntry(key: Any?, value: Any?): Boolean { + throw UnsupportedOperationException() + } + + override fun put(key: String, value: ResourceItem): Boolean { + val list = map.computeIfAbsent(key) { _ -> PerConfigResourceList() } + val oldSize = list.size + list += value + size += list.size - oldSize + return true + } + + override fun remove(key: Any?, value: Any?): Boolean { + throw UnsupportedOperationException() + } + + override fun putAll(key: String, values: Iterable): Boolean { + if (values is Collection<*>) { + if (values.isEmpty()) { + return false + } + + val list = map.computeIfAbsent(key) { _ -> PerConfigResourceList() } + val oldSize = list.size + val added = list.addAll(values as Collection) + size += list.size - oldSize + return added + } + + var added = false + var list: MutableList? = null + var oldSize = 0 + for (value in values) { + if (null == list) { + list = map.computeIfAbsent(key) { _ -> PerConfigResourceList() } + oldSize = list.size + } + added = list.add(value) + } + + if (list != null) { + size += list.size - oldSize + } + + return added + } + + override fun putAll(multimap: Multimap): Boolean { + multimap.asMap().entries.forEach { (key, items) -> + if (items.isNotEmpty()) { + val list = map.computeIfAbsent(key) { _ -> PerConfigResourceList() } + val oldSize = list.size + list += items + size += list.size - oldSize + } + } + return !multimap.isEmpty + } + + override fun replaceValues(key: String?, values: Iterable): List { + throw UnsupportedOperationException() + } + + override fun asMap(): Map> = map + + private inner class PerConfigResourceList : ArrayList() { + + private val resourceItems = arrayListOf>() + + override val size: Int + get() = resourceItems.size + + override fun get(index: Int): ResourceItem = resourceItems[index].first() + + override fun add(element: ResourceItem): Boolean { + add(element, 0) + return true + } + + override fun addAll(elements: Collection): Boolean { + if (elements.isEmpty()) { + return false + } + + if (elements.size == 1) { + return add(elements.first()) + } + + val sortedItems = elements.sortedWith(comparator) + var start = 0 + for (item in sortedItems) { + start = add(item, start) + } + return true + } + + private fun add(item: ResourceItem, start: Int): Int { + var index = findConfigIndex(item, start, resourceItems.size) + if (index < 0) { + index = index.inv() + resourceItems.add(index, mutableListOf(item)) + } else { + val nested = resourceItems[index] + var i = nested.size + while (--i >= 0) { + if (comparator.priorityComparator.compare(item, nested[i]) > 0) { + break + } + } + nested.add(i + 1, item) + } + return index + } + + private fun findConfigIndex(item: ResourceItem, start: Int, end: Int): Int { + val config = item.configuration + var low = start + var high = end + while (low < high) { + val mid = low + high ushr 1 + val result = resourceItems[mid].first().configuration.compareTo(config) + if (result < 0) { + low = mid + 1 + } else if (result > 0) { + high = mid + } else { + return mid + } + } + return low.inv() // not found + } + } + + private inner class Values(override val size: Int) : AbstractCollection() { + override fun iterator(): Iterator = map.values.asSequence().flatten().iterator() + } + } + +} \ No newline at end of file diff --git a/src/main/kotlin/io/johnsonlee/playground/sandbox/resources/ProjectResourceRepository.kt b/src/main/kotlin/io/johnsonlee/playground/sandbox/resources/ProjectResourceRepository.kt new file mode 100644 index 0000000..bfa98da --- /dev/null +++ b/src/main/kotlin/io/johnsonlee/playground/sandbox/resources/ProjectResourceRepository.kt @@ -0,0 +1,48 @@ +package io.johnsonlee.playground.sandbox.resources + +import com.android.ide.common.rendering.api.ResourceNamespace +import java.io.File + +/** + * @link https://cs.android.com/android-studio/platform/tools/adt/idea/+/mirror-goog-studio-main:android/src/com/android/tools/idea/res/ProjectResourceRepository.java + */ +class ProjectResourceRepository private constructor( + displayName: String, + localResources: List, +) : MultiResourceRepository("${displayName} with modules") { + + init { + setChildren(localResources, emptyList()) + } + + companion object { + + fun create( + resourceDirectories: List, + moduleResourceDirectories: List, + ) = ProjectResourceRepository( + displayName = "main", + localResources = buildList { + this += getModuleResources(resourceDirectories) + for (moduleResourceDirectory in moduleResourceDirectories) { + this += getModuleResources(listOf(moduleResourceDirectory)) + } + }, + ) + + private fun getModuleResources( + resourceDirectories: List + ): LocalResourceRepository = ModuleResourceRepository.forMainResources( + namespace = getNamespace(namespacing = ResourceNamespacing.DISABLED, packageName = "TODO"), + resourceDirectories = resourceDirectories + ) + + private fun getNamespace(namespacing: ResourceNamespacing, packageName: String?): ResourceNamespace { + if (namespacing == ResourceNamespacing.DISABLED || packageName == null) { + return ResourceNamespace.RES_AUTO + } + return ResourceNamespace.fromPackageName(packageName) + } + } + +} \ No newline at end of file diff --git a/src/main/kotlin/io/johnsonlee/playground/sandbox/resources/ResourceFile.kt b/src/main/kotlin/io/johnsonlee/playground/sandbox/resources/ResourceFile.kt new file mode 100644 index 0000000..2884739 --- /dev/null +++ b/src/main/kotlin/io/johnsonlee/playground/sandbox/resources/ResourceFile.kt @@ -0,0 +1,40 @@ +package io.johnsonlee.playground.sandbox.resources + +import com.android.ide.common.resources.configuration.FolderConfiguration +import com.android.resources.base.BasicResourceItem +import com.android.resources.base.RepositoryConfiguration +import com.android.resources.base.ResourceSourceFile +import com.android.utils.Base128OutputStream +import it.unimi.dsi.fastutil.objects.Object2IntMap +import java.io.File + +class ResourceFile( + val file: File?, + override val configuration: RepositoryConfiguration +) : ResourceSourceFile, Iterable { + + private val items = mutableListOf() + + val folderConfiguration: FolderConfiguration + get() = configuration.folderConfiguration + + override val repository: ResourceFolderRepository + get() = configuration.repository as ResourceFolderRepository + + override val relativePath: String? + get() = file?.let { + repository.resourceDir.toRelativeString(it) + } + + override fun iterator(): Iterator = items.iterator() + + override fun serialize(stream: Base128OutputStream, configIndexes: Object2IntMap) { + TODO("Not yet implemented") + } + + fun addItem(item: BasicResourceItem) { + items += item + } + + fun isValid(): Boolean = file != null +} \ No newline at end of file diff --git a/src/main/kotlin/io/johnsonlee/playground/sandbox/resources/ResourceFolderRepository.kt b/src/main/kotlin/io/johnsonlee/playground/sandbox/resources/ResourceFolderRepository.kt new file mode 100644 index 0000000..4485a92 --- /dev/null +++ b/src/main/kotlin/io/johnsonlee/playground/sandbox/resources/ResourceFolderRepository.kt @@ -0,0 +1,231 @@ +package io.johnsonlee.playground.sandbox.resources + +import com.android.ide.common.rendering.api.DensityBasedResourceValue +import com.android.ide.common.rendering.api.ResourceNamespace +import com.android.ide.common.resources.ResourceItem +import com.android.ide.common.resources.ResourceVisitor +import com.android.ide.common.util.PathString +import com.android.resources.Density +import com.android.resources.ResourceFolderType +import com.android.resources.ResourceType +import com.android.resources.base.BasicFileResourceItem +import com.android.resources.base.BasicResourceItem +import com.android.resources.base.BasicValueResourceItemBase +import com.android.resources.base.LoadableResourceRepository +import com.android.resources.base.RepositoryConfiguration +import com.android.resources.base.RepositoryLoader +import com.android.resources.base.ResourceSourceFile +import com.android.utils.SdkUtils +import com.google.common.collect.LinkedListMultimap +import com.google.common.collect.ListMultimap +import org.slf4j.LoggerFactory +import java.io.File +import java.nio.file.Path +import java.nio.file.Paths +import java.util.EnumMap +import javax.lang.model.SourceVersion +import kotlin.io.path.exists + +/** + * @link https://cs.android.com/android-studio/platform/tools/adt/idea/+/mirror-goog-studio-main:android/src/com/android/tools/idea/res/ResourceFolderRepository.java + */ +class ResourceFolderRepository( + val resourceDir: File, + private val namespace: ResourceNamespace +) : LocalResourceRepository(resourceDir.name), LoadableResourceRepository { + + private val resourcePathPrefix = "${resourceDir.path}/" + + private val resourcePathBase: PathString = PathString(resourcePathPrefix) + + private val resourceTable = EnumMap>(ResourceType::class.java) + + init { + Loader(this).load() + } + + override fun getLibraryName(): String? = null + + override fun getOrigin(): Path = Paths.get(resourceDir.path) + + override fun getResourceUrl(relativeResourcePath: String): String = "${resourcePathPrefix}${relativeResourcePath}" + + override fun getSourceFile( + relativeResourcePath: String, + forFileResource: Boolean + ): PathString = resourcePathBase.resolve(relativeResourcePath) + + override fun getPackageName(): String = namespace.packageName + + override fun containsUserDefinedResources(): Boolean = true + + override fun accept(visitor: ResourceVisitor): ResourceVisitor.VisitResult { + if (visitor.shouldVisitNamespace(namespace)) { + if (acceptByResources(resourceTable, visitor) == ResourceVisitor.VisitResult.ABORT) { + return ResourceVisitor.VisitResult.ABORT + } + } + return ResourceVisitor.VisitResult.CONTINUE + } + + override fun getMap( + namespace: ResourceNamespace, + resourceType: ResourceType + ): ListMultimap? = if (namespace != this.namespace) { + null + } else { + resourceTable[resourceType] + } + + override fun getNamespace(): ResourceNamespace = namespace + + private fun checkResourceFilename(file: PathString): Boolean { + val fileNameToResourceName = SdkUtils.fileNameToResourceName(file.fileName) + return SourceVersion.isIdentifier(fileNameToResourceName) && !SourceVersion.isKeyword(fileNameToResourceName) + } + + private fun commitToRepository(itemsByType: Map>) { + itemsByType.forEach { (type, items) -> + getOrCreateMap(type).putAll(items) + } + } + + private fun getOrCreateMap(type: ResourceType): ListMultimap { + return resourceTable.computeIfAbsent(type) { LinkedListMultimap.create() } + } + + private class Loader( + private val repository: ResourceFolderRepository + ) : RepositoryLoader( + repository.resourceDir.toPath(), + null, + repository.namespace + ) { + private val resourceDir = repository.resourceDir + private val resources = EnumMap>(ResourceType::class.java) + private val sources = mutableMapOf() + private val fileResources = mutableMapOf() + + private var lastFile: File? = null + private var lastPathString: PathString? = null + + override fun addResourceItem(item: BasicResourceItem, repository: ResourceFolderRepository) { + when (item) { + is BasicValueResourceItemBase -> { + val sourceFile = item.sourceFile as ResourceFile + val file = sourceFile.file + if (file != null && !file.isDirectory) { + sourceFile.addItem(item) + sources[file] = sourceFile + } + } + + is BasicFileResourceItem -> { + val file = getFile(item.source) + if (file != null && !file.isDirectory) { + fileResources[file] = item + } + } + + else -> throw IllegalArgumentException("Unsupported resource item: ${item.javaClass.name}") + } + } + + override fun createResourceSourceFile( + file: PathString, + configuration: RepositoryConfiguration + ): ResourceSourceFile = ResourceFile(getFile(file), configuration) + + fun load() { + if (!resourceDirectoryOrFile.exists()) { + return + } + + scanResFolder() + repository.commitToRepository(resources) + } + + private fun getFile(pathString: PathString): File? = if (pathString == lastPathString) { + lastFile + } else { + pathString.toFile() + } + + private fun scanResFolder() { + try { + resourceDir.listFiles()?.filter(File::isDirectory)?.sorted()?.forEach root@{ subDir -> + val folderName = subDir.name + val folderInfo = FolderInfo.create(folderName, myFolderConfigCache) ?: return@root + val configuration = getConfiguration(repository, folderInfo.configuration) + subDir.listFiles()?.filter { + it.name.startsWith(".") + }?.sorted()?.forEach sub@{ file -> + if (file in (if (folderInfo.folderType == ResourceFolderType.VALUES) sources else fileResources)) { + return@sub + } + + val pathString = PathString(file) + lastFile = file + lastPathString = pathString + loadResourceFile(pathString, folderInfo, configuration) + } + } + } catch (e: Throwable) { + logger.warn("Failed to load resources from ${resourceDirectoryOrFile}", e) + } + + super.finishLoading(repository) + + fileResources.entries.forEach { (file, item) -> + val source = sources.computeIfAbsent(file) { + ResourceFile(it, item.repositoryConfiguration) + } + source.addItem(item) + } + + sources.values.toMutableList().sortedWith(SOURCE_COMPARATOR).forEach { source -> + source.forEach { item -> + resources.getOrPut(item.type) { + LinkedListMultimap.create() + }.put(item.name, item) + } + } + } + + private fun loadResourceFile( + file: PathString, + folderInfo: FolderInfo, + configuration: RepositoryConfiguration + ) { + if (folderInfo.resourceType == null) { + if (isXmlFile(file)) { + parseValueResourceFile(file, configuration) + } + } else if (repository.checkResourceFilename(file)) { + if (isXmlFile(file) && folderInfo.isIdGenerating) { + parseIdGeneratingResourceFile(file, configuration) + } + val item = createFileResourceItem(file, folderInfo.resourceType, configuration) + addResourceItem(item, item.repository as ResourceFolderRepository) + } + } + + fun createFileResourceItem( + file: PathString, + resourceType: ResourceType, + configuration: RepositoryConfiguration + ): BasicFileResourceItem { + val resourceName = SdkUtils.fileNameToResourceName(file.fileName) + val visibility = getVisibility(resourceType, resourceName) + val density: Density? = configuration.takeIf { + DensityBasedResourceValue.isDensityBasedResourceType(resourceType) + }?.folderConfiguration?.densityQualifier?.value + return createFileResourceItem(file, resourceType, resourceName, configuration, visibility, density) + } + + companion object { + private val logger = LoggerFactory.getLogger(Loader::class.java) + private val SOURCE_COMPARATOR = Comparator.comparing(ResourceFile::folderConfiguration) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/johnsonlee/playground/sandbox/resources/ResourceNamespacing.kt b/src/main/kotlin/io/johnsonlee/playground/sandbox/resources/ResourceNamespacing.kt new file mode 100644 index 0000000..61a11a0 --- /dev/null +++ b/src/main/kotlin/io/johnsonlee/playground/sandbox/resources/ResourceNamespacing.kt @@ -0,0 +1,13 @@ +package io.johnsonlee.playground.sandbox.resources + +enum class ResourceNamespacing { + /** + * Resources are not namespaced. + */ + DISABLED, + + /** + * Resources must be namespaced. + */ + REQUIRED +} \ No newline at end of file diff --git a/src/main/kotlin/io/johnsonlee/playground/util/BufferedImage.kt b/src/main/kotlin/io/johnsonlee/playground/util/BufferedImage.kt new file mode 100644 index 0000000..c305823 --- /dev/null +++ b/src/main/kotlin/io/johnsonlee/playground/util/BufferedImage.kt @@ -0,0 +1,17 @@ +package io.johnsonlee.playground.util + +import java.awt.image.BufferedImage +import java.io.ByteArrayOutputStream +import java.nio.charset.StandardCharsets +import java.util.Base64 +import javax.imageio.ImageIO + +fun BufferedImage.toBase64(format: String): String { + val baos = ByteArrayOutputStream().apply { + bufferedWriter(StandardCharsets.UTF_8).append("data:image/$format;base64,").flush() + } + Base64.getEncoder().wrap(baos).use { encoder -> + ImageIO.write(this, format, encoder) + } + return baos.toString(StandardCharsets.UTF_8) +} \ No newline at end of file diff --git a/src/main/kotlin/io/johnsonlee/playground/util/Density.kt b/src/main/kotlin/io/johnsonlee/playground/util/Density.kt new file mode 100644 index 0000000..f24af5a --- /dev/null +++ b/src/main/kotlin/io/johnsonlee/playground/util/Density.kt @@ -0,0 +1,17 @@ +package io.johnsonlee.playground.util + +import com.android.resources.Density + +fun create(dpi: Int): Density { + val density = Density.getEnum(dpi) + if (null != density) return density + val value = "${dpi}dpi" + return Density::class.java.getConstructor( + String::class.java, + String::class.java, + Int::class.java, + Int::class.java + ).apply { + isAccessible = true + }.newInstance(value, value, dpi, 1) +} \ No newline at end of file diff --git a/src/main/kotlin/io/johnsonlee/playground/util/Gc.kt b/src/main/kotlin/io/johnsonlee/playground/util/Gc.kt new file mode 100644 index 0000000..f105af3 --- /dev/null +++ b/src/main/kotlin/io/johnsonlee/playground/util/Gc.kt @@ -0,0 +1,31 @@ +package io.johnsonlee.playground.util + +import org.slf4j.LoggerFactory +import java.lang.ref.WeakReference +import kotlin.time.measureTime + +object Gc { + + private val logger = LoggerFactory.getLogger(Gc::class.java) + + @Suppress("UNUSED_VALUE") + fun gc() { + val duration = measureTime { + var obj: Any? = Any() + val ref = WeakReference(obj) + + obj = null + + while (ref.get() != null) { + System.gc() + System.runFinalization() + } + + System.gc() + System.runFinalization() + } + + logger.info("GC completed in $duration") + } + +} \ No newline at end of file diff --git a/src/main/kotlin/io/johnsonlee/playground/util/Reflection.kt b/src/main/kotlin/io/johnsonlee/playground/util/Reflection.kt new file mode 100644 index 0000000..ab66ca5 --- /dev/null +++ b/src/main/kotlin/io/johnsonlee/playground/util/Reflection.kt @@ -0,0 +1,42 @@ +@file:Suppress("removal", "DEPRECATION") +package io.johnsonlee.playground.util + +import sun.misc.Unsafe +import java.lang.reflect.Field +import java.lang.reflect.Modifier +import java.security.AccessController +import java.security.PrivilegedAction + +fun Class<*>.getFieldReflectively(name: String): Field { + return getDeclaredField(name).apply { + isAccessible = true + } +} + +fun Field.setStaticValue(value: Any) { + this.isAccessible = true + + if (Modifier.isFinal(this.modifiers)) { + AccessController.doPrivileged(PrivilegedAction{ + val unsafe = Unsafe::class.java.getFieldReflectively("theUnsafe").get(null) as Unsafe + val offset = unsafe.staticFieldOffset(this) + val base = unsafe.staticFieldBase(this) + unsafe.setFieldValue(this, base, offset, value) + null + }) + } else { + this.set(null, value) + } +} + +private fun Unsafe.setFieldValue(field: Field, base: Any, offset: Long, value: Any) = when (field.type) { + java.lang.Integer.TYPE -> this.putInt(base, offset, value as Int) + java.lang.Short.TYPE -> this.putShort(base, offset, value as Short) + java.lang.Byte.TYPE -> this.putByte(base, offset, value as Byte) + java.lang.Long.TYPE -> this.putLong(base, offset, value as Long) + java.lang.Float.TYPE -> this.putFloat(base, offset, value as Float) + java.lang.Double.TYPE -> this.putDouble(base, offset, value as Double) + java.lang.Boolean.TYPE -> this.putBoolean(base, offset, value as Boolean) + java.lang.Character.TYPE -> this.putChar(base, offset, value as Char) + else -> this.putObject(base, offset, value) +} \ No newline at end of file diff --git a/src/main/kotlin/io/johnsonlee/playground/util/ResultExtension.kt b/src/main/kotlin/io/johnsonlee/playground/util/ResultExtension.kt new file mode 100644 index 0000000..e4db352 --- /dev/null +++ b/src/main/kotlin/io/johnsonlee/playground/util/ResultExtension.kt @@ -0,0 +1,10 @@ +@file:JvmName("ResultUtils") +package io.johnsonlee.playground.util + +import com.android.ide.common.rendering.api.Result + +fun Result.check(): Result = apply { + if (!isSuccess) { + throw exception ?: Exception(errorMessage ?: status.toString()) + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/johnsonlee/playground/util/String.kt b/src/main/kotlin/io/johnsonlee/playground/util/String.kt new file mode 100644 index 0000000..b36e94a --- /dev/null +++ b/src/main/kotlin/io/johnsonlee/playground/util/String.kt @@ -0,0 +1,10 @@ +package io.johnsonlee.playground.util + +import java.io.File + +fun String.capitalized(): String { + return this[0].uppercase() + this.substring(1) +} + +@JvmSynthetic +internal fun String.toCanonicalFile() = File(this).canonicalFile