Repository: google/accompanist Branch: main Commit: 12ec3408fc10 Files: 157 Total size: 515.2 KB Directory structure: gitextract_cgrc05nu/ ├── .allstar/ │ └── binary_artifacts.yaml ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── adaptive-bug-report.md │ │ ├── bug_report.md │ │ ├── config.yml │ │ ├── general-bug-report.md │ │ ├── general-other-bug-report.md │ │ ├── navigation-material-bug-report.md │ │ ├── permissions-bug-report.md │ │ └── testharness-bug-report.md │ ├── auto-merge.yml │ ├── ci-gradle.properties │ ├── pull_request_template.md │ ├── release-drafter.yml │ └── workflows/ │ ├── automerger.yml │ ├── build-snapshot.yml │ ├── build.yml │ ├── issues-stale.yml │ ├── publish-docs.yml │ └── update-release.yml ├── .gitignore ├── .idea/ │ ├── codeStyles/ │ │ ├── Project.xml │ │ └── codeStyleConfig.xml │ ├── copyright/ │ │ ├── AOSP.xml │ │ └── profiles_settings.xml │ ├── inspectionProfiles/ │ │ ├── ktlint.xml │ │ └── profiles_settings.xml │ ├── kotlinScripting.xml │ ├── kotlinc.xml │ ├── runConfigurations.xml │ └── vcs.xml ├── ASSETS_LICENSE.txt ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── adaptive/ │ ├── README.md │ ├── api/ │ │ └── current.api │ ├── build.gradle.kts │ ├── gradle.properties │ └── src/ │ ├── main/ │ │ ├── AndroidManifest.xml │ │ └── java/ │ │ └── com/ │ │ └── google/ │ │ └── accompanist/ │ │ └── adaptive/ │ │ ├── DisplayFeatures.kt │ │ ├── FoldAwareColumn.kt │ │ ├── FoldAwareColumnScope.kt │ │ ├── RowColumnImpl.kt │ │ ├── RowColumnMeasurementHelper.kt │ │ └── TwoPane.kt │ ├── sharedTest/ │ │ └── kotlin/ │ │ └── com/ │ │ └── google/ │ │ └── accompanist/ │ │ └── adaptive/ │ │ ├── DisplayFeaturesTest.kt │ │ ├── FoldAwareColumnTest.kt │ │ └── TwoPaneTest.kt │ └── test/ │ └── resources/ │ └── robolectric.properties ├── build-logic/ │ ├── convention/ │ │ ├── build.gradle.kts │ │ └── src/ │ │ └── main/ │ │ └── kotlin/ │ │ ├── AndroidLibraryComposeConventionPlugin.kt │ │ ├── AndroidLibraryConventionPlugin.kt │ │ ├── AndroidLibraryPublishedConventionPlugin.kt │ │ ├── AndroidLintConventionPlugin.kt │ │ └── com/ │ │ └── google/ │ │ └── accompanist/ │ │ ├── AndroidCompose.kt │ │ ├── BundleInsideHelper.kt │ │ ├── KotlinAndroid.kt │ │ └── ProjectExtensions.kt │ ├── gradle.properties │ └── settings.gradle.kts ├── build.gradle ├── checksum.sh ├── docs/ │ ├── adaptive.md │ ├── appcompat-theme.md │ ├── drawablepainter.md │ ├── migration.md │ ├── navigation-animation.md │ ├── navigation-material.md │ ├── permissions.md │ ├── systemuicontroller.md │ ├── updating.md │ ├── using-snapshot-version.md │ └── web.md ├── drawablepainter/ │ ├── README.md │ ├── api/ │ │ └── current.api │ ├── build.gradle.kts │ ├── gradle.properties │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ └── java/ │ └── com/ │ └── google/ │ └── accompanist/ │ └── drawablepainter/ │ └── DrawablePainter.kt ├── generate_docs.sh ├── gradle/ │ ├── libs.versions.toml │ └── wrapper/ │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradle.properties ├── gradlew ├── gradlew.bat ├── images/ │ └── Social.sketch ├── internal-testutils/ │ ├── build.gradle.kts │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ ├── java/ │ │ └── com/ │ │ └── google/ │ │ └── accompanist/ │ │ └── internal/ │ │ └── test/ │ │ ├── ActivityScenario.kt │ │ ├── Assertions.kt │ │ ├── IgnoreOnRobolectric.kt │ │ ├── TestUtils.kt │ │ └── WaitUntil.kt │ └── res/ │ └── values/ │ └── themes.xml ├── mkdocs.yml ├── permissions/ │ ├── README.md │ ├── api/ │ │ └── current.api │ ├── build.gradle.kts │ ├── gradle.properties │ └── src/ │ ├── androidTest/ │ │ ├── AndroidManifest.xml │ │ └── java/ │ │ └── com/ │ │ └── google/ │ │ └── accompanist/ │ │ └── permissions/ │ │ ├── FakeTests.kt │ │ ├── MultipleAndSinglePermissionsTest.kt │ │ ├── MultiplePermissionsStateTest.kt │ │ ├── PermissionStateTest.kt │ │ ├── RequestMultiplePermissionsTest.kt │ │ ├── RequestPermissionTest.kt │ │ ├── TestUtils.kt │ │ └── test/ │ │ ├── EmptyPermissionsTestActivity.kt │ │ └── PermissionsTestActivity.kt │ └── main/ │ ├── AndroidManifest.xml │ └── java/ │ └── com/ │ └── google/ │ └── accompanist/ │ └── permissions/ │ ├── MultiplePermissionsState.kt │ ├── MutableMultiplePermissionsState.kt │ ├── MutablePermissionState.kt │ ├── PermissionState.kt │ └── PermissionsUtil.kt ├── permissions-lint/ │ ├── README.md │ ├── build.gradle.kts │ └── src/ │ ├── main/ │ │ ├── java/ │ │ │ └── com/ │ │ │ └── google/ │ │ │ └── accompanist/ │ │ │ └── permissions/ │ │ │ └── lint/ │ │ │ ├── PermissionsIssueRegistry.kt │ │ │ ├── PermissionsLaunchDetector.kt │ │ │ └── util/ │ │ │ ├── ComposableUtils.kt │ │ │ ├── KotlinMetadataUtils.kt │ │ │ ├── Names.kt │ │ │ └── PsiUtils.kt │ │ └── resources/ │ │ └── META-INF/ │ │ └── services/ │ │ └── com.android.tools.lint.client.api.IssueRegistry │ └── test/ │ └── java/ │ └── com/ │ └── google/ │ └── accompanist/ │ └── permissions/ │ └── lint/ │ └── PermissionsLaunchDetectorTest.kt ├── release/ │ ├── secring.gpg.aes │ ├── signing-cleanup.sh │ ├── signing-setup.sh │ └── signing.properties.aes ├── sample/ │ ├── build.gradle.kts │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ ├── java/ │ │ └── com/ │ │ └── google/ │ │ └── accompanist/ │ │ └── sample/ │ │ ├── ImageLoadingSampleUtils.kt │ │ ├── MainActivity.kt │ │ ├── MainScreen.kt │ │ ├── Theme.kt │ │ ├── adaptive/ │ │ │ ├── BasicTwoPaneSample.kt │ │ │ ├── DraggableFoldAwareColumnSample.kt │ │ │ ├── HorizontalTwoPaneSample.kt │ │ │ ├── NavDrawerFoldAwareColumnSample.kt │ │ │ ├── NavRailFoldAwareColumnSample.kt │ │ │ └── VerticalTwoPaneSample.kt │ │ ├── drawablepainter/ │ │ │ └── DocSamples.kt │ │ └── permissions/ │ │ ├── RequestLocationPermissionsSample.kt │ │ ├── RequestMultiplePermissionsSample.kt │ │ └── RequestPermissionSample.kt │ └── res/ │ ├── drawable/ │ │ ├── ic_launcher_background.xml │ │ └── rectangle.xml │ ├── drawable-v24/ │ │ └── ic_launcher_foreground.xml │ ├── mipmap-anydpi-v26/ │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ ├── values/ │ │ └── strings.xml │ └── values-ar/ │ └── strings.xml ├── scripts/ │ └── run-tests.sh ├── settings.gradle.kts └── spotless/ ├── copyright.txt └── greclipse.properties ================================================ FILE CONTENTS ================================================ ================================================ FILE: .allstar/binary_artifacts.yaml ================================================ # Exemption reason: This repo uses binary artifacts to ship gradle.jar for users. It does not allow any others. # Exemption timeframe: permanent optConfig: optOut: true ================================================ FILE: .github/ISSUE_TEMPLATE/adaptive-bug-report.md ================================================ --- name: Adaptive bug report about: Create a report about adaptive title: "[Adaptive]" labels: adaptive assignees: alexvanyo --- **Description** **Steps to reproduce** **Expected behavior** **Additional context** ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Create a report to help us improve title: '' labels: '' assignees: '' --- ## Describe the bug A clear and concise description of what the bug is. ## To Reproduce Steps to reproduce the behavior: 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error ## Expected behavior A clear and concise description of what you expected to happen. ## Screenshots? If applicable, add screenshots to help explain your problem. ## Environment: - Android OS version: [e.g. Android 5.0] - Device: [e.g. Emulator, Google Pixel 4] - Accompanist version: [e.g. v0.X] ## Additional context Add any other context about the problem here. ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: false ================================================ FILE: .github/ISSUE_TEMPLATE/general-bug-report.md ================================================ --- name: General Bug report about: Create a report to help us improve title: '' labels: '' assignees: '' --- ## Describe the bug A clear and concise description of what the bug is. ## To Reproduce (if applicable) Steps to reproduce the behavior: 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error ## Expected behavior (if applicable) A clear and concise description of what you expected to happen. ## Screenshots? (if applicable) If applicable, add screenshots to help explain your problem. ## Environment: (if applicable) - Android OS version: [e.g. Android 5.0] - Device: [e.g. Emulator, Google Pixel 4] - Accompanist version: [e.g. v0.X] ## Additional context Add any other context about the problem here. ================================================ FILE: .github/ISSUE_TEMPLATE/general-other-bug-report.md ================================================ --- name: General/Other bug report about: Create a report to help us improve title: '' labels: '' assignees: '' --- ## Describe the bug A clear and concise description of what the bug is. ## To Reproduce (if applicable) Steps to reproduce the behavior: 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error ## Expected behavior (if applicable) A clear and concise description of what you expected to happen. ## Screenshots? (if applicable) If applicable, add screenshots to help explain your problem. ## Environment: (if applicable) - Android OS version: [e.g. Android 5.0] - Device: [e.g. Emulator, Google Pixel 4] - Accompanist version: [e.g. v0.X] ## Additional context Add any other context about the problem here. ================================================ FILE: .github/ISSUE_TEMPLATE/navigation-material-bug-report.md ================================================ --- name: Navigation Material bug report about: Create a report to help us improve title: "[Navigation Material] " labels: '' assignees: jossiwolf --- **Description** **Steps to reproduce** **Expected behavior** **Additional context** ================================================ FILE: .github/ISSUE_TEMPLATE/permissions-bug-report.md ================================================ --- name: Permissions bug report about: Create a report to help us improve title: "[Permissions] " labels: '' assignees: bentrengrove --- **Description** **Steps to reproduce** **Expected behavior** **Additional context** ================================================ FILE: .github/ISSUE_TEMPLATE/testharness-bug-report.md ================================================ --- name: Test harness bug report about: Create a report about test harness title: "[Test Harness]" labels: testharness assignees: alexvanyo --- **Description** **Steps to reproduce** **Expected behavior** **Additional context** ================================================ FILE: .github/auto-merge.yml ================================================ # Config for github.com/bobvanderlinden/probot-auto-merge minApprovals: COLLABORATOR: 1 requiredLabels: - automerge mergeMethod: merge reportStatus: true ================================================ FILE: .github/ci-gradle.properties ================================================ # # Copyright 2019 Google LLC # # 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. # # Turn Gradle daemon off due to https://github.com/Kotlin/dokka/issues/1405 org.gradle.daemon=false org.gradle.parallel=true org.gradle.jvmargs=-Xmx4608m -XX:MaxMetaspaceSize=2048m -XX:+HeapDumpOnOutOfMemoryError org.gradle.workers.max=2 kotlin.compiler.execution.strategy=in-process ================================================ FILE: .github/pull_request_template.md ================================================ ### Please add the library name to the PR title. Example: "[Insets] Fixes typo" ### ================================================ FILE: .github/release-drafter.yml ================================================ name-template: 'v$NEXT_PATCH_VERSION 🌈' tag-template: 'v$NEXT_PATCH_VERSION' template: | ## What’s Changed $CHANGES ================================================ FILE: .github/workflows/automerger.yml ================================================ name: main to snapshot auto merger on: push: branches: - main jobs: automerge: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 with: fetch-depth: '0' # 0 == fetch all history history ref: 'snapshot' token: ${{ secrets.AUTOMERGE_PAT }} - run: | git config user.name github-actions git config user.email github-actions@github.com git fetch origin git merge origin/main --no-edit git push ================================================ FILE: .github/workflows/build-snapshot.yml ================================================ name: Build & test (snapshot) on: push: branches: - snapshot paths-ignore: - '**.md' pull_request: branches: - snapshot workflow_dispatch: jobs: build: # Skip build if head commit contains 'skip ci' if: "!contains(github.event.head_commit.message, 'skip ci')" runs-on: ubuntu-latest timeout-minutes: 30 steps: - uses: actions/checkout@v2 with: # Fetch expanded history, which is needed for affected module detection fetch-depth: '500' - name: Copy CI gradle.properties run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties - name: set up JDK uses: actions/setup-java@v1 with: java-version: 17 - name: Decrypt secrets run: release/signing-setup.sh ${{ secrets.ENCRYPT_KEY }} - name: Generate cache key run: ./checksum.sh checksum.txt - uses: actions/cache@v2 with: path: | ~/.gradle/caches/modules-* ~/.gradle/caches/jars-* ~/.gradle/caches/build-cache-* key: gradle-${{ hashFiles('checksum.txt') }} - name: Build run: | ./gradlew --scan --stacktrace \ spotlessCheck \ assemble \ metalavaCheckCompatibilityRelease \ lintDebug - name: Unit Tests run: | ./scripts/run-tests.sh \ --unit-tests \ --run-affected \ --affected-base-ref=$BASE_REF - name: Upload test results if: always() uses: actions/upload-artifact@v4 with: name: test-results-robolectric path: | **/build/test-results/* **/build/reports/* - name: Clean secrets if: always() run: release/signing-cleanup.sh test: runs-on: macos-latest needs: build timeout-minutes: 50 strategy: # Allow tests to continue on other devices if they fail on one device. fail-fast: false matrix: api-level: [ 22, 26, 29, 31, 32 ] shard: [ 0, 1 ] # Need to update shard-count below if this changes env: TERM: dumb steps: - uses: actions/checkout@v2 with: # Fetch expanded history, which is needed for affected module detection fetch-depth: '500' - name: Copy CI gradle.properties run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties - name: set up JDK uses: actions/setup-java@v1 with: java-version: 17 - name: Decrypt secrets run: release/signing-setup.sh ${{ secrets.ENCRYPT_KEY }} - name: Generate cache key run: ./checksum.sh checksum.txt - uses: actions/cache@v2 with: path: | ~/.gradle/caches/modules-* ~/.gradle/caches/jars-* ~/.gradle/caches/build-cache-* key: gradle-${{ hashFiles('checksum.txt') }} # Determine what emulator image to use. We run all API 28+ emulators using # the google_apis image - name: Determine emulator target id: determine-target env: API_LEVEL: ${{ matrix.api-level }} run: | TARGET="default" if [ "$API_LEVEL" -ge "28" ]; then TARGET="google_apis" fi echo "TARGET=$TARGET" >> $GITHUB_OUTPUT - name: Run tests uses: reactivecircus/android-emulator-runner@v2 with: api-level: ${{ matrix.api-level }} target: ${{ steps.determine-target.outputs.TARGET }} profile: Galaxy Nexus script: ./scripts/run-tests.sh --log-file=logcat.txt --run-affected --affected-base-ref=$BASE_REF --shard-index=${{ matrix.shard }} --shard-count=2 - name: Clean secrets if: always() run: release/signing-cleanup.sh - name: Upload logs if: always() uses: actions/upload-artifact@v2 with: name: logs-${{ matrix.api-level }}-${{ steps.determine-target.outputs.TARGET }}-${{ matrix.shard }} path: logcat.txt - name: Upload test results if: always() uses: actions/upload-artifact@v2 with: name: test-results-${{ matrix.api-level }}-${{ steps.determine-target.outputs.TARGET }}-${{ matrix.shard }} path: | **/build/reports/* **/build/outputs/*/connected/* deploy: if: github.event_name == 'push' # only deploy for pushed commits (not PRs) runs-on: ubuntu-latest needs: [ build, test ] timeout-minutes: 30 env: TERM: dumb steps: - uses: actions/checkout@v2 - name: Copy CI gradle.properties run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties - name: set up JDK uses: actions/setup-java@v1 with: java-version: 17 - name: Decrypt secrets run: release/signing-setup.sh ${{ secrets.ENCRYPT_KEY }} - name: Generate cache key run: ./checksum.sh checksum.txt - uses: actions/cache@v2 with: path: | ~/.gradle/caches/modules-* ~/.gradle/caches/jars-* ~/.gradle/caches/build-cache-* key: gradle-${{ hashFiles('checksum.txt') }} - name: Deploy to Sonatype run: ./gradlew publish --no-parallel --stacktrace --no-configuration-cache env: ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.SONATYPE_NEXUS_USERNAME }} ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.SONATYPE_NEXUS_PASSWORD }} - name: Clean secrets if: always() run: release/signing-cleanup.sh ================================================ FILE: .github/workflows/build.yml ================================================ name: Build & test on: push: branches: - main - compose-1.0 - compose-1.1 - compose-1.2 - compose-1.3 - compose-1.4 - compose-1.5 - compose-1.6 paths-ignore: - '**.md' pull_request: jobs: build: # Skip build if head commit contains 'skip ci' if: "!contains(github.event.head_commit.message, 'skip ci')" runs-on: ubuntu-latest timeout-minutes: 45 steps: - uses: actions/checkout@v2 with: # Fetch expanded history, which is needed for affected module detection fetch-depth: '500' - name: Copy CI gradle.properties run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties - name: Setup java uses: actions/setup-java@v3 with: distribution: temurin java-version: 17 - name: Decrypt secrets run: release/signing-setup.sh ${{ secrets.ENCRYPT_KEY }} - name: Setup Gradle uses: gradle/gradle-build-action@v2 - name: Build run: | ./gradlew --scan --stacktrace \ spotlessCheck \ assemble \ metalavaCheckCompatibilityRelease \ lintDebug - name: Unit Tests run: | ./scripts/run-tests.sh \ --unit-tests \ --run-affected \ --affected-base-ref=$BASE_REF - name: Upload test results if: always() uses: actions/upload-artifact@v4 with: name: test-results-robolectric path: | **/build/test-results/* **/build/reports/* - name: Clean secrets if: always() run: release/signing-cleanup.sh test: runs-on: ubuntu-latest needs: build timeout-minutes: 70 strategy: # Allow tests to continue on other devices if they fail on one device. fail-fast: false matrix: api-level: [ 22, 26, 30 ] shard: [ 0, 1 ] # Need to update shard-count below if this changes env: TERM: dumb steps: - uses: actions/checkout@v3 with: # Fetch expanded history, which is needed for affected module detection fetch-depth: '500' - name: Copy CI gradle.properties run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties - name: Enable KVM run: | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm - name: Setup java uses: actions/setup-java@v3 with: distribution: temurin java-version: 17 - name: Decrypt secrets run: release/signing-setup.sh ${{ secrets.ENCRYPT_KEY }} - name: Setup Gradle uses: gradle/gradle-build-action@v2 # Determine what emulator image to use. We run all API 28+ emulators using # the google_apis image - name: Determine emulator target id: determine-target env: API_LEVEL: ${{ matrix.api-level }} run: | TARGET="default" if [ "$API_LEVEL" -ge "28" ]; then TARGET="google_apis" fi echo "TARGET=$TARGET" >> $GITHUB_OUTPUT - name: Determine emulator arch id: determine-arch env: API_LEVEL: ${{ matrix.api-level }} run: | ARCH="x86" if [ "$API_LEVEL" -ge "29" ]; then ARCH="x86_64" fi echo "ARCH=$ARCH" >> $GITHUB_OUTPUT - name: Run tests uses: reactivecircus/android-emulator-runner@v2 with: api-level: ${{ matrix.api-level }} arch: ${{ steps.determine-arch.outputs.ARCH }} target: ${{ steps.determine-target.outputs.TARGET }} profile: Galaxy Nexus script: ./scripts/run-tests.sh --log-file=logcat.txt --run-affected --affected-base-ref=$BASE_REF --shard-index=${{ matrix.shard }} --shard-count=2 - name: Clean secrets if: always() run: release/signing-cleanup.sh - name: Upload logs if: always() uses: actions/upload-artifact@v4 with: name: logs-${{ matrix.api-level }}-${{ steps.determine-target.outputs.TARGET }}-${{ matrix.shard }} path: logcat.txt - name: Upload test results if: always() uses: actions/upload-artifact@v4 with: name: test-results-${{ matrix.api-level }}-${{ steps.determine-target.outputs.TARGET }}-${{ matrix.shard }} path: | **/build/reports/* **/build/outputs/*/connected/* deploy: if: github.event_name == 'push' # only deploy for pushed commits (not PRs) runs-on: ubuntu-latest needs: [ build, test ] timeout-minutes: 30 env: TERM: dumb steps: - uses: actions/checkout@v2 - name: Copy CI gradle.properties run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties - name: Setup java uses: actions/setup-java@v3 with: distribution: temurin java-version: 17 - name: Decrypt secrets run: release/signing-setup.sh ${{ secrets.ENCRYPT_KEY }} - name: Setup Gradle uses: gradle/gradle-build-action@v2 - name: Deploy to Sonatype run: ./gradlew publish --no-parallel --stacktrace --no-configuration-cache env: ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.SONATYPE_NEXUS_USERNAME }} ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.SONATYPE_NEXUS_PASSWORD }} - name: Clean secrets if: always() run: release/signing-cleanup.sh ================================================ FILE: .github/workflows/issues-stale.yml ================================================ name: 'Close stale issues and PRs' on: schedule: - cron: '15 3 * * *' jobs: stale: runs-on: ubuntu-latest steps: - uses: actions/stale@v3 with: stale-issue-message: 'This issue is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 5 days.' days-before-stale: 45 days-before-close: 5 exempt-all-pr-milestones: true exempt-issue-labels: 'waiting for info,waiting on dependency' ================================================ FILE: .github/workflows/publish-docs.yml ================================================ name: Publish docs on: push: tags: - v* workflow_dispatch: jobs: deploy_docs: runs-on: ubuntu-latest env: TERM: dumb steps: - uses: actions/checkout@v2 - name: Copy CI gradle.properties run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties - name: Setup java uses: actions/setup-java@v3 with: distribution: temurin java-version: 17 - name: Setup Gradle uses: gradle/gradle-build-action@v2 - name: Setup Python uses: actions/setup-python@v4 with: python-version: '3.x' - name: Install dependencies run: | python3 -m pip install --upgrade pip python3 -m pip install mkdocs-material=="9.*" - name: Generate docs run: ./generate_docs.sh - name: Build site run: mkdocs build - name: Deploy uses: peaceiris/actions-gh-pages@v3 with: github_token: ${{ secrets.GITHUB_TOKEN }} publish_dir: ./site ================================================ FILE: .github/workflows/update-release.yml ================================================ name: Update release on: push: branches: - main jobs: update_draft_release: runs-on: ubuntu-latest steps: # pin directly to 6.0.0 because we don't want to update without knowledge - uses: release-drafter/release-drafter@3f0f87098bd6b5c5b9a36d49c41d998ea58f9348 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .gitignore ================================================ # Gradle .gradle build/ captures /local.properties # IntelliJ .idea folder .idea/workspace.xml .idea/libraries .idea/caches .idea/navEditor.xml .idea/tasks.xml .idea/modules.xml .idea/compiler.xml .idea/jarRepositories.xml .idea/deploymentTargetDropDown.xml .idea/misc.xml .idea/androidTestResultsUserPreferences.xml .idea/deploymentTargetSelector.xml gradle.xml *.iml # General .DS_Store .externalNativeBuild # Do not commit plain-text signing info release/*.properties release/*.gpg # VS Code config org.eclipse.buildship.core.prefs .classpath .project # Temporary API docs docs/api package-list-coil-base # Mkdocs temporary serving folder docs-gen site *.bak # Lint reports lint-report.* ================================================ FILE: .idea/codeStyles/Project.xml ================================================
xmlns:android ^$
xmlns:.* ^$ BY_NAME
.*:id http://schemas.android.com/apk/res/android
.*:name http://schemas.android.com/apk/res/android
name ^$
style ^$
.* ^$ BY_NAME
.* http://schemas.android.com/apk/res/android ANDROID_ATTRIBUTE_ORDER
.* .* BY_NAME
================================================ FILE: .idea/codeStyles/codeStyleConfig.xml ================================================ ================================================ FILE: .idea/copyright/AOSP.xml ================================================ ================================================ FILE: .idea/copyright/profiles_settings.xml ================================================ ================================================ FILE: .idea/inspectionProfiles/ktlint.xml ================================================ ================================================ FILE: .idea/inspectionProfiles/profiles_settings.xml ================================================ ================================================ FILE: .idea/kotlinScripting.xml ================================================ ================================================ FILE: .idea/kotlinc.xml ================================================ ================================================ FILE: .idea/runConfigurations.xml ================================================ ================================================ FILE: .idea/vcs.xml ================================================ ================================================ FILE: ASSETS_LICENSE.txt ================================================ All font files are licensed under the SIL OPEN FONT LICENSE license. All other files are licensed under the Apache 2 license. SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 PREAMBLE The goals of the Open Font License (OFL) are to stimulate worldwide development of collaborative font projects, to support the font creation efforts of academic and linguistic communities, and to provide a free and open framework in which fonts may be shared and improved in partnership with others. The OFL allows the licensed fonts to be used, studied, modified and redistributed freely as long as they are not sold by themselves. The fonts, including any derivative works, can be bundled, embedded, redistributed and/or sold with any software provided that any reserved names are not used by derivative works. The fonts and derivatives, however, cannot be released under any other type of license. The requirement for fonts to remain under this license does not apply to any document created using the fonts or their derivatives. DEFINITIONS "Font Software" refers to the set of files released by the Copyright Holder(s) under this license and clearly marked as such. This may include source files, build scripts and documentation. "Reserved Font Name" refers to any names specified as such after the copyright statement(s). "Original Version" refers to the collection of Font Software components as distributed by the Copyright Holder(s). "Modified Version" refers to any derivative made by adding to, deleting, or substituting — in part or in whole — any of the components of the Original Version, by changing formats or by porting the Font Software to a new environment. "Author" refers to any designer, engineer, programmer, technical writer or other person who contributed to the Font Software. PERMISSION & CONDITIONS Permission is hereby granted, free of charge, to any person obtaining a copy of the Font Software, to use, study, copy, merge, embed, modify, redistribute, and sell modified and unmodified copies of the Font Software, subject to the following conditions: 1) Neither the Font Software nor any of its individual components, in Original or Modified Versions, may be sold by itself. 2) Original or Modified Versions of the Font Software may be bundled, redistributed and/or sold with any software, provided that each copy contains the above copyright notice and this license. These can be included either as stand-alone text files, human-readable headers or in the appropriate machine-readable metadata fields within text or binary files as long as those fields can be easily viewed by the user. 3) No Modified Version of the Font Software may use the Reserved Font Name(s) unless explicit written permission is granted by the corresponding Copyright Holder. This restriction only applies to the primary font name as presented to the users. 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font Software shall not be used to promote, endorse or advertise any Modified Version, except to acknowledge the contribution(s) of the Copyright Holder(s) and the Author(s) or with their explicit written permission. 5) The Font Software, modified or unmodified, in part or in whole, must be distributed entirely under this license, and must not be distributed under any other license. The requirement for fonts to remain under this license does not apply to any document created using the Font Software. TERMINATION This license becomes null and void if any of the above conditions are not met. DISCLAIMER THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE. ================================================ FILE: CONTRIBUTING.md ================================================ # How to Contribute We'd love to accept your patches and contributions to this project. There are just a few small guidelines you need to follow. ## New Features/Libraries Before contributing large new features and/or libraries please start a discussion with us first via GitHub Issues and check that we can support it. We are unable to support all new features, even though we wish we could! If we are unable to support adding your feature, we always encourage you to open source it in your own repository to help the Compose community grow. ## Contributor License Agreement Contributions to this project must be accompanied by a Contributor License Agreement. You (or your employer) retain the copyright to your contribution, this simply gives us permission to use and redistribute your contributions as part of the project. Head over to to see your current agreements on file or to sign a new one. You generally only need to submit a CLA once, so if you've already submitted one (even if it was for a different project), you probably don't need to do it again. ## Code Reviews All submissions, including submissions by project members, require review. We use GitHub pull requests for this purpose. Consult [GitHub Help](https://help.github.com/articles/about-pull-requests/) for more information on using pull requests. ## API Changes If you are changing any public APIs, you need to run `./gradlew metalavaGenerateSignatureRelease` which will update the API signatures. ## Formatting To apply formatting, we use spotless. Run `./gradlew :pager:spotlessApply` to format the code according to the spec. ================================================ FILE: LICENSE ================================================ 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. ================================================ FILE: README.md ================================================ ![Accompanist logo](docs/header.png) Accompanist is a group of libraries that aim to supplement [Jetpack Compose][compose] with features that are commonly required by developers but not yet available. Accompanist is a labs like environment for new Compose APIs. We use it to help fill known gaps in the Compose toolkit, experiment with new APIs and to gather insight into the development experience of developing a Compose library. The goal of these libraries is to upstream them into the official toolkit, at which point they will be deprecated and removed from Accompanist. For more details like, why does this library exist? Why is it not part of AndroidX? Will you be releasing more libraries? Check out our [Accompanist FAQ](https://medium.com/p/b55117b02712). ## Compose versions Each [release](https://github.com/google/accompanist/releases) outlines what version of the Compose UI libraries it depends on. We are currently releasing multiple versions of Accompanist for the different versions of Compose:
Compose 1.0 (1.0.x)Maven Central
Compose 1.1 (1.1.x)Maven Central
Compose UI 1.2 (1.2.x)Maven Central
Compose UI 1.3 (1.3.x)Maven Central
Compose UI 1.4 (1.4.x)Maven Central
Compose UI 1.5 (1.5.x)Maven Central
Compose UI 1.6 (1.6.x)Maven Central
Compose UI 1.7+ (1.7.x)Maven Central
For stable versions of Compose, we use the latest *stable* version of the Compose compiler. For non-stable versions (alpha, beta, etc), we use the latest compiler at the time of release. > :warning: **Ensure you are using the Accompanist version that matches with your Compose UI version**: If you upgrade Accompanist, it will upgrade your Compose libraries version via transitive dependencies. ## Libraries ### 📫 [Permissions](./permissions/) A library that provides [Android runtime permissions][runtimepermissions] support for Jetpack Compose. ### 🖌️ [Drawable Painter](./drawablepainter/) A library which provides a way to use Android Drawables as Jetpack Compose Painters. ### 📜 [Adaptive](./adaptive/) A library providing a collection of utilities for adaptive layouts. ### 🧭✨[Navigation-Animation](./navigation-animation/) (Deprecated & Removed) See our [Migration Guide](https://google.github.io/accompanist/navigation-animation/) for migrating to using built in support for animations in Jetpack Navigation Compose. ### 🧭🎨️ [Navigation-Material](./navigation-material/) (Deprecated & Removed) See our [Migration Guide](https://google.github.io/accompanist/navigation-material/) for migrating to using built in material-navigation support. ### 🍫 [System UI Controller](./systemuicontroller/) (Deprecated & Removed) We recommend migrating to edge to edge. See our [Migration Guide](https://google.github.io/accompanist/systemuicontroller/) for more details. --- ## Future? Any of the features available in this group of libraries may become obsolete in the future, at which point they will (probably) become deprecated. We will aim to provide a migration path (where possible), to whatever supersedes the functionality. ## Snapshots Snapshots of the current development version of Accompanist are available, which track the latest commit. See [here](docs/using-snapshot-version.md) for more information. --- ### Why the name? The library is all about adding some utilities around Compose. Music composing is done by a composer, and since this library is about supporting composition, the supporting role of an [accompanist](https://en.wikipedia.org/wiki/Accompaniment) felt like a good name. ## Contributions Please contribute! We will gladly review any pull requests. Make sure to read the [Contributing](CONTRIBUTING.md) page first though. ## License ``` Copyright 2020 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 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. ``` [appcompat]: https://developer.android.com/jetpack/androidx/releases/appcompat [compose]: https://developer.android.com/jetpack/compose [snap]: https://oss.sonatype.org/content/repositories/snapshots/com/google/accompanist/ [mdc]: https://github.com/material-components/material-components-android [windowinsets]: https://developer.android.com/reference/kotlin/android/view/WindowInsets [viewpager]: https://developer.android.com/reference/kotlin/androidx/viewpager/widget/ViewPager [runtimepermissions]: https://developer.android.com/guide/topics/permissions/overview ================================================ FILE: adaptive/README.md ================================================ # Adaptive utilities for Jetpack Compose [![Maven Central](https://img.shields.io/maven-central/v/com.google.accompanist/accompanist-adaptive)](https://search.maven.org/search?q=g:com.google.accompanist) For more information, visit the documentation: https://google.github.io/accompanist/adaptive ## Download ```groovy repositories { mavenCentral() } dependencies { implementation "com.google.accompanist:accompanist-adaptive:" } ``` Snapshots of the development version are available in [Sonatype's `snapshots` repository][snap]. These are updated on every commit. [snap]: https://oss.sonatype.org/content/repositories/snapshots/com/google/accompanist/accompanist-adaptive/ ================================================ FILE: adaptive/api/current.api ================================================ // Signature format: 4.0 package com.google.accompanist.adaptive { public final class DisplayFeaturesKt { method @androidx.compose.runtime.Composable public static java.util.List calculateDisplayFeatures(android.app.Activity activity); } public final class FoldAwareColumnKt { method @androidx.compose.runtime.Composable public static void FoldAwareColumn(java.util.List displayFeatures, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.foundation.layout.PaddingValues foldPadding, optional androidx.compose.ui.Alignment.Horizontal horizontalAlignment, kotlin.jvm.functions.Function1 content); } @androidx.compose.foundation.layout.LayoutScopeMarker @androidx.compose.runtime.Immutable public interface FoldAwareColumnScope { method @androidx.compose.runtime.Stable public androidx.compose.ui.Modifier align(androidx.compose.ui.Modifier, androidx.compose.ui.Alignment.Horizontal alignment); method @androidx.compose.runtime.Stable public androidx.compose.ui.Modifier alignBy(androidx.compose.ui.Modifier, androidx.compose.ui.layout.VerticalAlignmentLine alignmentLine); method @androidx.compose.runtime.Stable public androidx.compose.ui.Modifier alignBy(androidx.compose.ui.Modifier, kotlin.jvm.functions.Function1 alignmentLineBlock); method @androidx.compose.runtime.Stable public androidx.compose.ui.Modifier ignoreFold(androidx.compose.ui.Modifier); } @kotlin.jvm.JvmInline public final value class FoldAwareConfiguration { field public static final com.google.accompanist.adaptive.FoldAwareConfiguration.Companion Companion; } public static final class FoldAwareConfiguration.Companion { method public int getAllFolds(); method public int getHorizontalFoldsOnly(); method public int getVerticalFoldsOnly(); property public final int AllFolds; property public final int HorizontalFoldsOnly; property public final int VerticalFoldsOnly; } public final class SplitResult { ctor public SplitResult(androidx.compose.foundation.gestures.Orientation gapOrientation, androidx.compose.ui.geometry.Rect gapBounds); method public androidx.compose.ui.geometry.Rect getGapBounds(); method public androidx.compose.foundation.gestures.Orientation getGapOrientation(); property public final androidx.compose.ui.geometry.Rect gapBounds; property public final androidx.compose.foundation.gestures.Orientation gapOrientation; } public final class TwoPaneKt { method public static com.google.accompanist.adaptive.TwoPaneStrategy HorizontalTwoPaneStrategy(float splitOffset, optional boolean offsetFromStart, optional float gapWidth); method public static com.google.accompanist.adaptive.TwoPaneStrategy HorizontalTwoPaneStrategy(float splitFraction, optional float gapWidth); method @androidx.compose.runtime.Composable public static void TwoPane(kotlin.jvm.functions.Function0 first, kotlin.jvm.functions.Function0 second, com.google.accompanist.adaptive.TwoPaneStrategy strategy, java.util.List displayFeatures, optional androidx.compose.ui.Modifier modifier, optional int foldAwareConfiguration); method public static com.google.accompanist.adaptive.TwoPaneStrategy VerticalTwoPaneStrategy(float splitOffset, optional boolean offsetFromTop, optional float gapHeight); method public static com.google.accompanist.adaptive.TwoPaneStrategy VerticalTwoPaneStrategy(float splitFraction, optional float gapHeight); } public fun interface TwoPaneStrategy { method public com.google.accompanist.adaptive.SplitResult calculateSplitResult(androidx.compose.ui.unit.Density density, androidx.compose.ui.unit.LayoutDirection layoutDirection, androidx.compose.ui.layout.LayoutCoordinates layoutCoordinates); } } ================================================ FILE: adaptive/build.gradle.kts ================================================ /* * Copyright 2023 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 * * 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. */ @file:Suppress("UnstableApiUsage") plugins { alias(libs.plugins.accompanist.android.library) alias(libs.plugins.accompanist.android.library.compose) alias(libs.plugins.accompanist.android.library.published) } android { namespace = "com.google.accompanist.adaptive" sourceSets { named("test") { java.srcDirs("src/sharedTest/kotlin") res.srcDirs("src/sharedTest/res") } named("androidTest") { java.srcDirs("src/sharedTest/kotlin") res.srcDirs("src/sharedTest/res") } } } dependencies { api(libs.compose.foundation.foundation) api(libs.compose.ui.ui) api(libs.androidx.window) implementation(libs.kotlin.coroutines.android) implementation(libs.compose.ui.util) // ====================== // Test dependencies // ====================== androidTestImplementation(project(":internal-testutils")) testImplementation(project(":internal-testutils")) androidTestImplementation(libs.junit) testImplementation(libs.junit) androidTestImplementation(libs.truth) testImplementation(libs.truth) androidTestImplementation(libs.compose.ui.test.junit4) testImplementation(libs.compose.ui.test.junit4) androidTestImplementation(libs.compose.ui.test.manifest) testImplementation(libs.compose.ui.test.manifest) androidTestImplementation(libs.androidx.test.runner) testImplementation(libs.androidx.test.runner) androidTestImplementation(libs.androidx.window.testing) testImplementation(libs.androidx.window.testing) testImplementation(libs.robolectric) } ================================================ FILE: adaptive/gradle.properties ================================================ POM_ARTIFACT_ID=accompanist-adaptive POM_NAME=Accompanist Adaptive library POM_PACKAGING=aar ================================================ FILE: adaptive/src/main/AndroidManifest.xml ================================================ ================================================ FILE: adaptive/src/main/java/com/google/accompanist/adaptive/DisplayFeatures.kt ================================================ /* * Copyright 2022 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 * * 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. */ package com.google.accompanist.adaptive import android.app.Activity import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.window.layout.DisplayFeature import androidx.window.layout.WindowInfoTracker /** * Calculates the list of [DisplayFeature]s from the given [activity]. */ @Composable public fun calculateDisplayFeatures(activity: Activity): List { val windowLayoutInfo = remember(activity) { WindowInfoTracker.getOrCreate(activity).windowLayoutInfo(activity) } val displayFeatures by produceState( initialValue = emptyList(), key1 = windowLayoutInfo ) { windowLayoutInfo.collect { info -> value = info.displayFeatures } } return displayFeatures } ================================================ FILE: adaptive/src/main/java/com/google/accompanist/adaptive/FoldAwareColumn.kt ================================================ /* * Copyright 2023 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 * * 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. */ package com.google.accompanist.adaptive import android.util.Range import androidx.annotation.VisibleForTesting import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.layout.IntrinsicMeasurable import androidx.compose.ui.layout.IntrinsicMeasureScope import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.Measurable import androidx.compose.ui.layout.MeasurePolicy import androidx.compose.ui.layout.MeasureResult import androidx.compose.ui.layout.MeasureScope import androidx.compose.ui.layout.ParentDataModifier import androidx.compose.ui.layout.Placeable import androidx.compose.ui.layout.boundsInRoot import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.findRootCoordinates import androidx.compose.ui.platform.InspectorInfo import androidx.compose.ui.platform.InspectorValueInfo import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.LayoutDirection import androidx.window.layout.DisplayFeature import androidx.window.layout.FoldingFeature import kotlin.math.roundToInt /** * A simplified version of [Column] that places children in a fold-aware manner. * * The layout starts placing children from the top of the available space. If there is a horizontal * [separating](https://developer.android.com/reference/kotlin/androidx/window/layout/FoldingFeature#isSeparating()) * fold present in the window, then the layout will check to see if any children overlap the fold. * If a child would overlap the fold in its current position, then the layout will increase its * y coordinate so that the child is now placed below the fold, and any subsequent children will * also be placed below the fold. * * * @param displayFeatures a list of display features the device currently has * @param modifier an optional modifier for the layout * @param foldPadding the optional padding to add around a fold * @param horizontalAlignment the horizontal alignment of the layout's children. */ @Composable public fun FoldAwareColumn( displayFeatures: List, modifier: Modifier = Modifier, foldPadding: PaddingValues = PaddingValues(), horizontalAlignment: Alignment.Horizontal = Alignment.Start, content: @Composable FoldAwareColumnScope.() -> Unit, ) { Layout( modifier = modifier, measurePolicy = foldAwareColumnMeasurePolicy( verticalArrangement = Arrangement.Top, horizontalAlignment = horizontalAlignment, fold = { // Extract folding feature if horizontal and separating displayFeatures.find { it is FoldingFeature && it.orientation == FoldingFeature.Orientation.HORIZONTAL && it.isSeparating } as FoldingFeature? }, foldPadding = foldPadding, ), content = { FoldAwareColumnScopeInstance.content() } ) } /** * FoldAwareColumn version of [rowColumnMeasurePolicy] that uses [FoldAwareColumnMeasurementHelper.foldAwarePlaceHelper] * method instead of [RowColumnMeasurementHelper.placeHelper] */ // TODO: change from internal to private once metalava issue is solved https://issuetracker.google.com/issues/271539608 @Composable internal fun foldAwareColumnMeasurePolicy( verticalArrangement: Arrangement.Vertical, horizontalAlignment: Alignment.Horizontal, fold: () -> FoldingFeature?, foldPadding: PaddingValues ) = remember(verticalArrangement, horizontalAlignment, fold, foldPadding) { val orientation = LayoutOrientation.Vertical val arrangement: (Int, IntArray, LayoutDirection, Density, IntArray) -> Unit = { totalSize, size, _, density, outPosition -> with(verticalArrangement) { density.arrange(totalSize, size, outPosition) } } val arrangementSpacing = verticalArrangement.spacing val crossAxisAlignment = CrossAxisAlignment.horizontal(horizontalAlignment) val crossAxisSize = SizeMode.Wrap object : MeasurePolicy { override fun MeasureScope.measure( measurables: List, constraints: Constraints ): MeasureResult { val placeables = arrayOfNulls(measurables.size) val rowColumnMeasureHelper = FoldAwareColumnMeasurementHelper( orientation, arrangement, arrangementSpacing, crossAxisSize, crossAxisAlignment, measurables, placeables ) val measureResult = rowColumnMeasureHelper .measureWithoutPlacing( this, constraints, 0, measurables.size ) val layoutWidth: Int val layoutHeight: Int if (orientation == LayoutOrientation.Horizontal) { layoutWidth = measureResult.mainAxisSize layoutHeight = measureResult.crossAxisSize } else { layoutWidth = measureResult.crossAxisSize layoutHeight = measureResult.mainAxisSize } // Calculate fold bounds in pixels (including any added fold padding) val foldBoundsPx = with(density) { val topPaddingPx = foldPadding.calculateTopPadding().roundToPx() val bottomPaddingPx = foldPadding.calculateBottomPadding().roundToPx() fold()?.bounds?.let { Rect( left = it.left.toFloat(), top = it.top.toFloat() - topPaddingPx, right = it.right.toFloat(), bottom = it.bottom.toFloat() + bottomPaddingPx ) } } // We only know how much padding is added inside the placement scope, so just add fold height // and height of the largest child when laying out to cover the maximum possible height val heightPadding = foldBoundsPx?.let { bounds -> val largestChildHeight = rowColumnMeasureHelper.placeables.maxOfOrNull { if ((it?.parentData as? RowColumnParentData)?.ignoreFold == true) { 0 } else { it?.height ?: 0 } } ?: 0 bounds.height.roundToInt() + largestChildHeight } ?: 0 val paddedLayoutHeight = layoutHeight + heightPadding return layout(layoutWidth, paddedLayoutHeight) { rowColumnMeasureHelper.foldAwarePlaceHelper( this, measureResult, 0, layoutDirection, foldBoundsPx ) } } override fun IntrinsicMeasureScope.minIntrinsicWidth( measurables: List, height: Int ) = MinIntrinsicWidthMeasureBlock(orientation)( measurables, height, arrangementSpacing.roundToPx() ) override fun IntrinsicMeasureScope.minIntrinsicHeight( measurables: List, width: Int ) = MinIntrinsicHeightMeasureBlock(orientation)( measurables, width, arrangementSpacing.roundToPx() ) override fun IntrinsicMeasureScope.maxIntrinsicWidth( measurables: List, height: Int ) = MaxIntrinsicWidthMeasureBlock(orientation)( measurables, height, arrangementSpacing.roundToPx() ) override fun IntrinsicMeasureScope.maxIntrinsicHeight( measurables: List, width: Int ) = MaxIntrinsicHeightMeasureBlock(orientation)( measurables, width, arrangementSpacing.roundToPx() ) } } /** * Inherits from [RowColumnMeasurementHelper] to place children in a fold-aware manner */ private class FoldAwareColumnMeasurementHelper( orientation: LayoutOrientation, arrangement: (Int, IntArray, LayoutDirection, Density, IntArray) -> Unit, arrangementSpacing: Dp, crossAxisSize: SizeMode, crossAxisAlignment: CrossAxisAlignment, measurables: List, placeables: Array ) : RowColumnMeasurementHelper( orientation, arrangement, arrangementSpacing, crossAxisSize, crossAxisAlignment, measurables, placeables ) { /** * Copy of [placeHelper] that has been modified for FoldAwareColumn implementation */ @OptIn(ExperimentalComposeUiApi::class) fun foldAwarePlaceHelper( placeableScope: Placeable.PlacementScope, measureResult: RowColumnMeasureHelperResult, crossAxisOffset: Int, layoutDirection: LayoutDirection, foldBoundsPx: Rect? ) { with(placeableScope) { val layoutBounds = coordinates!!.trueBoundsInWindow() var placeableY = 0 for (i in measureResult.startIndex until measureResult.endIndex) { val placeable = placeables[i]!! val mainAxisPositions = measureResult.mainAxisPositions val crossAxisPosition = getCrossAxisPosition( placeable, (measurables[i].parentData as? RowColumnParentData), measureResult.crossAxisSize, layoutDirection, measureResult.beforeCrossAxisAlignmentLine ) + crossAxisOffset if (orientation == LayoutOrientation.Horizontal) { placeable.place( mainAxisPositions[i - measureResult.startIndex], crossAxisPosition ) } else { val relativeBounds = Rect( left = 0f, top = placeableY.toFloat(), right = placeable.width.toFloat(), bottom = (placeableY + placeable.height).toFloat() ) val absoluteBounds = relativeBounds.translate(layoutBounds.left, layoutBounds.top) // If placeable overlaps fold, push placeable below if (foldBoundsPx?.overlapsVertically(absoluteBounds) == true && (placeable.parentData as? RowColumnParentData)?.ignoreFold != true ) { placeableY = (foldBoundsPx.bottom - layoutBounds.top).toInt() } placeable.place(crossAxisPosition, placeableY) placeableY += placeable.height } } } } } /** * Copy of original [LayoutCoordinates.boundsInWindow], but without the nonzero dimension check. * * Instead of returning [Rect.Zero] for a layout with zero width/height, this method will still * return a Rect with the layout's bounds. */ @VisibleForTesting internal fun LayoutCoordinates.trueBoundsInWindow(): Rect { val root = findRootCoordinates() val bounds = boundsInRoot() val rootWidth = root.size.width.toFloat() val rootHeight = root.size.height.toFloat() val boundsLeft = bounds.left.coerceIn(0f, rootWidth) val boundsTop = bounds.top.coerceIn(0f, rootHeight) val boundsRight = bounds.right.coerceIn(0f, rootWidth) val boundsBottom = bounds.bottom.coerceIn(0f, rootHeight) val topLeft = root.localToWindow(Offset(boundsLeft, boundsTop)) val topRight = root.localToWindow(Offset(boundsRight, boundsTop)) val bottomRight = root.localToWindow(Offset(boundsRight, boundsBottom)) val bottomLeft = root.localToWindow(Offset(boundsLeft, boundsBottom)) val left = minOf(topLeft.x, topRight.x, bottomLeft.x, bottomRight.x) val top = minOf(topLeft.y, topRight.y, bottomLeft.y, bottomRight.y) val right = maxOf(topLeft.x, topRight.x, bottomLeft.x, bottomRight.x) val bottom = maxOf(topLeft.y, topRight.y, bottomLeft.y, bottomRight.y) return Rect(left, top, right, bottom) } /** * Checks if the vertical ranges of the two Rects overlap (inclusive) */ private fun Rect.overlapsVertically(other: Rect): Boolean { val verticalRange = Range(top, bottom) val otherVerticalRange = Range(other.top, other.bottom) return verticalRange.overlaps(otherVerticalRange) } /** * Inclusive check to see if the given float ranges overlap */ private fun Range.overlaps(other: Range): Boolean { return (lower >= other.lower && lower <= other.upper) || (upper >= other.lower && upper <= other.upper) } /** * Copy of [RowColumnParentData] that has been modified to include the new ignoreFold field. */ internal data class RowColumnParentData( var weight: Float = 0f, var fill: Boolean = true, var crossAxisAlignment: CrossAxisAlignment? = null, var ignoreFold: Boolean = false ) internal class IgnoreFoldModifier( inspectorInfo: InspectorInfo.() -> Unit ) : ParentDataModifier, InspectorValueInfo(inspectorInfo) { override fun Density.modifyParentData(parentData: Any?) = ((parentData as? RowColumnParentData) ?: RowColumnParentData()).also { it.ignoreFold = true } override fun equals(other: Any?): Boolean { if (this === other) return true return other is IgnoreFoldModifier } override fun hashCode(): Int { return 0 } override fun toString(): String = "IgnoreFoldModifier(ignoreFold=true)" } ================================================ FILE: adaptive/src/main/java/com/google/accompanist/adaptive/FoldAwareColumnScope.kt ================================================ /* * Copyright 2023 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 * * 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. */ package com.google.accompanist.adaptive import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.LayoutScopeMarker import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.Measured import androidx.compose.ui.layout.VerticalAlignmentLine import androidx.compose.ui.platform.debugInspectorInfo /** * Copy of [ColumnScope] that excludes the weight Modifier attribute. * * Also adds a new [ignoreFold] Modifier attribute. */ @LayoutScopeMarker @Immutable public interface FoldAwareColumnScope { /** * Ignore the fold when placing this child within the [FoldAwareColumn]. */ @Stable public fun Modifier.ignoreFold(): Modifier /** * Align the element horizontally within the [Column]. This alignment will have priority over * the [Column]'s `horizontalAlignment` parameter. * * Example usage: * @sample androidx.compose.foundation.layout.samples.SimpleAlignInColumn */ @Stable public fun Modifier.align(alignment: Alignment.Horizontal): Modifier /** * Position the element horizontally such that its [alignmentLine] aligns with sibling elements * also configured to [alignBy]. [alignBy] is a form of [align], * so both modifiers will not work together if specified for the same layout. * Within a [Column], all components with [alignBy] will align horizontally using * the specified [VerticalAlignmentLine]s or values provided using the other * [alignBy] overload, forming a sibling group. * At least one element of the sibling group will be placed as it had [Alignment.Start] align * in [Column], and the alignment of the other siblings will be then determined such that * the alignment lines coincide. Note that if only one element in a [Column] has the * [alignBy] modifier specified the element will be positioned * as if it had [Alignment.Start] align. * * Example usage: * @sample androidx.compose.foundation.layout.samples.SimpleRelativeToSiblingsInColumn */ @Stable public fun Modifier.alignBy(alignmentLine: VerticalAlignmentLine): Modifier /** * Position the element horizontally such that the alignment line for the content as * determined by [alignmentLineBlock] aligns with sibling elements also configured to * [alignBy]. [alignBy] is a form of [align], so both modifiers * will not work together if specified for the same layout. * Within a [Column], all components with [alignBy] will align horizontally using * the specified [VerticalAlignmentLine]s or values obtained from [alignmentLineBlock], * forming a sibling group. * At least one element of the sibling group will be placed as it had [Alignment.Start] align * in [Column], and the alignment of the other siblings will be then determined such that * the alignment lines coincide. Note that if only one element in a [Column] has the * [alignBy] modifier specified the element will be positioned * as if it had [Alignment.Start] align. * * Example usage: * @sample androidx.compose.foundation.layout.samples.SimpleRelativeToSiblings */ @Stable public fun Modifier.alignBy(alignmentLineBlock: (Measured) -> Int): Modifier } internal object FoldAwareColumnScopeInstance : FoldAwareColumnScope { @Stable override fun Modifier.ignoreFold() = this.then( IgnoreFoldModifier( inspectorInfo = debugInspectorInfo { name = "ignoreFold" value = true } ) ) @Stable override fun Modifier.align(alignment: Alignment.Horizontal) = this.then( HorizontalAlignModifier( horizontal = alignment, inspectorInfo = debugInspectorInfo { name = "align" value = alignment } ) ) @Stable override fun Modifier.alignBy(alignmentLine: VerticalAlignmentLine) = this.then( SiblingsAlignedModifier.WithAlignmentLine( alignmentLine = alignmentLine, inspectorInfo = debugInspectorInfo { name = "alignBy" value = alignmentLine } ) ) @Stable override fun Modifier.alignBy(alignmentLineBlock: (Measured) -> Int) = this.then( SiblingsAlignedModifier.WithAlignmentLineBlock( block = alignmentLineBlock, inspectorInfo = debugInspectorInfo { name = "alignBy" value = alignmentLineBlock } ) ) } ================================================ FILE: adaptive/src/main/java/com/google/accompanist/adaptive/RowColumnImpl.kt ================================================ /* * Copyright 2023 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 * * 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. */ package com.google.accompanist.adaptive import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import androidx.compose.ui.Alignment import androidx.compose.ui.layout.AlignmentLine import androidx.compose.ui.layout.IntrinsicMeasurable import androidx.compose.ui.layout.Measured import androidx.compose.ui.layout.ParentDataModifier import androidx.compose.ui.layout.Placeable import androidx.compose.ui.platform.InspectorInfo import androidx.compose.ui.platform.InspectorValueInfo import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.util.fastForEach import com.google.accompanist.adaptive.LayoutOrientation.Horizontal import com.google.accompanist.adaptive.LayoutOrientation.Vertical import kotlin.math.max import kotlin.math.min import kotlin.math.roundToInt /** * Copied from: * RowColumnImpl.kt * https://android-review.googlesource.com/c/platform/frameworks/support/+/2260390/27/compose/foundation/foundation-layout/src/commonMain/kotlin/androidx/compose/foundation/layout/RowColumnImpl.kt * * The only changes were updating access modifiers and removing unused code */ /** * [Row] will be [Horizontal], [Column] is [Vertical]. */ internal enum class LayoutOrientation { Horizontal, Vertical } /** * Used to specify the alignment of a layout's children, in cross axis direction. */ @Immutable internal sealed class CrossAxisAlignment { /** * Aligns to [size]. If this is a vertical alignment, [layoutDirection] should be * [LayoutDirection.Ltr]. * * @param size The remaining space (total size - content size) in the container. * @param layoutDirection The layout direction of the content if horizontal or * [LayoutDirection.Ltr] if vertical. * @param placeable The item being aligned. * @param beforeCrossAxisAlignmentLine The space before the cross-axis alignment line if * an alignment line is being used or 0 if no alignment line is being used. */ internal abstract fun align( size: Int, layoutDirection: LayoutDirection, placeable: Placeable, beforeCrossAxisAlignmentLine: Int ): Int /** * Returns `true` if this is [Relative]. */ internal open val isRelative: Boolean get() = false /** * Returns the alignment line position relative to the left/top of the space or `null` if * this alignment doesn't rely on alignment lines. */ internal open fun calculateAlignmentLinePosition(placeable: Placeable): Int? = null companion object { /** * Place children such that their center is in the middle of the cross axis. */ @Stable val Center: CrossAxisAlignment = CenterCrossAxisAlignment /** * Place children such that their start edge is aligned to the start edge of the cross * axis. TODO(popam): Consider rtl directionality. */ @Stable val Start: CrossAxisAlignment = StartCrossAxisAlignment /** * Place children such that their end edge is aligned to the end edge of the cross * axis. TODO(popam): Consider rtl directionality. */ @Stable val End: CrossAxisAlignment = EndCrossAxisAlignment /** * Align children by their baseline. */ fun AlignmentLine(alignmentLine: AlignmentLine): CrossAxisAlignment = AlignmentLineCrossAxisAlignment(AlignmentLineProvider.Value(alignmentLine)) /** * Align children relative to their siblings using the alignment line provided as a * parameter using [AlignmentLineProvider]. */ internal fun Relative(alignmentLineProvider: AlignmentLineProvider): CrossAxisAlignment = AlignmentLineCrossAxisAlignment(alignmentLineProvider) /** * Align children with vertical alignment. */ internal fun vertical(vertical: Alignment.Vertical): CrossAxisAlignment = VerticalCrossAxisAlignment(vertical) /** * Align children with horizontal alignment. */ internal fun horizontal(horizontal: Alignment.Horizontal): CrossAxisAlignment = HorizontalCrossAxisAlignment(horizontal) } private object CenterCrossAxisAlignment : CrossAxisAlignment() { override fun align( size: Int, layoutDirection: LayoutDirection, placeable: Placeable, beforeCrossAxisAlignmentLine: Int ): Int { return size / 2 } } private object StartCrossAxisAlignment : CrossAxisAlignment() { override fun align( size: Int, layoutDirection: LayoutDirection, placeable: Placeable, beforeCrossAxisAlignmentLine: Int ): Int { return if (layoutDirection == LayoutDirection.Ltr) 0 else size } } private object EndCrossAxisAlignment : CrossAxisAlignment() { override fun align( size: Int, layoutDirection: LayoutDirection, placeable: Placeable, beforeCrossAxisAlignmentLine: Int ): Int { return if (layoutDirection == LayoutDirection.Ltr) size else 0 } } private class AlignmentLineCrossAxisAlignment( val alignmentLineProvider: AlignmentLineProvider ) : CrossAxisAlignment() { override val isRelative: Boolean get() = true override fun calculateAlignmentLinePosition(placeable: Placeable): Int { return alignmentLineProvider.calculateAlignmentLinePosition(placeable) } override fun align( size: Int, layoutDirection: LayoutDirection, placeable: Placeable, beforeCrossAxisAlignmentLine: Int ): Int { val alignmentLinePosition = alignmentLineProvider.calculateAlignmentLinePosition(placeable) return if (alignmentLinePosition != AlignmentLine.Unspecified) { val line = beforeCrossAxisAlignmentLine - alignmentLinePosition if (layoutDirection == LayoutDirection.Rtl) { size - line } else { line } } else { 0 } } } private class VerticalCrossAxisAlignment( val vertical: Alignment.Vertical ) : CrossAxisAlignment() { override fun align( size: Int, layoutDirection: LayoutDirection, placeable: Placeable, beforeCrossAxisAlignmentLine: Int ): Int { return vertical.align(0, size) } } private class HorizontalCrossAxisAlignment( val horizontal: Alignment.Horizontal ) : CrossAxisAlignment() { override fun align( size: Int, layoutDirection: LayoutDirection, placeable: Placeable, beforeCrossAxisAlignmentLine: Int ): Int { return horizontal.align(0, size, layoutDirection) } } } /** * Box [Constraints], but which abstract away width and height in favor of main axis and cross axis. */ internal data class OrientationIndependentConstraints( val mainAxisMin: Int, val mainAxisMax: Int, val crossAxisMin: Int, val crossAxisMax: Int ) { constructor(c: Constraints, orientation: LayoutOrientation) : this( if (orientation === Horizontal) c.minWidth else c.minHeight, if (orientation === Horizontal) c.maxWidth else c.maxHeight, if (orientation === Horizontal) c.minHeight else c.minWidth, if (orientation === Horizontal) c.maxHeight else c.maxWidth ) // Creates a new instance with the same main axis constraints and maximum tight cross axis. fun stretchCrossAxis() = OrientationIndependentConstraints( mainAxisMin, mainAxisMax, if (crossAxisMax != Constraints.Infinity) crossAxisMax else crossAxisMin, crossAxisMax ) // Given an orientation, resolves the current instance to traditional constraints. fun toBoxConstraints(orientation: LayoutOrientation) = if (orientation === Horizontal) { Constraints(mainAxisMin, mainAxisMax, crossAxisMin, crossAxisMax) } else { Constraints(crossAxisMin, crossAxisMax, mainAxisMin, mainAxisMax) } // Given an orientation, resolves the max width constraint this instance represents. fun maxWidth(orientation: LayoutOrientation) = if (orientation === Horizontal) { mainAxisMax } else { crossAxisMax } // Given an orientation, resolves the max height constraint this instance represents. fun maxHeight(orientation: LayoutOrientation) = if (orientation === Horizontal) { crossAxisMax } else { mainAxisMax } } internal val IntrinsicMeasurable.rowColumnParentData: RowColumnParentData? get() = parentData as? RowColumnParentData internal val RowColumnParentData?.weight: Float get() = this?.weight ?: 0f internal val RowColumnParentData?.fill: Boolean get() = this?.fill ?: true internal val RowColumnParentData?.crossAxisAlignment: CrossAxisAlignment? get() = this?.crossAxisAlignment internal val RowColumnParentData?.isRelative: Boolean get() = this.crossAxisAlignment?.isRelative ?: false internal fun MinIntrinsicWidthMeasureBlock(orientation: LayoutOrientation) = if (orientation == Horizontal) { IntrinsicMeasureBlocks.HorizontalMinWidth } else { IntrinsicMeasureBlocks.VerticalMinWidth } internal fun MinIntrinsicHeightMeasureBlock(orientation: LayoutOrientation) = if (orientation == Horizontal) { IntrinsicMeasureBlocks.HorizontalMinHeight } else { IntrinsicMeasureBlocks.VerticalMinHeight } internal fun MaxIntrinsicWidthMeasureBlock(orientation: LayoutOrientation) = if (orientation == Horizontal) { IntrinsicMeasureBlocks.HorizontalMaxWidth } else { IntrinsicMeasureBlocks.VerticalMaxWidth } internal fun MaxIntrinsicHeightMeasureBlock(orientation: LayoutOrientation) = if (orientation == Horizontal) { IntrinsicMeasureBlocks.HorizontalMaxHeight } else { IntrinsicMeasureBlocks.VerticalMaxHeight } internal object IntrinsicMeasureBlocks { val HorizontalMinWidth: (List, Int, Int) -> Int = { measurables, availableHeight, mainAxisSpacing -> intrinsicSize( measurables, { h -> minIntrinsicWidth(h) }, { w -> maxIntrinsicHeight(w) }, availableHeight, mainAxisSpacing, Horizontal, Horizontal ) } val VerticalMinWidth: (List, Int, Int) -> Int = { measurables, availableHeight, mainAxisSpacing -> intrinsicSize( measurables, { h -> minIntrinsicWidth(h) }, { w -> maxIntrinsicHeight(w) }, availableHeight, mainAxisSpacing, Vertical, Horizontal ) } val HorizontalMinHeight: (List, Int, Int) -> Int = { measurables, availableWidth, mainAxisSpacing -> intrinsicSize( measurables, { w -> minIntrinsicHeight(w) }, { h -> maxIntrinsicWidth(h) }, availableWidth, mainAxisSpacing, Horizontal, Vertical ) } val VerticalMinHeight: (List, Int, Int) -> Int = { measurables, availableWidth, mainAxisSpacing -> intrinsicSize( measurables, { w -> minIntrinsicHeight(w) }, { h -> maxIntrinsicWidth(h) }, availableWidth, mainAxisSpacing, Vertical, Vertical ) } val HorizontalMaxWidth: (List, Int, Int) -> Int = { measurables, availableHeight, mainAxisSpacing -> intrinsicSize( measurables, { h -> maxIntrinsicWidth(h) }, { w -> maxIntrinsicHeight(w) }, availableHeight, mainAxisSpacing, Horizontal, Horizontal ) } val VerticalMaxWidth: (List, Int, Int) -> Int = { measurables, availableHeight, mainAxisSpacing -> intrinsicSize( measurables, { h -> maxIntrinsicWidth(h) }, { w -> maxIntrinsicHeight(w) }, availableHeight, mainAxisSpacing, Vertical, Horizontal ) } val HorizontalMaxHeight: (List, Int, Int) -> Int = { measurables, availableWidth, mainAxisSpacing -> intrinsicSize( measurables, { w -> maxIntrinsicHeight(w) }, { h -> maxIntrinsicWidth(h) }, availableWidth, mainAxisSpacing, Horizontal, Vertical ) } val VerticalMaxHeight: (List, Int, Int) -> Int = { measurables, availableWidth, mainAxisSpacing -> intrinsicSize( measurables, { w -> maxIntrinsicHeight(w) }, { h -> maxIntrinsicWidth(h) }, availableWidth, mainAxisSpacing, Vertical, Vertical ) } } private fun intrinsicSize( children: List, intrinsicMainSize: IntrinsicMeasurable.(Int) -> Int, intrinsicCrossSize: IntrinsicMeasurable.(Int) -> Int, crossAxisAvailable: Int, mainAxisSpacing: Int, layoutOrientation: LayoutOrientation, intrinsicOrientation: LayoutOrientation ) = if (layoutOrientation == intrinsicOrientation) { intrinsicMainAxisSize(children, intrinsicMainSize, crossAxisAvailable, mainAxisSpacing) } else { intrinsicCrossAxisSize( children, intrinsicCrossSize, intrinsicMainSize, crossAxisAvailable, mainAxisSpacing ) } private fun intrinsicMainAxisSize( children: List, mainAxisSize: IntrinsicMeasurable.(Int) -> Int, crossAxisAvailable: Int, mainAxisSpacing: Int ): Int { var weightUnitSpace = 0 var fixedSpace = 0 var totalWeight = 0f children.fastForEach { child -> val weight = child.rowColumnParentData.weight val size = child.mainAxisSize(crossAxisAvailable) if (weight == 0f) { fixedSpace += size } else if (weight > 0f) { totalWeight += weight weightUnitSpace = max(weightUnitSpace, (size / weight).roundToInt()) } } return (weightUnitSpace * totalWeight).roundToInt() + fixedSpace + (children.size - 1) * mainAxisSpacing } private fun intrinsicCrossAxisSize( children: List, mainAxisSize: IntrinsicMeasurable.(Int) -> Int, crossAxisSize: IntrinsicMeasurable.(Int) -> Int, mainAxisAvailable: Int, mainAxisSpacing: Int ): Int { var fixedSpace = min((children.size - 1) * mainAxisSpacing, mainAxisAvailable) var crossAxisMax = 0 var totalWeight = 0f children.fastForEach { child -> val weight = child.rowColumnParentData.weight if (weight == 0f) { // Ask the child how much main axis space it wants to occupy. This cannot be more // than the remaining available space. val mainAxisSpace = min( child.mainAxisSize(Constraints.Infinity), mainAxisAvailable - fixedSpace ) fixedSpace += mainAxisSpace // Now that the assigned main axis space is known, ask about the cross axis space. crossAxisMax = max(crossAxisMax, child.crossAxisSize(mainAxisSpace)) } else if (weight > 0f) { totalWeight += weight } } // For weighted children, calculate how much main axis space weight=1 would represent. val weightUnitSpace = if (totalWeight == 0f) { 0 } else if (mainAxisAvailable == Constraints.Infinity) { Constraints.Infinity } else { (max(mainAxisAvailable - fixedSpace, 0) / totalWeight).roundToInt() } children.fastForEach { child -> val weight = child.rowColumnParentData.weight // Now the main axis for weighted children is known, so ask about the cross axis space. if (weight > 0f) { crossAxisMax = max( crossAxisMax, child.crossAxisSize( if (weightUnitSpace != Constraints.Infinity) { (weightUnitSpace * weight).roundToInt() } else { Constraints.Infinity } ) ) } } return crossAxisMax } internal class LayoutWeightImpl( val weight: Float, val fill: Boolean, inspectorInfo: InspectorInfo.() -> Unit ) : ParentDataModifier, InspectorValueInfo(inspectorInfo) { override fun Density.modifyParentData(parentData: Any?) = ((parentData as? RowColumnParentData) ?: RowColumnParentData()).also { it.weight = weight it.fill = fill } override fun equals(other: Any?): Boolean { if (this === other) return true val otherModifier = other as? LayoutWeightImpl ?: return false return weight == otherModifier.weight && fill == otherModifier.fill } override fun hashCode(): Int { var result = weight.hashCode() result = 31 * result + fill.hashCode() return result } override fun toString(): String = "LayoutWeightImpl(weight=$weight, fill=$fill)" } internal sealed class SiblingsAlignedModifier( inspectorInfo: InspectorInfo.() -> Unit ) : ParentDataModifier, InspectorValueInfo(inspectorInfo) { abstract override fun Density.modifyParentData(parentData: Any?): Any? internal class WithAlignmentLineBlock( val block: (Measured) -> Int, inspectorInfo: InspectorInfo.() -> Unit ) : SiblingsAlignedModifier(inspectorInfo) { override fun Density.modifyParentData(parentData: Any?): Any { return ((parentData as? RowColumnParentData) ?: RowColumnParentData()).also { it.crossAxisAlignment = CrossAxisAlignment.Relative(AlignmentLineProvider.Block(block)) } } override fun equals(other: Any?): Boolean { if (this === other) return true val otherModifier = other as? WithAlignmentLineBlock ?: return false return block == otherModifier.block } override fun hashCode(): Int = block.hashCode() override fun toString(): String = "WithAlignmentLineBlock(block=$block)" } internal class WithAlignmentLine( val alignmentLine: AlignmentLine, inspectorInfo: InspectorInfo.() -> Unit ) : SiblingsAlignedModifier(inspectorInfo) { override fun Density.modifyParentData(parentData: Any?): Any { return ((parentData as? RowColumnParentData) ?: RowColumnParentData()).also { it.crossAxisAlignment = CrossAxisAlignment.Relative(AlignmentLineProvider.Value(alignmentLine)) } } override fun equals(other: Any?): Boolean { if (this === other) return true val otherModifier = other as? WithAlignmentLine ?: return false return alignmentLine == otherModifier.alignmentLine } override fun hashCode(): Int = alignmentLine.hashCode() override fun toString(): String = "WithAlignmentLine(line=$alignmentLine)" } } internal class HorizontalAlignModifier( val horizontal: Alignment.Horizontal, inspectorInfo: InspectorInfo.() -> Unit ) : ParentDataModifier, InspectorValueInfo(inspectorInfo) { override fun Density.modifyParentData(parentData: Any?): RowColumnParentData { return ((parentData as? RowColumnParentData) ?: RowColumnParentData()).also { it.crossAxisAlignment = CrossAxisAlignment.horizontal(horizontal) } } override fun equals(other: Any?): Boolean { if (this === other) return true val otherModifier = other as? HorizontalAlignModifier ?: return false return horizontal == otherModifier.horizontal } override fun hashCode(): Int = horizontal.hashCode() override fun toString(): String = "HorizontalAlignModifier(horizontal=$horizontal)" } internal class VerticalAlignModifier( val vertical: Alignment.Vertical, inspectorInfo: InspectorInfo.() -> Unit ) : ParentDataModifier, InspectorValueInfo(inspectorInfo) { override fun Density.modifyParentData(parentData: Any?): RowColumnParentData { return ((parentData as? RowColumnParentData) ?: RowColumnParentData()).also { it.crossAxisAlignment = CrossAxisAlignment.vertical(vertical) } } override fun equals(other: Any?): Boolean { if (this === other) return true val otherModifier = other as? VerticalAlignModifier ?: return false return vertical == otherModifier.vertical } override fun hashCode(): Int = vertical.hashCode() override fun toString(): String = "VerticalAlignModifier(vertical=$vertical)" } /** * Provides the alignment line. */ internal sealed class AlignmentLineProvider { abstract fun calculateAlignmentLinePosition(placeable: Placeable): Int data class Block(val lineProviderBlock: (Measured) -> Int) : AlignmentLineProvider() { override fun calculateAlignmentLinePosition( placeable: Placeable ): Int { return lineProviderBlock(placeable) } } data class Value(val alignmentLine: AlignmentLine) : AlignmentLineProvider() { override fun calculateAlignmentLinePosition(placeable: Placeable): Int { return placeable[alignmentLine] } } } /** * Used to specify how a layout chooses its own size when multiple behaviors are possible. */ // TODO(popam): remove this when Flow is reworked internal enum class SizeMode { /** * Minimize the amount of free space by wrapping the children, * subject to the incoming layout constraints. */ Wrap, /** * Maximize the amount of free space by expanding to fill the available space, * subject to the incoming layout constraints. */ Expand } ================================================ FILE: adaptive/src/main/java/com/google/accompanist/adaptive/RowColumnMeasurementHelper.kt ================================================ /* * Copyright 2023 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 * * 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. */ package com.google.accompanist.adaptive import androidx.compose.ui.layout.AlignmentLine import androidx.compose.ui.layout.Measurable import androidx.compose.ui.layout.MeasureScope import androidx.compose.ui.layout.Placeable import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.LayoutDirection import kotlin.math.max import kotlin.math.min import kotlin.math.roundToInt import kotlin.math.sign /** * Copied from: * RowColumnMeasurementHelper.kt * https://android-review.googlesource.com/c/platform/frameworks/support/+/2260390/27/compose/foundation/foundation-layout/src/commonMain/kotlin/androidx/compose/foundation/layout/RowColumnMeasurementHelper.kt * * The only changes were updating access modifiers and making RowColumnMeasurementHelper an open class */ /** * This is a data class that holds the determined width, height of a row, * and information on how to retrieve main axis and cross axis positions. */ internal class RowColumnMeasureHelperResult( val crossAxisSize: Int, val mainAxisSize: Int, val startIndex: Int, val endIndex: Int, val beforeCrossAxisAlignmentLine: Int, val mainAxisPositions: IntArray, ) /** * RowColumnMeasurementHelper * Measures the row and column without placing, useful for reusing row/column logic */ internal open class RowColumnMeasurementHelper( val orientation: LayoutOrientation, val arrangement: (Int, IntArray, LayoutDirection, Density, IntArray) -> Unit, val arrangementSpacing: Dp, val crossAxisSize: SizeMode, val crossAxisAlignment: CrossAxisAlignment, val measurables: List, val placeables: Array ) { private val rowColumnParentData = Array(measurables.size) { measurables[it].rowColumnParentData } fun Placeable.mainAxisSize() = if (orientation == LayoutOrientation.Horizontal) width else height fun Placeable.crossAxisSize() = if (orientation == LayoutOrientation.Horizontal) height else width /** * Measures the row and column without placing, useful for reusing row/column logic * * @param measureScope The measure scope to retrieve density * @param constraints The desired constraints for the startIndex and endIndex * can hold null items if not measured. * @param startIndex The startIndex (inclusive) when examining measurables, placeable * and parentData * @param endIndex The ending index (exclusive) when examinning measurable, placeable * and parentData */ fun measureWithoutPlacing( measureScope: MeasureScope, constraints: Constraints, startIndex: Int, endIndex: Int ): RowColumnMeasureHelperResult { @Suppress("NAME_SHADOWING") val constraints = OrientationIndependentConstraints(constraints, orientation) val arrangementSpacingPx = with(measureScope) { arrangementSpacing.roundToPx() } var totalWeight = 0f var fixedSpace = 0 var crossAxisSpace = 0 var weightChildrenCount = 0 var anyAlignBy = false val subSize = endIndex - startIndex // First measure children with zero weight. var spaceAfterLastNoWeight = 0 for (i in startIndex until endIndex) { val child = measurables[i] val parentData = rowColumnParentData[i] val weight = parentData.weight if (weight > 0f) { totalWeight += weight ++weightChildrenCount } else { val mainAxisMax = constraints.mainAxisMax val placeable = placeables[i] ?: child.measure( // Ask for preferred main axis size. constraints.copy( mainAxisMin = 0, mainAxisMax = if (mainAxisMax == Constraints.Infinity) { Constraints.Infinity } else { mainAxisMax - fixedSpace }, crossAxisMin = 0 ).toBoxConstraints(orientation) ) spaceAfterLastNoWeight = min( arrangementSpacingPx, mainAxisMax - fixedSpace - placeable.mainAxisSize() ) fixedSpace += placeable.mainAxisSize() + spaceAfterLastNoWeight crossAxisSpace = max(crossAxisSpace, placeable.crossAxisSize()) anyAlignBy = anyAlignBy || parentData.isRelative placeables[i] = placeable } } var weightedSpace = 0 if (weightChildrenCount == 0) { // fixedSpace contains an extra spacing after the last non-weight child. fixedSpace -= spaceAfterLastNoWeight } else { // Measure the rest according to their weights in the remaining main axis space. val targetSpace = if (totalWeight > 0f && constraints.mainAxisMax != Constraints.Infinity) { constraints.mainAxisMax } else { constraints.mainAxisMin } val remainingToTarget = targetSpace - fixedSpace - arrangementSpacingPx * (weightChildrenCount - 1) val weightUnitSpace = if (totalWeight > 0) remainingToTarget / totalWeight else 0f var remainder = remainingToTarget - (startIndex until endIndex).sumOf { (weightUnitSpace * rowColumnParentData[it].weight).roundToInt() } for (i in startIndex until endIndex) { if (placeables[i] == null) { val child = measurables[i] val parentData = rowColumnParentData[i] val weight = parentData.weight check(weight > 0) { "All weights <= 0 should have placeables" } // After the weightUnitSpace rounding, the total space going to be occupied // can be smaller or larger than remainingToTarget. Here we distribute the // loss or gain remainder evenly to the first children. val remainderUnit = remainder.sign remainder -= remainderUnit val childMainAxisSize = max( 0, (weightUnitSpace * weight).roundToInt() + remainderUnit ) val placeable = child.measure( OrientationIndependentConstraints( if (parentData.fill && childMainAxisSize != Constraints.Infinity) { childMainAxisSize } else { 0 }, childMainAxisSize, 0, constraints.crossAxisMax ).toBoxConstraints(orientation) ) weightedSpace += placeable.mainAxisSize() crossAxisSpace = max(crossAxisSpace, placeable.crossAxisSize()) anyAlignBy = anyAlignBy || parentData.isRelative placeables[i] = placeable } } weightedSpace = (weightedSpace + arrangementSpacingPx * (weightChildrenCount - 1)) .coerceAtMost(constraints.mainAxisMax - fixedSpace) } var beforeCrossAxisAlignmentLine = 0 var afterCrossAxisAlignmentLine = 0 if (anyAlignBy) { for (i in startIndex until endIndex) { val placeable = placeables[i]!! val parentData = rowColumnParentData[i] val alignmentLinePosition = parentData.crossAxisAlignment ?.calculateAlignmentLinePosition(placeable) if (alignmentLinePosition != null) { beforeCrossAxisAlignmentLine = max( beforeCrossAxisAlignmentLine, alignmentLinePosition.let { if (it != AlignmentLine.Unspecified) it else 0 } ) afterCrossAxisAlignmentLine = max( afterCrossAxisAlignmentLine, placeable.crossAxisSize() - ( alignmentLinePosition.let { if (it != AlignmentLine.Unspecified) { it } else { placeable.crossAxisSize() } } ) ) } } } // Compute the Row or Column size and position the children. val mainAxisLayoutSize = max(fixedSpace + weightedSpace, constraints.mainAxisMin) val crossAxisLayoutSize = if (constraints.crossAxisMax != Constraints.Infinity && crossAxisSize == SizeMode.Expand ) { constraints.crossAxisMax } else { max( crossAxisSpace, max( constraints.crossAxisMin, beforeCrossAxisAlignmentLine + afterCrossAxisAlignmentLine ) ) } val mainAxisPositions = IntArray(subSize) { 0 } val childrenMainAxisSize = IntArray(subSize) { index -> placeables[index + startIndex]!!.mainAxisSize() } return RowColumnMeasureHelperResult( mainAxisSize = mainAxisLayoutSize, crossAxisSize = crossAxisLayoutSize, startIndex = startIndex, endIndex = endIndex, beforeCrossAxisAlignmentLine = beforeCrossAxisAlignmentLine, mainAxisPositions = mainAxisPositions( mainAxisLayoutSize, childrenMainAxisSize, mainAxisPositions, measureScope ) ) } private fun mainAxisPositions( mainAxisLayoutSize: Int, childrenMainAxisSize: IntArray, mainAxisPositions: IntArray, measureScope: MeasureScope ): IntArray { arrangement( mainAxisLayoutSize, childrenMainAxisSize, measureScope.layoutDirection, measureScope, mainAxisPositions ) return mainAxisPositions } protected fun getCrossAxisPosition( placeable: Placeable, parentData: RowColumnParentData?, crossAxisLayoutSize: Int, layoutDirection: LayoutDirection, beforeCrossAxisAlignmentLine: Int ): Int { val childCrossAlignment = parentData?.crossAxisAlignment ?: crossAxisAlignment return childCrossAlignment.align( size = crossAxisLayoutSize - placeable.crossAxisSize(), layoutDirection = if (orientation == LayoutOrientation.Horizontal) { LayoutDirection.Ltr } else { layoutDirection }, placeable = placeable, beforeCrossAxisAlignmentLine = beforeCrossAxisAlignmentLine ) } fun placeHelper( placeableScope: Placeable.PlacementScope, measureResult: RowColumnMeasureHelperResult, crossAxisOffset: Int, layoutDirection: LayoutDirection, ) { with(placeableScope) { for (i in measureResult.startIndex until measureResult.endIndex) { val placeable = placeables[i] placeable!! val mainAxisPositions = measureResult.mainAxisPositions val crossAxisPosition = getCrossAxisPosition( placeable, (measurables[i].parentData as? RowColumnParentData), measureResult.crossAxisSize, layoutDirection, measureResult.beforeCrossAxisAlignmentLine ) + crossAxisOffset if (orientation == LayoutOrientation.Horizontal) { placeable.place( mainAxisPositions[i - measureResult.startIndex], crossAxisPosition ) } else { placeable.place( crossAxisPosition, mainAxisPositions[i - measureResult.startIndex] ) } } } } } ================================================ FILE: adaptive/src/main/java/com/google/accompanist/adaptive/TwoPane.kt ================================================ /* * Copyright 2022 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 * * 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. */ package com.google.accompanist.adaptive import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.runtime.Composable import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.toComposeRect import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.layoutId import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.constrain import androidx.compose.ui.unit.constrainHeight import androidx.compose.ui.unit.constrainWidth import androidx.compose.ui.unit.dp import androidx.window.layout.DisplayFeature import androidx.window.layout.FoldingFeature import kotlin.math.roundToInt /** * A layout that places two different pieces of content defined by the [first] and [second] * slots where the arrangement, sizes and separation behaviour is controlled by [TwoPaneStrategy]. * * [TwoPane] is fold and hinges aware using the provided the [displayFeatures] (which should * normally be calculated via [calculateDisplayFeatures]). The layout will be adapted to properly * separate [first] and [second] panes so they don't interfere with hardware hinges (vertical or * horizontal) as specified in [displayFeatures], or respect folds when needed (for example, when * foldable is half-folded (90-degree fold AKA tabletop) the split will become on the bend). * * To only be aware of folds with a specific orientation, pass in an alternate * [foldAwareConfiguration] to only adjust for vertical or horizontal folds. * * The [TwoPane] layout will always place both [first] and [second], based on the provided * [strategy] and window environment. If you instead only want to place one or the other, * that should be controlled at a higher level and not calling [TwoPane] if placing both is not * desired. * * @param first the first content of the layout, a left-most in LTR, a right-most in RTL and * top-most in a vertical split based on the [SplitResult] of [TwoPaneStrategy.calculateSplitResult] * @param second the second content of the layout, a right-most in the LTR, a left-most in the RTL * and the bottom-most in a vertical split based on the [SplitResult] of * [TwoPaneStrategy.calculateSplitResult] * @param strategy strategy of the two pane that controls the arrangement of the layout * @param displayFeatures the list of known display features to automatically avoid * @param foldAwareConfiguration the types of display features to automatically avoid * @param modifier an optional modifier for the layout */ @Composable public fun TwoPane( first: @Composable () -> Unit, second: @Composable () -> Unit, strategy: TwoPaneStrategy, displayFeatures: List, modifier: Modifier = Modifier, foldAwareConfiguration: FoldAwareConfiguration = FoldAwareConfiguration.AllFolds, ) { TwoPane( first = first, second = second, strategy = when (foldAwareConfiguration) { FoldAwareConfiguration.HorizontalFoldsOnly -> { VerticalTwoPaneStrategy( displayFeatures = displayFeatures, defaultStrategy = strategy, ) } FoldAwareConfiguration.VerticalFoldsOnly -> { HorizontalTwoPaneStrategy( displayFeatures = displayFeatures, defaultStrategy = strategy, ) } FoldAwareConfiguration.AllFolds -> { TwoPaneStrategy( displayFeatures = displayFeatures, defaultStrategy = strategy, ) } else -> error("Unknown FoldAware value!") }, modifier = modifier, ) } @OptIn(ExperimentalComposeUiApi::class) @Composable internal fun TwoPane( first: @Composable () -> Unit, second: @Composable () -> Unit, strategy: TwoPaneStrategy, modifier: Modifier = Modifier ) { val density = LocalDensity.current Layout( modifier = modifier.wrapContentSize(), content = { Box(Modifier.layoutId("first")) { first() } Box(Modifier.layoutId("second")) { second() } } ) { measurable, constraints -> val firstMeasurable = measurable.find { it.layoutId == "first" }!! val secondMeasurable = measurable.find { it.layoutId == "second" }!! layout(constraints.maxWidth, constraints.maxHeight) { val splitResult = strategy.calculateSplitResult( density = density, layoutDirection = layoutDirection, layoutCoordinates = coordinates ?: return@layout ) val gapOrientation = splitResult.gapOrientation val gapBounds = splitResult.gapBounds val gapLeft = constraints.constrainWidth(gapBounds.left.roundToInt()) val gapRight = constraints.constrainWidth(gapBounds.right.roundToInt()) val gapTop = constraints.constrainHeight(gapBounds.top.roundToInt()) val gapBottom = constraints.constrainHeight(gapBounds.bottom.roundToInt()) val firstConstraints = if (gapOrientation == Orientation.Vertical) { val width = when (layoutDirection) { LayoutDirection.Ltr -> gapLeft LayoutDirection.Rtl -> constraints.maxWidth - gapRight } constraints.copy(minWidth = width, maxWidth = width) } else { constraints.copy(minHeight = gapTop, maxHeight = gapTop) } val secondConstraints = if (gapOrientation == Orientation.Vertical) { val width = when (layoutDirection) { LayoutDirection.Ltr -> constraints.maxWidth - gapRight LayoutDirection.Rtl -> gapLeft } constraints.copy(minWidth = width, maxWidth = width) } else { val height = constraints.maxHeight - gapBottom constraints.copy(minHeight = height, maxHeight = height) } val firstPlaceable = firstMeasurable.measure(constraints.constrain(firstConstraints)) val secondPlaceable = secondMeasurable.measure(constraints.constrain(secondConstraints)) firstPlaceable.placeRelative(0, 0) val detailOffsetX = if (gapOrientation == Orientation.Vertical) { constraints.maxWidth - secondPlaceable.width } else { 0 } val detailOffsetY = if (gapOrientation == Orientation.Vertical) { 0 } else { constraints.maxHeight - secondPlaceable.height } secondPlaceable.placeRelative(detailOffsetX, detailOffsetY) } } } /** * The configuration for which type of folds for a [TwoPane] to automatically avoid. */ @JvmInline public value class FoldAwareConfiguration private constructor(private val value: Int) { public companion object { /** * The [TwoPane] will only be aware of horizontal folds only, splitting the content * vertically. */ public val HorizontalFoldsOnly: FoldAwareConfiguration = FoldAwareConfiguration(0) /** * The [TwoPane] will only be aware of vertical folds only, splitting the content * horizontally. */ public val VerticalFoldsOnly: FoldAwareConfiguration = FoldAwareConfiguration(1) /** * The [TwoPane] will be aware of both horizontal and vertical folds, splitting the content * vertically and horizontally respectively. */ public val AllFolds: FoldAwareConfiguration = FoldAwareConfiguration(2) } } /** * Returns the specification for where to place a split in [TwoPane] as a result of * [TwoPaneStrategy.calculateSplitResult] */ public class SplitResult( /** * Whether the gap is vertical or horizontal */ public val gapOrientation: Orientation, /** * The bounds that are nether a `start` pane or an `end` pane, but a separation between those * two. In case width or height is 0 - it means that the gap itself is a 0 width/height, but the * place within the layout is still defined. * * The [gapBounds] should be defined in local bounds to the [TwoPane]. */ public val gapBounds: Rect, ) /** * A strategy for configuring the [TwoPane] component, that is responsible for the meta-data * corresponding to the arrangement of the two panes of the layout. */ public fun interface TwoPaneStrategy { /** * Calculates the split result in local bounds of the [TwoPane]. * * @param density the [Density] for measuring and laying out * @param layoutDirection the [LayoutDirection] for measuring and laying out * @param layoutCoordinates the [LayoutCoordinates] of the [TwoPane] */ public fun calculateSplitResult( density: Density, layoutDirection: LayoutDirection, layoutCoordinates: LayoutCoordinates ): SplitResult } /** * A strategy for configuring the [TwoPane] component, that is responsible for the meta-data * corresponding to the arrangement of the two panes of the layout. * * This strategy can be conditional: If `null` is returned from [calculateSplitResult], then this * strategy did not produce a split result to use, and a different strategy should be used. */ private fun interface ConditionalTwoPaneStrategy { /** * Calculates the split result in local bounds of the [TwoPane], or `null` if this strategy * does not apply. * * @param density the [Density] for measuring and laying out * @param layoutDirection the [LayoutDirection] for measuring and laying out * @param layoutCoordinates the [LayoutCoordinates] of the [TwoPane] */ fun calculateSplitResult( density: Density, layoutDirection: LayoutDirection, layoutCoordinates: LayoutCoordinates ): SplitResult? } /** * Returns a [TwoPaneStrategy] that will place the slots horizontally. * * The gap will be placed at the given [splitFraction] from start, with the given * [gapWidth]. */ public fun HorizontalTwoPaneStrategy( splitFraction: Float, gapWidth: Dp = 0.dp, ): TwoPaneStrategy = FractionHorizontalTwoPaneStrategy( splitFraction = splitFraction, gapWidth = gapWidth ) /** * Returns a [TwoPaneStrategy] that will place the slots horizontally. * * The gap will be placed at [splitOffset] either from the start or end based on * [offsetFromStart], with the given [gapWidth]. */ public fun HorizontalTwoPaneStrategy( splitOffset: Dp, offsetFromStart: Boolean = true, gapWidth: Dp = 0.dp, ): TwoPaneStrategy = FixedOffsetHorizontalTwoPaneStrategy( splitOffset = splitOffset, offsetFromStart = offsetFromStart, gapWidth = gapWidth ) /** * Returns a [TwoPaneStrategy] that will place the slots horizontally. * * The gap will be placed at the given [splitFraction] from top, with the given * [gapHeight]. */ public fun VerticalTwoPaneStrategy( splitFraction: Float, gapHeight: Dp = 0.dp, ): TwoPaneStrategy = FractionVerticalTwoPaneStrategy( splitFraction = splitFraction, gapHeight = gapHeight ) /** * Returns a [TwoPaneStrategy] that will place the slots horizontally. * * The gap will be placed at [splitOffset] either from the top or bottom based on * [offsetFromTop], with the given [gapHeight]. */ public fun VerticalTwoPaneStrategy( splitOffset: Dp, offsetFromTop: Boolean = true, gapHeight: Dp = 0.dp, ): TwoPaneStrategy = FixedOffsetVerticalTwoPaneStrategy( splitOffset = splitOffset, offsetFromTop = offsetFromTop, gapHeight = gapHeight ) /** * Returns a [TwoPaneStrategy] that will place the slots vertically or horizontally if there is a * horizontal or vertical fold respectively. * * If there is no fold, then the [defaultStrategy] will be used instead. */ private fun TwoPaneStrategy( displayFeatures: List, defaultStrategy: TwoPaneStrategy, ): TwoPaneStrategy = TwoPaneStrategy( FoldAwareHorizontalTwoPaneStrategy(displayFeatures), FoldAwareVerticalTwoPaneStrategy(displayFeatures), defaultStrategy = defaultStrategy ) /** * Returns a [TwoPaneStrategy] that will place the slots horizontally if there is a vertical fold. * * If there is no fold, then the [defaultStrategy] will be used instead. */ private fun HorizontalTwoPaneStrategy( displayFeatures: List, defaultStrategy: TwoPaneStrategy, ): TwoPaneStrategy = TwoPaneStrategy( FoldAwareHorizontalTwoPaneStrategy(displayFeatures), defaultStrategy = defaultStrategy ) /** * Returns a [TwoPaneStrategy] that will place the slots vertically if there is a horizontal fold. * * If there is no fold, then the [defaultStrategy] will be used instead. */ private fun VerticalTwoPaneStrategy( displayFeatures: List, defaultStrategy: TwoPaneStrategy, ): TwoPaneStrategy = TwoPaneStrategy( FoldAwareVerticalTwoPaneStrategy(displayFeatures), defaultStrategy = defaultStrategy ) /** * Returns a composite [TwoPaneStrategy]. * * The conditional strategies (if any) will be attempted in order, and their split result used * if they return one. If none return a split result, then the [defaultStrategy] will be used, * which guarantees returning a [SplitResult]. */ private fun TwoPaneStrategy( vararg conditionalStrategies: ConditionalTwoPaneStrategy, defaultStrategy: TwoPaneStrategy ): TwoPaneStrategy = TwoPaneStrategy { density, layoutDirection, layoutCoordinates -> conditionalStrategies.firstNotNullOfOrNull { conditionalTwoPaneStrategy -> conditionalTwoPaneStrategy.calculateSplitResult( density = density, layoutDirection = layoutDirection, layoutCoordinates = layoutCoordinates ) } ?: defaultStrategy.calculateSplitResult( density = density, layoutDirection = layoutDirection, layoutCoordinates = layoutCoordinates ) } /** * Returns a [ConditionalTwoPaneStrategy] that will place the slots horizontally if there is a * vertical fold, or `null` if there is no fold. */ private fun FoldAwareHorizontalTwoPaneStrategy( displayFeatures: List, ): ConditionalTwoPaneStrategy = ConditionalTwoPaneStrategy { _, _, layoutCoordinates -> val verticalFold = displayFeatures.find { it is FoldingFeature && it.orientation == FoldingFeature.Orientation.VERTICAL } as FoldingFeature? if (verticalFold != null && ( verticalFold.isSeparating || verticalFold.occlusionType == FoldingFeature.OcclusionType.FULL ) && verticalFold.bounds.toComposeRect().overlaps(layoutCoordinates.boundsInWindow()) ) { val foldBounds = verticalFold.bounds.toComposeRect() SplitResult( gapOrientation = Orientation.Vertical, gapBounds = Rect( layoutCoordinates.windowToLocal(foldBounds.topLeft), layoutCoordinates.windowToLocal(foldBounds.bottomRight) ) ) } else { null } } /** * Returns a [ConditionalTwoPaneStrategy] that will place the slots vertically if there is a * horizontal fold, or `null` if there is no fold. */ private fun FoldAwareVerticalTwoPaneStrategy( displayFeatures: List, ): ConditionalTwoPaneStrategy = ConditionalTwoPaneStrategy { _, _, layoutCoordinates -> val horizontalFold = displayFeatures.find { it is FoldingFeature && it.orientation == FoldingFeature.Orientation.HORIZONTAL } as FoldingFeature? if (horizontalFold != null && ( horizontalFold.isSeparating || horizontalFold.occlusionType == FoldingFeature.OcclusionType.FULL ) && horizontalFold.bounds.toComposeRect().overlaps(layoutCoordinates.boundsInWindow()) ) { val foldBounds = horizontalFold.bounds.toComposeRect() SplitResult( gapOrientation = Orientation.Horizontal, gapBounds = Rect( layoutCoordinates.windowToLocal(foldBounds.topLeft), layoutCoordinates.windowToLocal(foldBounds.bottomRight) ) ) } else { null } } /** * Returns a [TwoPaneStrategy] that will place the slots horizontally. * * The gap will be placed at the given [splitFraction] from start, with the given [gapWidth]. * * This strategy is _not_ fold aware. */ internal fun FractionHorizontalTwoPaneStrategy( splitFraction: Float, gapWidth: Dp = 0.dp, ): TwoPaneStrategy = TwoPaneStrategy { density, layoutDirection, layoutCoordinates -> val splitX = layoutCoordinates.size.width * when (layoutDirection) { LayoutDirection.Ltr -> splitFraction LayoutDirection.Rtl -> 1 - splitFraction } val splitWidthPixel = with(density) { gapWidth.toPx() } SplitResult( gapOrientation = Orientation.Vertical, gapBounds = Rect( left = splitX - splitWidthPixel / 2f, top = 0f, right = splitX + splitWidthPixel / 2f, bottom = layoutCoordinates.size.height.toFloat(), ) ) } /** * Returns a [TwoPaneStrategy] that will place the slots horizontally. * * The gap will be placed at [splitOffset] either from the start or end based on * [offsetFromStart], with the given [gapWidth]. * * This strategy is _not_ fold aware. */ internal fun FixedOffsetHorizontalTwoPaneStrategy( splitOffset: Dp, offsetFromStart: Boolean, gapWidth: Dp = 0.dp, ): TwoPaneStrategy = TwoPaneStrategy { density, layoutDirection, layoutCoordinates -> val splitOffsetPixel = with(density) { splitOffset.toPx() } val splitX = when (layoutDirection) { LayoutDirection.Ltr -> if (offsetFromStart) { splitOffsetPixel } else { layoutCoordinates.size.width - splitOffsetPixel } LayoutDirection.Rtl -> if (offsetFromStart) { layoutCoordinates.size.width - splitOffsetPixel } else { splitOffsetPixel } } val splitWidthPixel = with(density) { gapWidth.toPx() } SplitResult( gapOrientation = Orientation.Vertical, gapBounds = Rect( left = splitX - splitWidthPixel / 2f, top = 0f, right = splitX + splitWidthPixel / 2f, bottom = layoutCoordinates.size.height.toFloat(), ) ) } /** * Returns a [TwoPaneStrategy] that will place the slots horizontally. * * The split will be placed at the given [splitFraction] from start, with the given [gapHeight]. * * This strategy is _not_ fold aware. */ internal fun FractionVerticalTwoPaneStrategy( splitFraction: Float, gapHeight: Dp = 0.dp, ): TwoPaneStrategy = TwoPaneStrategy { density, _, layoutCoordinates -> val splitY = layoutCoordinates.size.height * splitFraction val splitHeightPixel = with(density) { gapHeight.toPx() } SplitResult( gapOrientation = Orientation.Horizontal, gapBounds = Rect( left = 0f, top = splitY - splitHeightPixel / 2f, right = layoutCoordinates.size.width.toFloat(), bottom = splitY + splitHeightPixel / 2f, ) ) } /** * Returns a [TwoPaneStrategy] that will place the slots horizontally. * * The split will be placed at [splitOffset] either from the top or bottom based on * [offsetFromTop], with the given [gapHeight]. * * This strategy is _not_ fold aware. */ internal fun FixedOffsetVerticalTwoPaneStrategy( splitOffset: Dp, offsetFromTop: Boolean, gapHeight: Dp = 0.dp, ): TwoPaneStrategy = TwoPaneStrategy { density, _, layoutCoordinates -> val splitOffsetPixel = with(density) { splitOffset.toPx() } val splitY = if (offsetFromTop) { splitOffsetPixel } else { layoutCoordinates.size.height - splitOffsetPixel } val splitHeightPixel = with(density) { gapHeight.toPx() } SplitResult( gapOrientation = Orientation.Horizontal, gapBounds = Rect( left = 0f, top = splitY - splitHeightPixel / 2f, right = layoutCoordinates.size.width.toFloat(), bottom = splitY + splitHeightPixel / 2f, ) ) } ================================================ FILE: adaptive/src/sharedTest/kotlin/com/google/accompanist/adaptive/DisplayFeaturesTest.kt ================================================ /* * Copyright 2022 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 * * 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. */ package com.google.accompanist.adaptive import androidx.activity.ComponentActivity import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.window.layout.DisplayFeature import androidx.window.layout.FoldingFeature import androidx.window.layout.WindowLayoutInfo import androidx.window.testing.layout.FoldingFeature import androidx.window.testing.layout.WindowLayoutInfoPublisherRule import com.google.common.truth.Truth.assertThat import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class DisplayFeaturesTest { @get:Rule val composeTestRule = createAndroidComposeRule() @get:Rule val windowLayoutInfoPublisherRule = WindowLayoutInfoPublisherRule() @Test fun empty_folding_features_is_correct() { lateinit var displayFeatures: List composeTestRule.setContent { displayFeatures = calculateDisplayFeatures(activity = composeTestRule.activity) } windowLayoutInfoPublisherRule.overrideWindowLayoutInfo(WindowLayoutInfo(emptyList())) composeTestRule.waitForIdle() assertThat(displayFeatures).isEmpty() } @Test fun single_folding_features_is_correct() { lateinit var displayFeatures: List composeTestRule.setContent { displayFeatures = calculateDisplayFeatures(activity = composeTestRule.activity) } val fakeFoldingFeature = FoldingFeature( activity = composeTestRule.activity, center = 200, size = 40, state = FoldingFeature.State.HALF_OPENED, orientation = FoldingFeature.Orientation.VERTICAL, ) windowLayoutInfoPublisherRule.overrideWindowLayoutInfo( WindowLayoutInfo( listOf( fakeFoldingFeature ) ) ) composeTestRule.waitForIdle() assertThat(displayFeatures).hasSize(1) assertThat(displayFeatures[0]).isEqualTo(fakeFoldingFeature) } @Test fun updating_folding_features_is_correct() { lateinit var displayFeatures: List composeTestRule.setContent { displayFeatures = calculateDisplayFeatures(activity = composeTestRule.activity) } windowLayoutInfoPublisherRule.overrideWindowLayoutInfo(WindowLayoutInfo(emptyList())) val fakeFoldingFeature = FoldingFeature( activity = composeTestRule.activity, center = 200, size = 40, state = FoldingFeature.State.HALF_OPENED, orientation = FoldingFeature.Orientation.VERTICAL, ) windowLayoutInfoPublisherRule.overrideWindowLayoutInfo( WindowLayoutInfo( listOf( fakeFoldingFeature ) ) ) composeTestRule.waitForIdle() assertThat(displayFeatures).hasSize(1) assertThat(displayFeatures[0]).isEqualTo(fakeFoldingFeature) } } ================================================ FILE: adaptive/src/sharedTest/kotlin/com/google/accompanist/adaptive/FoldAwareColumnTest.kt ================================================ /* * Copyright 2023 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 * * 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. */ package com.google.accompanist.adaptive import android.annotation.SuppressLint import androidx.activity.ComponentActivity import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.toComposeRect import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInWindow import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag import androidx.compose.ui.test.assertTopPositionInRootIsEqualTo import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onRoot import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.window.layout.FoldingFeature import androidx.window.layout.WindowLayoutInfo import androidx.window.layout.WindowMetricsCalculator import androidx.window.testing.layout.FoldingFeature import androidx.window.testing.layout.WindowLayoutInfoPublisherRule import com.google.accompanist.adaptive.FoldAwareColumnScopeInstance.ignoreFold import org.junit.After import org.junit.Assert.assertEquals import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class FoldAwareColumnTest { @get:Rule val composeTestRule = createAndroidComposeRule() @get:Rule val publisherRule = WindowLayoutInfoPublisherRule() private val testTag = "FoldAwareColumnTestTag" private var firstSpacerHeightDp = 0.dp private var secondSpacerTopPx = 0f private var secondSpacerBottomPx = 0f @After fun cleanUp() { firstSpacerHeightDp = 0.dp secondSpacerTopPx = 0f secondSpacerBottomPx = 0f } @Test fun second_spacer_placed_below_fold_with_hinge() { composeTestRule.setContent { FoldAwareColumnWithSpacers() } val foldBoundsPx = simulateFoldingFeature() assertEquals(foldBoundsPx.bottom, secondSpacerTopPx) } @Test fun second_spacer_placed_below_fold_with_separating_fold() { composeTestRule.setContent { FoldAwareColumnWithSpacers() } val foldBoundsPx = simulateFoldingFeature(foldSizePx = 0) assertEquals(foldBoundsPx.bottom, secondSpacerTopPx) } @Test fun second_spacer_placed_below_first_spacer_without_fold() { composeTestRule.setContent { FoldAwareColumnWithSpacers() } composeTestRule.onNodeWithTag(testTag).assertTopPositionInRootIsEqualTo(firstSpacerHeightDp) } @Test fun second_spacer_placed_below_first_spacer_with_non_separating_fold() { composeTestRule.setContent { FoldAwareColumnWithSpacers() } simulateFoldingFeature(foldSizePx = 0, foldState = FoldingFeature.State.FLAT) composeTestRule.onNodeWithTag(testTag).assertTopPositionInRootIsEqualTo(firstSpacerHeightDp) } @Test fun second_spacer_placed_below_first_spacer_with_vertical_hinge() { composeTestRule.setContent { FoldAwareColumnWithSpacers() } simulateFoldingFeature(foldOrientation = FoldingFeature.Orientation.VERTICAL) composeTestRule.onNodeWithTag(testTag).assertTopPositionInRootIsEqualTo(firstSpacerHeightDp) } @Test fun second_spacer_placed_below_first_spacer_with_ignore_fold_modifier() { composeTestRule.setContent { FoldAwareColumnWithSpacers(secondSpacerModifier = Modifier.ignoreFold()) } simulateFoldingFeature() composeTestRule.onNodeWithTag(testTag).assertTopPositionInRootIsEqualTo(firstSpacerHeightDp) } @Test fun even_fold_padding_modifier_applies_around_hinge() { val foldPaddingDp = 20.dp lateinit var density: Density composeTestRule.setContent { density = LocalDensity.current FoldAwareColumnWithSpacers( foldPadding = PaddingValues(vertical = foldPaddingDp) ) } val foldBoundsPx = simulateFoldingFeature() with(density) { assertEquals(foldBoundsPx.bottom + foldPaddingDp.roundToPx(), secondSpacerTopPx) } } @Test fun uneven_fold_padding_modifier_applies_around_hinge() { val foldPaddingBottom = 40.dp lateinit var density: Density composeTestRule.setContent { density = LocalDensity.current FoldAwareColumnWithSpacers( foldPadding = PaddingValues(top = 15.dp, bottom = foldPaddingBottom) ) } val foldBoundsPx = simulateFoldingFeature() with(density) { assertEquals(foldBoundsPx.bottom + foldPaddingBottom.roundToPx(), secondSpacerTopPx) } } @Test fun layout_bounds_align_with_child_bounds_without_separating_fold() { composeTestRule.setContent { FoldAwareColumnWithSpacers() } val layoutBottomPx = composeTestRule.onRoot() .fetchSemanticsNode().layoutInfo.coordinates.trueBoundsInWindow().bottom assertEquals(layoutBottomPx, secondSpacerBottomPx) } @Test fun layout_bounds_contain_child_bounds_when_placed_above_hinge() { composeTestRule.setContent { FoldAwareColumnWithSpacers( firstSpacerHeightPct = 0.1f, secondSpacerHeightPct = 0.1f ) } simulateFoldingFeature() val layoutBottomPx = composeTestRule.onRoot() .fetchSemanticsNode().layoutInfo.coordinates.trueBoundsInWindow().bottom assert(secondSpacerBottomPx <= layoutBottomPx) } @Test fun layout_bounds_contain_child_bounds_when_placed_below_hinge() { composeTestRule.setContent { FoldAwareColumnWithSpacers() } simulateFoldingFeature() val layoutBottomPx = composeTestRule.onRoot() .fetchSemanticsNode().layoutInfo.coordinates.trueBoundsInWindow().bottom assert(secondSpacerBottomPx <= layoutBottomPx) } /** * Test layout for FoldAwareColumn that includes two spacers with the provided heights */ @Composable @SuppressLint("ModifierParameter") private fun FoldAwareColumnWithSpacers( foldPadding: PaddingValues = PaddingValues(), firstSpacerHeightPct: Float = 0.25f, secondSpacerHeightPct: Float = 0.25f, secondSpacerModifier: Modifier = Modifier, ) { var secondSpacerHeightDp: Dp val metrics = remember(LocalConfiguration.current) { WindowMetricsCalculator.getOrCreate() .computeCurrentWindowMetrics(composeTestRule.activity) } with(LocalDensity.current) { val windowHeight = metrics.bounds.height().toDp().value firstSpacerHeightDp = (firstSpacerHeightPct * windowHeight).dp secondSpacerHeightDp = (secondSpacerHeightPct * windowHeight).dp } FoldAwareColumn( displayFeatures = calculateDisplayFeatures(activity = composeTestRule.activity), foldPadding = foldPadding, ) { Spacer( modifier = Modifier.height(firstSpacerHeightDp) ) Spacer( modifier = secondSpacerModifier .height(secondSpacerHeightDp) .testTag(testTag) .onGloballyPositioned { secondSpacerTopPx = it.positionInWindow().y secondSpacerBottomPx = secondSpacerTopPx + it.size.height } ) } } /** * Simulates a Jetpack Window Manager folding feature with the provided properties and returns * the bounding box of the fold */ private fun simulateFoldingFeature( foldSizePx: Int = 25, foldState: FoldingFeature.State = FoldingFeature.State.HALF_OPENED, foldOrientation: FoldingFeature.Orientation = FoldingFeature.Orientation.HORIZONTAL ): Rect { val fakeFoldingFeature = FoldingFeature( activity = composeTestRule.activity, size = foldSizePx, state = foldState, orientation = foldOrientation, ) publisherRule.overrideWindowLayoutInfo(WindowLayoutInfo(listOf(fakeFoldingFeature))) composeTestRule.waitForIdle() return fakeFoldingFeature.bounds.toComposeRect() } } ================================================ FILE: adaptive/src/sharedTest/kotlin/com/google/accompanist/adaptive/TwoPaneTest.kt ================================================ /* * Copyright 2022 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 * * 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. */ package com.google.accompanist.adaptive import androidx.compose.foundation.background import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.onPlaced import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.test.DeviceConfigurationOverride import androidx.compose.ui.test.ForcedSize import androidx.compose.ui.test.LayoutDirection import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.then import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.DpRect import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.IntRect import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.toIntRect import androidx.compose.ui.unit.toOffset import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.window.core.ExperimentalWindowApi import androidx.window.layout.DisplayFeature import androidx.window.layout.FoldingFeature import androidx.window.testing.layout.FoldingFeature import com.google.common.truth.Truth.assertThat import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import kotlin.math.roundToInt @RunWith(AndroidJUnit4::class) class TwoPaneTest { @get:Rule val composeTestRule = createComposeRule() @Test fun fraction_horizontal_renders_correctly_ltr() { lateinit var density: Density lateinit var twoPaneCoordinates: LayoutCoordinates lateinit var firstCoordinates: LayoutCoordinates lateinit var secondCoordinates: LayoutCoordinates composeTestRule.setContent { DeviceConfigurationOverride( DeviceConfigurationOverride.ForcedSize(DpSize(900.dp, 1200.dp)) then DeviceConfigurationOverride.LayoutDirection(LayoutDirection.Ltr) ) { density = LocalDensity.current TwoPane( first = { Spacer( Modifier .background(Color.Red) .fillMaxSize() .onPlaced { firstCoordinates = it } ) }, second = { Spacer( Modifier .background(Color.Blue) .fillMaxSize() .onPlaced { secondCoordinates = it } ) }, strategy = FractionHorizontalTwoPaneStrategy( splitFraction = 1f / 3f ), modifier = Modifier.onPlaced { twoPaneCoordinates = it } ) } } compareRectWithTolerance( with(density) { DpRect( DpOffset(0.dp, 0.dp), DpSize(300.dp, 1200.dp) ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(firstCoordinates), 1f ) compareRectWithTolerance( with(density) { DpRect( DpOffset(300.dp, 0.dp), DpSize(600.dp, 1200.dp) ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(secondCoordinates), 1f ) } @Test fun fraction_horizontal_renders_correctly_rtl() { lateinit var density: Density lateinit var twoPaneCoordinates: LayoutCoordinates lateinit var firstCoordinates: LayoutCoordinates lateinit var secondCoordinates: LayoutCoordinates composeTestRule.setContent { DeviceConfigurationOverride( DeviceConfigurationOverride.ForcedSize(DpSize(900.dp, 1200.dp)) then DeviceConfigurationOverride.LayoutDirection(LayoutDirection.Rtl) ) { density = LocalDensity.current TwoPane( first = { Spacer( Modifier .background(Color.Red) .fillMaxSize() .onPlaced { firstCoordinates = it } ) }, second = { Spacer( Modifier .background(Color.Blue) .fillMaxSize() .onPlaced { secondCoordinates = it } ) }, strategy = FractionHorizontalTwoPaneStrategy( splitFraction = 1f / 3f ), modifier = Modifier.onPlaced { twoPaneCoordinates = it } ) } } compareRectWithTolerance( with(density) { DpRect( DpOffset(600.dp, 0.dp), DpSize(300.dp, 1200.dp) ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(firstCoordinates), 1f ) compareRectWithTolerance( with(density) { DpRect( DpOffset(0.dp, 0.dp), DpSize(600.dp, 1200.dp) ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(secondCoordinates), 1f ) } @Test fun fraction_horizontal_renders_correctly_with_split_width_ltr() { lateinit var density: Density lateinit var twoPaneCoordinates: LayoutCoordinates lateinit var firstCoordinates: LayoutCoordinates lateinit var secondCoordinates: LayoutCoordinates composeTestRule.setContent { DeviceConfigurationOverride( DeviceConfigurationOverride.ForcedSize(DpSize(900.dp, 1200.dp)) then DeviceConfigurationOverride.LayoutDirection(LayoutDirection.Ltr) ) { density = LocalDensity.current TwoPane( first = { Spacer( Modifier .background(Color.Red) .fillMaxSize() .onPlaced { firstCoordinates = it } ) }, second = { Spacer( Modifier .background(Color.Blue) .fillMaxSize() .onPlaced { secondCoordinates = it } ) }, strategy = FractionHorizontalTwoPaneStrategy( splitFraction = 1f / 3f, gapWidth = 64.dp ), modifier = Modifier.onPlaced { twoPaneCoordinates = it } ) } } compareRectWithTolerance( with(density) { DpRect( DpOffset(0.dp, 0.dp), DpSize(268.dp, 1200.dp) ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(firstCoordinates), 1f ) compareRectWithTolerance( with(density) { DpRect( DpOffset(332.dp, 0.dp), DpSize(568.dp, 1200.dp) ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(secondCoordinates), 1f ) } @Test fun fraction_horizontal_renders_correctly_with_split_width_rtl() { lateinit var density: Density lateinit var twoPaneCoordinates: LayoutCoordinates lateinit var firstCoordinates: LayoutCoordinates lateinit var secondCoordinates: LayoutCoordinates composeTestRule.setContent { DeviceConfigurationOverride( DeviceConfigurationOverride.ForcedSize(DpSize(900.dp, 1200.dp)) then DeviceConfigurationOverride.LayoutDirection(LayoutDirection.Rtl) ) { density = LocalDensity.current TwoPane( first = { Spacer( Modifier .background(Color.Red) .fillMaxSize() .onPlaced { firstCoordinates = it } ) }, second = { Spacer( Modifier .background(Color.Blue) .fillMaxSize() .onPlaced { secondCoordinates = it } ) }, strategy = FractionHorizontalTwoPaneStrategy( splitFraction = 1f / 3f, gapWidth = 64.dp ), modifier = Modifier.onPlaced { twoPaneCoordinates = it } ) } } compareRectWithTolerance( with(density) { DpRect( DpOffset(632.dp, 0.dp), DpSize(268.dp, 1200.dp) ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(firstCoordinates), 1f ) compareRectWithTolerance( with(density) { DpRect( DpOffset(0.dp, 0.dp), DpSize(568.dp, 1200.dp) ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(secondCoordinates), 1f ) } @Test fun fraction_vertical_renders_correctly() { lateinit var density: Density lateinit var twoPaneCoordinates: LayoutCoordinates lateinit var firstCoordinates: LayoutCoordinates lateinit var secondCoordinates: LayoutCoordinates composeTestRule.setContent { DeviceConfigurationOverride( DeviceConfigurationOverride.ForcedSize(DpSize(900.dp, 1200.dp)) ) { density = LocalDensity.current TwoPane( first = { Spacer( Modifier .background(Color.Red) .fillMaxSize() .onPlaced { firstCoordinates = it } ) }, second = { Spacer( Modifier .background(Color.Blue) .fillMaxSize() .onPlaced { secondCoordinates = it } ) }, strategy = FractionVerticalTwoPaneStrategy( splitFraction = 1f / 3f ), modifier = Modifier.onPlaced { twoPaneCoordinates = it } ) } } compareRectWithTolerance( with(density) { DpRect( DpOffset(0.dp, 0.dp), DpSize(900.dp, 400.dp) ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(firstCoordinates), 1f ) compareRectWithTolerance( with(density) { DpRect( DpOffset(0.dp, 400.dp), DpSize(900.dp, 800.dp) ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(secondCoordinates), 1f ) } @Test fun fraction_vertical_renders_correctly_with_split_height() { lateinit var density: Density lateinit var twoPaneCoordinates: LayoutCoordinates lateinit var firstCoordinates: LayoutCoordinates lateinit var secondCoordinates: LayoutCoordinates composeTestRule.setContent { DeviceConfigurationOverride( DeviceConfigurationOverride.ForcedSize(DpSize(900.dp, 1200.dp)) ) { density = LocalDensity.current TwoPane( first = { Spacer( Modifier .background(Color.Red) .fillMaxSize() .onPlaced { firstCoordinates = it } ) }, second = { Spacer( Modifier .background(Color.Blue) .fillMaxSize() .onPlaced { secondCoordinates = it } ) }, strategy = FractionVerticalTwoPaneStrategy( splitFraction = 1f / 3f, gapHeight = 64.dp ), modifier = Modifier.onPlaced { twoPaneCoordinates = it } ) } } compareRectWithTolerance( with(density) { DpRect( DpOffset(0.dp, 0.dp), DpSize(900.dp, 368.dp) ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(firstCoordinates), 1f ) compareRectWithTolerance( with(density) { DpRect( DpOffset(0.dp, 432.dp), DpSize(900.dp, 768.dp) ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(secondCoordinates), 1f ) } @Test fun fixed_offset_horizontal_from_start_horizontal_renders_correctly_ltr() { lateinit var density: Density lateinit var twoPaneCoordinates: LayoutCoordinates lateinit var firstCoordinates: LayoutCoordinates lateinit var secondCoordinates: LayoutCoordinates composeTestRule.setContent { DeviceConfigurationOverride( DeviceConfigurationOverride.ForcedSize(DpSize(900.dp, 1200.dp)) then DeviceConfigurationOverride.LayoutDirection(LayoutDirection.Ltr) ) { density = LocalDensity.current TwoPane( first = { Spacer( Modifier .background(Color.Red) .fillMaxSize() .onPlaced { firstCoordinates = it } ) }, second = { Spacer( Modifier .background(Color.Blue) .fillMaxSize() .onPlaced { secondCoordinates = it } ) }, strategy = FixedOffsetHorizontalTwoPaneStrategy( splitOffset = 200.dp, offsetFromStart = true, ), modifier = Modifier.onPlaced { twoPaneCoordinates = it } ) } } compareRectWithTolerance( with(density) { DpRect( DpOffset(0.dp, 0.dp), DpSize(200.dp, 1200.dp) ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(firstCoordinates), 1f ) compareRectWithTolerance( with(density) { DpRect( DpOffset(200.dp, 0.dp), DpSize(700.dp, 1200.dp) ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(secondCoordinates), 1f ) } @Test fun fixed_offset_horizontal_from_start_horizontal_renders_correctly_rtl() { lateinit var density: Density lateinit var twoPaneCoordinates: LayoutCoordinates lateinit var firstCoordinates: LayoutCoordinates lateinit var secondCoordinates: LayoutCoordinates composeTestRule.setContent { DeviceConfigurationOverride( DeviceConfigurationOverride.ForcedSize(DpSize(900.dp, 1200.dp)) then DeviceConfigurationOverride.LayoutDirection(LayoutDirection.Rtl) ) { density = LocalDensity.current TwoPane( first = { Spacer( Modifier .background(Color.Red) .fillMaxSize() .onPlaced { firstCoordinates = it } ) }, second = { Spacer( Modifier .background(Color.Blue) .fillMaxSize() .onPlaced { secondCoordinates = it } ) }, strategy = FixedOffsetHorizontalTwoPaneStrategy( splitOffset = 200.dp, offsetFromStart = true, ), modifier = Modifier.onPlaced { twoPaneCoordinates = it } ) } } compareRectWithTolerance( with(density) { DpRect( DpOffset(700.dp, 0.dp), DpSize(200.dp, 1200.dp) ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(firstCoordinates), 1f ) compareRectWithTolerance( with(density) { DpRect( DpOffset(0.dp, 0.dp), DpSize(700.dp, 1200.dp) ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(secondCoordinates), 1f ) } @Test fun fixed_offset_horizontal_from_start_renders_correctly_with_split_width_ltr() { lateinit var density: Density lateinit var twoPaneCoordinates: LayoutCoordinates lateinit var firstCoordinates: LayoutCoordinates lateinit var secondCoordinates: LayoutCoordinates composeTestRule.setContent { DeviceConfigurationOverride( DeviceConfigurationOverride.ForcedSize(DpSize(900.dp, 1200.dp)) then DeviceConfigurationOverride.LayoutDirection(LayoutDirection.Ltr) ) { density = LocalDensity.current TwoPane( first = { Spacer( Modifier .background(Color.Red) .fillMaxSize() .onPlaced { firstCoordinates = it } ) }, second = { Spacer( Modifier .background(Color.Blue) .fillMaxSize() .onPlaced { secondCoordinates = it } ) }, strategy = FixedOffsetHorizontalTwoPaneStrategy( splitOffset = 200.dp, offsetFromStart = true, gapWidth = 64.dp ), modifier = Modifier.onPlaced { twoPaneCoordinates = it } ) } } compareRectWithTolerance( with(density) { DpRect( DpOffset(0.dp, 0.dp), DpSize(168.dp, 1200.dp) ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(firstCoordinates), 1f ) compareRectWithTolerance( with(density) { DpRect( DpOffset(232.dp, 0.dp), DpSize(668.dp, 1200.dp) ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(secondCoordinates), 1f ) } @Test fun fixed_offset_horizontal_from_start_renders_correctly_with_split_width_rtl() { lateinit var density: Density lateinit var twoPaneCoordinates: LayoutCoordinates lateinit var firstCoordinates: LayoutCoordinates lateinit var secondCoordinates: LayoutCoordinates composeTestRule.setContent { DeviceConfigurationOverride( DeviceConfigurationOverride.ForcedSize(DpSize(900.dp, 1200.dp)) then DeviceConfigurationOverride.LayoutDirection(LayoutDirection.Rtl) ) { density = LocalDensity.current TwoPane( first = { Spacer( Modifier .background(Color.Red) .fillMaxSize() .onPlaced { firstCoordinates = it } ) }, second = { Spacer( Modifier .background(Color.Blue) .fillMaxSize() .onPlaced { secondCoordinates = it } ) }, strategy = FixedOffsetHorizontalTwoPaneStrategy( splitOffset = 200.dp, offsetFromStart = true, gapWidth = 64.dp ), modifier = Modifier.onPlaced { twoPaneCoordinates = it } ) } } compareRectWithTolerance( with(density) { DpRect( DpOffset(732.dp, 0.dp), DpSize(168.dp, 1200.dp) ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(firstCoordinates), 1f ) compareRectWithTolerance( with(density) { DpRect( DpOffset(0.dp, 0.dp), DpSize(668.dp, 1200.dp) ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(secondCoordinates), 1f ) } @Test fun fixed_offset_vertical_from_top_renders_correctly() { lateinit var density: Density lateinit var twoPaneCoordinates: LayoutCoordinates lateinit var firstCoordinates: LayoutCoordinates lateinit var secondCoordinates: LayoutCoordinates composeTestRule.setContent { DeviceConfigurationOverride( DeviceConfigurationOverride.ForcedSize(DpSize(900.dp, 1200.dp)) ) { density = LocalDensity.current TwoPane( first = { Spacer( Modifier .background(Color.Red) .fillMaxSize() .onPlaced { firstCoordinates = it } ) }, second = { Spacer( Modifier .background(Color.Blue) .fillMaxSize() .onPlaced { secondCoordinates = it } ) }, strategy = FixedOffsetVerticalTwoPaneStrategy( splitOffset = 300.dp, offsetFromTop = true ), modifier = Modifier.onPlaced { twoPaneCoordinates = it } ) } } compareRectWithTolerance( with(density) { DpRect( DpOffset(0.dp, 0.dp), DpSize(900.dp, 300.dp) ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(firstCoordinates), 1f ) compareRectWithTolerance( with(density) { DpRect( DpOffset(0.dp, 300.dp), DpSize(900.dp, 900.dp) ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(secondCoordinates), 1f ) } @Test fun fixed_offset_vertical_from_top_renders_correctly_with_split_height() { lateinit var density: Density lateinit var twoPaneCoordinates: LayoutCoordinates lateinit var firstCoordinates: LayoutCoordinates lateinit var secondCoordinates: LayoutCoordinates composeTestRule.setContent { DeviceConfigurationOverride( DeviceConfigurationOverride.ForcedSize(DpSize(900.dp, 1200.dp)) ) { density = LocalDensity.current TwoPane( first = { Spacer( Modifier .background(Color.Red) .fillMaxSize() .onPlaced { firstCoordinates = it } ) }, second = { Spacer( Modifier .background(Color.Blue) .fillMaxSize() .onPlaced { secondCoordinates = it } ) }, strategy = FixedOffsetVerticalTwoPaneStrategy( splitOffset = 300.dp, offsetFromTop = true, gapHeight = 64.dp ), modifier = Modifier.onPlaced { twoPaneCoordinates = it } ) } } compareRectWithTolerance( with(density) { DpRect( DpOffset(0.dp, 0.dp), DpSize(900.dp, 268.dp) ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(firstCoordinates), 1f ) compareRectWithTolerance( with(density) { DpRect( DpOffset(0.dp, 332.dp), DpSize(900.dp, 868.dp) ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(secondCoordinates), 1f ) } @Test fun fixed_offset_vertical_from_bottom_renders_correctly() { lateinit var density: Density lateinit var twoPaneCoordinates: LayoutCoordinates lateinit var firstCoordinates: LayoutCoordinates lateinit var secondCoordinates: LayoutCoordinates composeTestRule.setContent { DeviceConfigurationOverride( DeviceConfigurationOverride.ForcedSize(DpSize(900.dp, 1200.dp)) ) { density = LocalDensity.current TwoPane( first = { Spacer( Modifier .background(Color.Red) .fillMaxSize() .onPlaced { firstCoordinates = it } ) }, second = { Spacer( Modifier .background(Color.Blue) .fillMaxSize() .onPlaced { secondCoordinates = it } ) }, strategy = FixedOffsetVerticalTwoPaneStrategy( splitOffset = 300.dp, offsetFromTop = false ), modifier = Modifier.onPlaced { twoPaneCoordinates = it } ) } } compareRectWithTolerance( with(density) { DpRect( DpOffset(0.dp, 0.dp), DpSize(900.dp, 900.dp) ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(firstCoordinates), 1f ) compareRectWithTolerance( with(density) { DpRect( DpOffset(0.dp, 900.dp), DpSize(900.dp, 300.dp) ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(secondCoordinates), 1f ) } @Test fun fixed_offset_vertical_from_bottom_renders_correctly_with_split_height() { lateinit var density: Density lateinit var twoPaneCoordinates: LayoutCoordinates lateinit var firstCoordinates: LayoutCoordinates lateinit var secondCoordinates: LayoutCoordinates composeTestRule.setContent { DeviceConfigurationOverride( DeviceConfigurationOverride.ForcedSize(DpSize(900.dp, 1200.dp)) ) { density = LocalDensity.current TwoPane( first = { Spacer( Modifier .background(Color.Red) .fillMaxSize() .onPlaced { firstCoordinates = it } ) }, second = { Spacer( Modifier .background(Color.Blue) .fillMaxSize() .onPlaced { secondCoordinates = it } ) }, strategy = FixedOffsetVerticalTwoPaneStrategy( splitOffset = 300.dp, offsetFromTop = false, gapHeight = 64.dp ), modifier = Modifier.onPlaced { twoPaneCoordinates = it } ) } } compareRectWithTolerance( with(density) { DpRect( DpOffset(0.dp, 0.dp), DpSize(900.dp, 868.dp) ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(firstCoordinates), 1f ) compareRectWithTolerance( with(density) { DpRect( DpOffset(0.dp, 932.dp), DpSize(900.dp, 268.dp) ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(secondCoordinates), 1f ) } @Test fun two_pane_strategy_uses_fallback_when_no_fold_present() { lateinit var density: Density lateinit var twoPaneCoordinates: LayoutCoordinates lateinit var firstCoordinates: LayoutCoordinates lateinit var secondCoordinates: LayoutCoordinates val displayFeatures = DelegateList { fakeDisplayFeatures( density = density, twoPaneCoordinates = twoPaneCoordinates, localFoldingFeatures = emptyList() ) } composeTestRule.setContent { DeviceConfigurationOverride( DeviceConfigurationOverride.ForcedSize(DpSize(900.dp, 1200.dp)) ) { density = LocalDensity.current TwoPane( first = { Spacer( Modifier .background(Color.Red) .fillMaxSize() .onPlaced { firstCoordinates = it } ) }, second = { Spacer( Modifier .background(Color.Blue) .fillMaxSize() .onPlaced { secondCoordinates = it } ) }, strategy = VerticalTwoPaneStrategy( splitFraction = 1f / 3f ), displayFeatures = displayFeatures, modifier = Modifier.onPlaced { twoPaneCoordinates = it } ) } } compareRectWithTolerance( with(density) { DpRect( DpOffset(0.dp, 0.dp), DpSize(900.dp, 400.dp) ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(firstCoordinates), 1f ) compareRectWithTolerance( with(density) { DpRect( DpOffset(0.dp, 400.dp), DpSize(900.dp, 800.dp) ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(secondCoordinates), 1f ) } @Test fun two_pane_strategy_uses_vertical_placing_when_occluding_horizontal_fold_present() { lateinit var density: Density lateinit var twoPaneCoordinates: LayoutCoordinates lateinit var firstCoordinates: LayoutCoordinates lateinit var secondCoordinates: LayoutCoordinates val displayFeatures = DelegateList { fakeDisplayFeatures( density = density, twoPaneCoordinates = twoPaneCoordinates, localFoldingFeatures = listOf( LocalFoldingFeature( center = 600.dp, size = 0.dp, state = FoldingFeature.State.HALF_OPENED, orientation = FoldingFeature.Orientation.HORIZONTAL ) ) ) } composeTestRule.setContent { DeviceConfigurationOverride( DeviceConfigurationOverride.ForcedSize(DpSize(900.dp, 1200.dp)) ) { density = LocalDensity.current TwoPane( first = { Spacer( Modifier .background(Color.Red) .fillMaxSize() .onPlaced { firstCoordinates = it } ) }, second = { Spacer( Modifier .background(Color.Blue) .fillMaxSize() .onPlaced { secondCoordinates = it } ) }, strategy = VerticalTwoPaneStrategy( splitFraction = 1f / 3f ), displayFeatures = displayFeatures, modifier = Modifier.onPlaced { twoPaneCoordinates = it } ) } } compareRectWithTolerance( with(density) { DpRect( DpOffset(0.dp, 0.dp), DpSize(900.dp, 600.dp) ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(firstCoordinates), 1f ) compareRectWithTolerance( with(density) { DpRect( DpOffset(0.dp, 600.dp), DpSize(900.dp, 600.dp) ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(secondCoordinates), 1f ) } @Test fun two_pane_strategy_uses_vertical_placing_when_separating_horizontal_fold_present() { lateinit var density: Density lateinit var twoPaneCoordinates: LayoutCoordinates lateinit var firstCoordinates: LayoutCoordinates lateinit var secondCoordinates: LayoutCoordinates val displayFeatures = DelegateList { fakeDisplayFeatures( density = density, twoPaneCoordinates = twoPaneCoordinates, localFoldingFeatures = listOf( LocalFoldingFeature( center = 600.dp, size = 60.dp, state = FoldingFeature.State.FLAT, orientation = FoldingFeature.Orientation.HORIZONTAL ) ) ) } composeTestRule.setContent { DeviceConfigurationOverride( DeviceConfigurationOverride.ForcedSize(DpSize(900.dp, 1200.dp)) ) { density = LocalDensity.current TwoPane( first = { Spacer( Modifier .background(Color.Red) .fillMaxSize() .onPlaced { firstCoordinates = it } ) }, second = { Spacer( Modifier .background(Color.Blue) .fillMaxSize() .onPlaced { secondCoordinates = it } ) }, strategy = VerticalTwoPaneStrategy( splitFraction = 1f / 3f ), displayFeatures = displayFeatures, modifier = Modifier.onPlaced { twoPaneCoordinates = it } ) } } compareRectWithTolerance( with(density) { DpRect( DpOffset(0.dp, 0.dp), DpSize(900.dp, 570.dp) ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(firstCoordinates), 1f ) compareRectWithTolerance( with(density) { DpRect( DpOffset(0.dp, 630.dp), DpSize(900.dp, 570.dp) ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(secondCoordinates), 1f ) } @Test fun two_pane_strategy_uses_fallback_when_non_occluding_horizontal_fold_present() { lateinit var density: Density lateinit var twoPaneCoordinates: LayoutCoordinates lateinit var firstCoordinates: LayoutCoordinates lateinit var secondCoordinates: LayoutCoordinates val displayFeatures = DelegateList { fakeDisplayFeatures( density = density, twoPaneCoordinates = twoPaneCoordinates, localFoldingFeatures = listOf( LocalFoldingFeature( center = 600.dp, size = 0.dp, state = FoldingFeature.State.FLAT, orientation = FoldingFeature.Orientation.HORIZONTAL ) ) ) } composeTestRule.setContent { DeviceConfigurationOverride( DeviceConfigurationOverride.ForcedSize(DpSize(900.dp, 1200.dp)) ) { density = LocalDensity.current TwoPane( first = { Spacer( Modifier .background(Color.Red) .fillMaxSize() .onPlaced { firstCoordinates = it } ) }, second = { Spacer( Modifier .background(Color.Blue) .fillMaxSize() .onPlaced { secondCoordinates = it } ) }, strategy = VerticalTwoPaneStrategy( splitFraction = 1f / 3f ), displayFeatures = displayFeatures, modifier = Modifier.onPlaced { twoPaneCoordinates = it } ) } } compareRectWithTolerance( with(density) { DpRect( DpOffset(0.dp, 0.dp), DpSize(900.dp, 400.dp) ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(firstCoordinates), 1f ) compareRectWithTolerance( with(density) { DpRect( DpOffset(0.dp, 400.dp), DpSize(900.dp, 800.dp) ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(secondCoordinates), 1f ) } @Test fun two_pane_strategy_uses_horizontal_placing_when_occluding_vertical_fold_present() { lateinit var density: Density lateinit var twoPaneCoordinates: LayoutCoordinates lateinit var firstCoordinates: LayoutCoordinates lateinit var secondCoordinates: LayoutCoordinates val displayFeatures = DelegateList { fakeDisplayFeatures( density = density, twoPaneCoordinates = twoPaneCoordinates, localFoldingFeatures = listOf( LocalFoldingFeature( center = 450.dp, size = 0.dp, state = FoldingFeature.State.HALF_OPENED, orientation = FoldingFeature.Orientation.VERTICAL ) ) ) } composeTestRule.setContent { DeviceConfigurationOverride( DeviceConfigurationOverride.ForcedSize(DpSize(900.dp, 1200.dp)) ) { density = LocalDensity.current TwoPane( first = { Spacer( Modifier .background(Color.Red) .fillMaxSize() .onPlaced { firstCoordinates = it } ) }, second = { Spacer( Modifier .background(Color.Blue) .fillMaxSize() .onPlaced { secondCoordinates = it } ) }, strategy = VerticalTwoPaneStrategy( splitFraction = 1f / 3f ), displayFeatures = displayFeatures, modifier = Modifier.onPlaced { twoPaneCoordinates = it } ) } } compareRectWithTolerance( with(density) { DpRect( DpOffset(0.dp, 0.dp), DpSize(450.dp, 1200.dp) ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(firstCoordinates), 1f ) compareRectWithTolerance( with(density) { DpRect( DpOffset(450.dp, 0.dp), DpSize(450.dp, 1200.dp) ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(secondCoordinates), 1f ) } @Test fun two_pane_strategy_uses_horizontal_placing_when_separating_vertical_fold_present() { lateinit var density: Density lateinit var twoPaneCoordinates: LayoutCoordinates lateinit var firstCoordinates: LayoutCoordinates lateinit var secondCoordinates: LayoutCoordinates val displayFeatures = DelegateList { fakeDisplayFeatures( density = density, twoPaneCoordinates = twoPaneCoordinates, localFoldingFeatures = listOf( LocalFoldingFeature( center = 450.dp, size = 64.dp, state = FoldingFeature.State.FLAT, orientation = FoldingFeature.Orientation.VERTICAL ) ) ) } composeTestRule.setContent { DeviceConfigurationOverride( DeviceConfigurationOverride.ForcedSize(DpSize(900.dp, 1200.dp)) ) { density = LocalDensity.current TwoPane( first = { Spacer( Modifier .background(Color.Red) .fillMaxSize() .onPlaced { firstCoordinates = it } ) }, second = { Spacer( Modifier .background(Color.Blue) .fillMaxSize() .onPlaced { secondCoordinates = it } ) }, strategy = VerticalTwoPaneStrategy( splitFraction = 1f / 3f ), displayFeatures = displayFeatures, modifier = Modifier.onPlaced { twoPaneCoordinates = it } ) } } compareRectWithTolerance( with(density) { DpRect( DpOffset(0.dp, 0.dp), DpSize(418.dp, 1200.dp) ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(firstCoordinates), 1f ) compareRectWithTolerance( with(density) { DpRect( DpOffset(482.dp, 0.dp), DpSize(418.dp, 1200.dp) ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(secondCoordinates), 1f ) } @Test fun two_pane_strategy_uses_fallback_when_non_occluding_vertical_fold_present() { lateinit var density: Density lateinit var twoPaneCoordinates: LayoutCoordinates lateinit var firstCoordinates: LayoutCoordinates lateinit var secondCoordinates: LayoutCoordinates val displayFeatures = DelegateList { fakeDisplayFeatures( density = density, twoPaneCoordinates = twoPaneCoordinates, localFoldingFeatures = listOf( LocalFoldingFeature( center = 450.dp, size = 0.dp, state = FoldingFeature.State.FLAT, orientation = FoldingFeature.Orientation.VERTICAL ) ) ) } composeTestRule.setContent { DeviceConfigurationOverride( DeviceConfigurationOverride.ForcedSize(DpSize(900.dp, 1200.dp)) ) { density = LocalDensity.current TwoPane( first = { Spacer( Modifier .background(Color.Red) .fillMaxSize() .onPlaced { firstCoordinates = it } ) }, second = { Spacer( Modifier .background(Color.Blue) .fillMaxSize() .onPlaced { secondCoordinates = it } ) }, strategy = VerticalTwoPaneStrategy( splitFraction = 1f / 3f ), displayFeatures = displayFeatures, modifier = Modifier.onPlaced { twoPaneCoordinates = it } ) } } compareRectWithTolerance( with(density) { DpRect( DpOffset(0.dp, 0.dp), DpSize(900.dp, 400.dp) ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(firstCoordinates), 1f ) compareRectWithTolerance( with(density) { DpRect( DpOffset(0.dp, 400.dp), DpSize(900.dp, 800.dp) ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(secondCoordinates), 1f ) } } private fun compareRectWithTolerance( expected: Rect, actual: Rect, tolerance: Float, ) { assertThat(actual.left).isWithin(tolerance).of(expected.left) assertThat(actual.right).isWithin(tolerance).of(expected.right) assertThat(actual.top).isWithin(tolerance).of(expected.top) assertThat(actual.bottom).isWithin(tolerance).of(expected.bottom) } /** * A descriptor of a [FoldingFeature] but with the [center] and [size] specified relative to the * to the coordinates of the [TwoPane] layout. */ private data class LocalFoldingFeature( val center: Dp, val size: Dp, val state: FoldingFeature.State, val orientation: FoldingFeature.Orientation ) /** * A [List] that lazily constructs the backing delegate list by calling the provided lambda. */ private class DelegateList( listFactory: () -> List ) : List { val delegate by lazy(listFactory) override val size: Int get() = delegate.size override fun get(index: Int): T = delegate[index] override fun isEmpty(): Boolean = delegate.isEmpty() override fun iterator(): Iterator = delegate.iterator() override fun listIterator(): ListIterator = delegate.listIterator() override fun listIterator(index: Int): ListIterator = delegate.listIterator(index) override fun subList(fromIndex: Int, toIndex: Int): List = delegate.subList(fromIndex, toIndex) override fun lastIndexOf(element: T): Int = delegate.lastIndexOf(element) override fun indexOf(element: T): Int = delegate.indexOf(element) override fun containsAll(elements: Collection): Boolean = delegate.containsAll(elements) override fun contains(element: T): Boolean = delegate.contains(element) } /** * Folding features are always expressed in window coordinates. * * For the sake of testing, however, we want to specify them relative to the [TwoPane] under test. * * Therefore, this method takes in a list of [LocalFoldingFeature]s and the [TwoPane] layout info * in order to map the [LocalFoldingFeature]s into real [FoldingFeature] with the proper window * coordinates. * * In other words, this allows specifying [LocalFoldingFeature]s as if the [TwoPane] layout matches * the window bounds. */ @OptIn(ExperimentalWindowApi::class) private fun fakeDisplayFeatures( density: Density, twoPaneCoordinates: LayoutCoordinates, localFoldingFeatures: List ): List { val boundsTopLeftOffset = twoPaneCoordinates.localToWindow( twoPaneCoordinates.size.toIntRect().topLeft.toOffset() ) val boundsBottomRightOffset = twoPaneCoordinates.localToWindow( twoPaneCoordinates.size.toIntRect().bottomRight.toOffset() ) val bounds = Rect( boundsTopLeftOffset, boundsBottomRightOffset ) return localFoldingFeatures.map { localFoldingFeature -> val foldLeft: Float val foldTop: Float val foldRight: Float val foldBottom: Float with(density) { if (localFoldingFeature.orientation == FoldingFeature.Orientation.HORIZONTAL) { foldLeft = 0f foldTop = (localFoldingFeature.center - localFoldingFeature.size / 2).toPx() foldRight = twoPaneCoordinates.size.width.toFloat() foldBottom = (localFoldingFeature.center + localFoldingFeature.size / 2).toPx() } else { foldLeft = (localFoldingFeature.center - localFoldingFeature.size / 2).toPx() foldTop = 0f foldRight = (localFoldingFeature.center + localFoldingFeature.size / 2).toPx() foldBottom = twoPaneCoordinates.size.height.toFloat() } } val foldTopLeftOffset = twoPaneCoordinates.localToWindow( Offset(foldLeft, foldTop) ) val foldBottomRightOffset = twoPaneCoordinates.localToWindow( Offset(foldRight, foldBottom) ) val foldBounds = Rect( foldTopLeftOffset, foldBottomRightOffset, ) val center: Int val size: Int if (localFoldingFeature.orientation == FoldingFeature.Orientation.HORIZONTAL) { center = foldBounds.center.y.roundToInt() size = foldBounds.height.roundToInt() } else { center = foldBounds.center.x.roundToInt() size = foldBounds.width.roundToInt() } FoldingFeature( windowBounds = android.graphics.Rect( bounds.left.toInt(), bounds.top.toInt(), bounds.right.toInt(), bounds.bottom.toInt() ), center = center, size = size, state = localFoldingFeature.state, orientation = localFoldingFeature.orientation, ) } } private fun Rect.round(): IntRect = IntRect( left = left.roundToInt(), top = top.roundToInt(), right = right.roundToInt(), bottom = bottom.roundToInt() ) private fun IntRect.toRect(): Rect = Rect( left = left.toFloat(), top = top.toFloat(), right = right.toFloat(), bottom = bottom.toFloat() ) ================================================ FILE: adaptive/src/test/resources/robolectric.properties ================================================ # Pin SDK to 30 since Robolectric does not currently support API 31: # https://github.com/robolectric/robolectric/issues/6635 sdk=30 ================================================ FILE: build-logic/convention/build.gradle.kts ================================================ /* * Copyright 2022 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 * * 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. */ import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { `kotlin-dsl` } group = "com.google.accompanist.buildlogic" java { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } kotlin { compilerOptions { jvmTarget = JvmTarget.JVM_17 } } dependencies { // used by BundleInsideHelper.kt implementation(libs.apacheAnt) implementation(libs.shadow) compileOnly(libs.android.gradlePlugin) compileOnly(libs.android.tools.common) compileOnly(libs.compose.gradlePlugin) compileOnly(libs.kotlin.gradlePlugin) compileOnly(libs.metalavaGradle) compileOnly(libs.gradleMavenPublishPlugin) implementation(libs.truth) } tasks { validatePlugins { enableStricterValidation = true failOnWarning = true } } gradlePlugin { plugins { register("androidLibrary") { id = "accompanist.android.library" implementationClass = "AndroidLibraryConventionPlugin" } register("androidLibraryCompose") { id = "accompanist.android.library.compose" implementationClass = "AndroidLibraryComposeConventionPlugin" } register("androidLint") { id = "accompanist.android.lint" implementationClass = "AndroidLintConventionPlugin" } register("androidLibraryPublished") { id = "accompanist.android.library.published" implementationClass = "AndroidLibraryPublishedConventionPlugin" } } } ================================================ FILE: build-logic/convention/src/main/kotlin/AndroidLibraryComposeConventionPlugin.kt ================================================ /* * Copyright 2024 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 * * 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. */ import com.android.build.gradle.LibraryExtension import com.google.accompanist.configureAndroidCompose import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.kotlin.dsl.apply import org.gradle.kotlin.dsl.getByType class AndroidLibraryComposeConventionPlugin : Plugin { override fun apply(target: Project) { with(target) { apply(plugin = "com.android.library") apply(plugin = "org.jetbrains.kotlin.plugin.compose") val extension = extensions.getByType() configureAndroidCompose(extension) } } } ================================================ FILE: build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt ================================================ /* * Copyright 2024 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 * * 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. */ import com.android.build.gradle.LibraryExtension import com.google.accompanist.configureKotlinAndroid import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.kotlin.dsl.configure import org.gradle.kotlin.dsl.dependencies import org.gradle.kotlin.dsl.kotlin class AndroidLibraryConventionPlugin : Plugin { override fun apply(target: Project) { with(target) { with(pluginManager) { apply("com.android.library") apply("org.jetbrains.kotlin.android") } extensions.configure { configureKotlinAndroid(this) defaultConfig.targetSdk = 35 defaultConfig.testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" buildFeatures.buildConfig = false testOptions.animationsDisabled = true // The resource prefix is derived from the module name, // so resources inside ":core:module1" must be prefixed with "core_module1_" resourcePrefix = path.split("""\W""".toRegex()).drop(1).distinct().joinToString(separator = "_").lowercase() + "_" } dependencies { add("androidTestImplementation", kotlin("test")) add("testImplementation", kotlin("test")) } } } } ================================================ FILE: build-logic/convention/src/main/kotlin/AndroidLibraryPublishedConventionPlugin.kt ================================================ /* * Copyright 2024 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 * * 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. */ import me.tylerbwong.gradle.metalava.extension.MetalavaExtension import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.kotlin.dsl.apply import org.gradle.kotlin.dsl.configure class AndroidLibraryPublishedConventionPlugin : Plugin { override fun apply(target: Project) { with(target) { with(pluginManager) { apply(AndroidLintConventionPlugin::class) apply("me.tylerbwong.gradle.metalava") apply("org.jetbrains.dokka") apply("com.vanniktech.maven.publish") } extensions.configure { sourcePaths.setFrom("src/main") filename.set("api/current.api") reportLintsAsErrors.set(true) } } } } ================================================ FILE: build-logic/convention/src/main/kotlin/AndroidLintConventionPlugin.kt ================================================ /* * Copyright 2024 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 * * 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. */ import com.android.build.api.dsl.ApplicationExtension import com.android.build.api.dsl.LibraryExtension import com.android.build.api.dsl.Lint import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.kotlin.dsl.configure import java.io.File class AndroidLintConventionPlugin : Plugin { override fun apply(target: Project) { with(target) { when { pluginManager.hasPlugin("com.android.application") -> configure { lint(Lint::configure) } pluginManager.hasPlugin("com.android.library") -> configure { lint(Lint::configure) } else -> { pluginManager.apply("com.android.lint") configure(Lint::configure) } } } } } private fun Lint.configure() { textReport = true textOutput = File("stdout") // We run a full lint analysis as build part in CI, so skip vital checks for assemble tasks checkReleaseBuilds = false disable += setOf("GradleOverrides") } ================================================ FILE: build-logic/convention/src/main/kotlin/com/google/accompanist/AndroidCompose.kt ================================================ /* * Copyright 2024 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 * * 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. */ package com.google.accompanist import com.android.build.api.dsl.CommonExtension import org.gradle.api.Project import org.gradle.api.provider.Provider import org.gradle.kotlin.dsl.configure import org.jetbrains.kotlin.compose.compiler.gradle.ComposeCompilerGradlePluginExtension /** * Configure Compose-specific options */ internal fun Project.configureAndroidCompose( commonExtension: CommonExtension<*, *, *, *, *, *>, ) { commonExtension.apply { buildFeatures { compose = true } testOptions { unitTests { // For Robolectric isIncludeAndroidResources = true } } } extensions.configure { fun Provider.onlyIfTrue() = flatMap { provider { it.takeIf(String::toBoolean) } } fun Provider<*>.relativeToRootProject(dir: String) = flatMap { rootProject.layout.buildDirectory.dir(projectDir.toRelativeString(rootDir)) }.map { it.dir(dir) } project.providers.gradleProperty("enableComposeCompilerMetrics").onlyIfTrue() .relativeToRootProject("compose-metrics") .let(metricsDestination::set) project.providers.gradleProperty("enableComposeCompilerReports").onlyIfTrue() .relativeToRootProject("compose-reports") .let(reportsDestination::set) // We include source information to match how the main Compose libraries are shipped. // This allows accompanist to be displayed properly in the layout inspector and systrace includeSourceInformation.set(true) } } ================================================ FILE: build-logic/convention/src/main/kotlin/com/google/accompanist/BundleInsideHelper.kt ================================================ /* * Copyright 2024 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 * * 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. */ package com.google.accompanist import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar import com.github.jengelman.gradle.plugins.shadow.transformers.Transformer import com.github.jengelman.gradle.plugins.shadow.transformers.TransformerContext import org.apache.tools.zip.ZipOutputStream import org.gradle.api.Action import org.gradle.api.Project import org.gradle.api.artifacts.Configuration import org.gradle.api.attributes.Usage import org.gradle.api.file.FileTreeElement import org.gradle.api.tasks.Input import org.gradle.api.tasks.Optional import org.gradle.api.tasks.SourceSetContainer import org.gradle.api.tasks.TaskProvider import org.gradle.kotlin.dsl.named import org.gradle.kotlin.dsl.register /** * Originally from https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:buildSrc/public/src/main/kotlin/androidx/build/BundleInsideHelper.kt * Small modifications based on gradle version */ /** Allow java and Android libraries to bundle other projects inside the project jar/aar. */ object BundleInsideHelper { val CONFIGURATION_NAME = "bundleInside" val REPACKAGE_TASK_NAME = "repackageBundledJars" /** * Creates a configuration for the users to use that will be used to bundle these dependency * jars inside of libs/ directory inside of the aar. * * ``` * dependencies { * bundleInside(project(":foo")) * } * ``` * * Used project are expected * * @param relocations a list of package relocations to apply * @param dropResourcesWithSuffix used to drop Java resources if they match this suffix, null * means no filtering * @receiver the project that should bundle jars specified by this configuration * @see forInsideAar(String, String) */ @JvmStatic fun Project.forInsideAar(relocations: List?, dropResourcesWithSuffix: String?) { val bundle = createBundleConfiguration() val repackage = configureRepackageTaskForType(relocations, bundle, dropResourcesWithSuffix) // Add to AGP's configuration so this jar get packaged inside of the aar. dependencies.add("implementation", files(repackage.flatMap { it.archiveFile })) } /** * Creates 3 configurations for the users to use that will be used bundle these dependency jars * inside of libs/ directory inside of the aar. * * ``` * dependencies { * bundleInside(project(":foo")) * } * ``` * * Used project are expected * * @param from specifies from which package the rename should happen * @param to specifies to which package to put the renamed classes * @param dropResourcesWithSuffix used to drop Java resources if they match this suffix, null * means no filtering * @receiver the project that should bundle jars specified by these configurations */ @JvmStatic fun Project.forInsideAar(from: String, to: String, dropResourcesWithSuffix: String?) { forInsideAar(listOf(Relocation(from, to)), dropResourcesWithSuffix) } /** * Creates a configuration for users to use that will bundle the dependency jars inside of this * lint check's jar. This is required because lintPublish does not currently support * dependencies, so instead we need to bundle any dependencies with the lint jar manually. * (b/182319899) * * ``` * dependencies { * if (rootProject.hasProperty("android.injected.invoked.from.ide")) { * compileOnly(LINT_API_LATEST) * } else { * compileOnly(LINT_API_MIN) * } * compileOnly(KOTLIN_STDLIB) * // Include this library inside the resulting lint jar * bundleInside(project(":foo-lint-utils")) * } * ``` * * @receiver the project that should bundle jars specified by these configurations */ @JvmStatic fun Project.forInsideLintJar(): Configuration { val bundle = createBundleConfiguration() val compileOnly = configurations.getByName("compileOnly") val testImplementation = configurations.getByName("testImplementation") compileOnly.extendsFrom(bundle) testImplementation.extendsFrom(bundle) // Relocation needed to avoid classpath conflicts with Android Studio (b/337980250) // Can be removed if we migrate from using kotlin-metadata-jvm inside of lint checks val relocations = listOf(Relocation("kotlin.metadata", "androidx.lint.kotlin.metadata")) val repackage = configureRepackageTaskForType(relocations, bundle, null) val sourceSets = extensions.getByType(SourceSetContainer::class.java) repackage.configure { this.from(sourceSets.findByName("main")?.output) // kotlin-metadata-jvm has a service descriptor that needs transformation this.mergeServiceFiles() // Exclude Kotlin metadata files from kotlin-metadata-jvm this.exclude( "META-INF/kotlin-metadata-jvm.kotlin_module", "META-INF/kotlin-metadata.kotlin_module", "META-INF/metadata.jvm.kotlin_module", "META-INF/metadata.kotlin_module" ) } listOf("apiElements", "runtimeElements").forEach { config -> configurations.getByName(config).apply { outgoing.artifacts.clear() outgoing.artifact(repackage) } } return bundle } data class Relocation(val from: String, val to: String) private fun Project.configureRepackageTaskForType( relocations: List?, configuration: Configuration, dropResourcesWithSuffix: String? ): TaskProvider { val action = Action { configurations = listOf(configuration) if (relocations != null) { for (relocation in relocations) { println("Relocating ${relocation.from} to ${relocation.to}") relocate(relocation.from, relocation.to) } } val dontIncludeResourceTransformer = DontIncludeResourceTransformer() dontIncludeResourceTransformer.dropResourcesWithSuffix = dropResourcesWithSuffix transformers.add(dontIncludeResourceTransformer) archiveBaseName.set("repackaged") archiveVersion.set("") destinationDirectory.set(layout.buildDirectory.dir("repackaged")) } return tasks.register(REPACKAGE_TASK_NAME, ShadowJar::class.java, action) } private fun Project.createBundleConfiguration(): Configuration { val bundle = configurations.create(CONFIGURATION_NAME) { attributes { attribute(Usage.USAGE_ATTRIBUTE, objects.named(Usage.JAVA_RUNTIME)) } isCanBeConsumed = false } return bundle } class DontIncludeResourceTransformer : Transformer { @Optional @Input var dropResourcesWithSuffix: String? = null override fun getName(): String { return "DontIncludeResourceTransformer" } override fun canTransformResource(element: FileTreeElement?): Boolean { val path = element?.relativePath?.pathString return dropResourcesWithSuffix != null && (path?.endsWith(dropResourcesWithSuffix!!) == true) } override fun transform(context: TransformerContext?) { // no op } override fun hasTransformedResource(): Boolean { return true } override fun modifyOutputStream(zipOutputStream: ZipOutputStream?, b: Boolean) { // no op } } } ================================================ FILE: build-logic/convention/src/main/kotlin/com/google/accompanist/KotlinAndroid.kt ================================================ /* * Copyright 2024 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 * * 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. */ package com.google.accompanist import com.android.build.api.dsl.CommonExtension import org.gradle.api.JavaVersion import org.gradle.api.Project import org.gradle.api.plugins.JavaPluginExtension import org.gradle.kotlin.dsl.assign import org.gradle.kotlin.dsl.configure import org.gradle.kotlin.dsl.dependencies import org.gradle.kotlin.dsl.provideDelegate import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.dsl.KotlinAndroidProjectExtension import org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension import org.jetbrains.kotlin.gradle.dsl.KotlinTopLevelExtension /** * Configure base Kotlin with Android options */ internal fun Project.configureKotlinAndroid( commonExtension: CommonExtension<*, *, *, *, *, *>, ) { commonExtension.apply { compileSdk = 35 defaultConfig { minSdk = 21 } compileOptions { sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 } } configureKotlin() } /** * Configure base Kotlin options */ private inline fun Project.configureKotlin() = configure { // Treat all Kotlin warnings as errors (disabled by default) // Override by setting warningsAsErrors=true in your ~/.gradle/gradle.properties val warningsAsErrors: String? by project when (this) { is KotlinAndroidProjectExtension -> compilerOptions is KotlinJvmProjectExtension -> compilerOptions else -> TODO("Unsupported project extension $this ${T::class}") }.apply { jvmTarget = JvmTarget.JVM_1_8 allWarningsAsErrors = warningsAsErrors.toBoolean() explicitApi() } } ================================================ FILE: build-logic/convention/src/main/kotlin/com/google/accompanist/ProjectExtensions.kt ================================================ /* * Copyright 2024 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 * * 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. */ package com.google.accompanist import org.gradle.api.Project import org.gradle.api.artifacts.VersionCatalog import org.gradle.api.artifacts.VersionCatalogsExtension import org.gradle.kotlin.dsl.getByType val Project.libs get(): VersionCatalog = extensions.getByType().named("libs") ================================================ FILE: build-logic/gradle.properties ================================================ # Gradle properties are not passed to included builds https://github.com/gradle/gradle/issues/2534 org.gradle.parallel=true org.gradle.caching=true org.gradle.configureondemand=true ================================================ FILE: build-logic/settings.gradle.kts ================================================ /* * Copyright 2024 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 * * 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. */ dependencyResolutionManagement { repositories { google() mavenCentral() gradlePluginPortal() } versionCatalogs { create("libs") { from(files("../gradle/libs.versions.toml")) } } } rootProject.name = "build-logic" include(":convention") ================================================ FILE: build.gradle ================================================ /* * Copyright 2020 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 * * 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. */ import org.jetbrains.dokka.gradle.DokkaMultiModuleTask buildscript { repositories { google() mavenCentral() } dependencies { classpath libs.affectedmoduledetector } } plugins { id "com.diffplug.spotless" version "6.5.2" alias(libs.plugins.android.application) apply false alias(libs.plugins.android.library) apply false alias(libs.plugins.android.kotlin) apply false alias(libs.plugins.jetbrains.dokka) alias(libs.plugins.compose.plugin) apply false alias(libs.plugins.accompanist.android.library) apply false alias(libs.plugins.accompanist.android.library.compose) apply false alias(libs.plugins.accompanist.android.library.published) apply false alias(libs.plugins.accompanist.android.lint) apply false alias(libs.plugins.gradle.metalava) apply false alias(libs.plugins.vanniktech.maven.publish) apply false } apply plugin: 'com.dropbox.affectedmoduledetector' apply plugin: 'com.diffplug.spotless' tasks.withType(DokkaMultiModuleTask).configureEach { outputDirectory = rootProject.file('docs/api') failOnWarning = true } affectedModuleDetector { baseDir = "${project.rootDir}" pathsAffectingAllModules = [ "gradle/libs.versions.toml", ] excludedModules = [ "sample" ] logFilename = "output.log" logFolder = "${rootProject.buildDir}/affectedModuleDetector" String baseRef = findProperty("affected_base_ref") // If we have a base ref to diff against, extract the branch name and use it if (baseRef != null && !baseRef.isEmpty()) { // Remove the prefix from the head. // TODO: need to support other types of git refs specifiedBranch = baseRef.replace('refs/heads/', '') compareFrom = "SpecifiedBranchCommit" } else { // Otherwise we use the previous commit. This is mostly used for commits to main. compareFrom = "PreviousCommit" } } subprojects { apply plugin: 'com.diffplug.spotless' spotless { kotlin { target "**/*.kt" ktlint(libs.versions.ktlint.get()) licenseHeaderFile rootProject.file('spotless/copyright.txt') } groovyGradle { target '**/*.gradle' greclipse().configFile(rootProject.file('spotless/greclipse.properties')) licenseHeaderFile rootProject.file('spotless/copyright.txt'), '(buildscript|apply|import|plugins)' } } // Remove all test apps after running UI tests. // This is specially important in CI so that test emulators don't run out of space. tasks.whenTaskAdded { task -> if (task.name == 'connectedDebugAndroidTest') { task.finalizedBy 'uninstallDebugAndroidTest' } } configurations.configureEach { resolutionStrategy.eachDependency { DependencyResolveDetails details -> // Make sure that we're using the Android version of Guava if (details.requested.group == 'com.google.guava' && details.requested.module.name == 'guava' && details.requested.version.contains('jre')) { details.useVersion details.requested.version.replace('jre', 'android') } } } // Read in the signing.properties file if it is exists def signingPropsFile = rootProject.file('release/signing.properties') if (signingPropsFile.exists()) { def localProperties = new Properties() signingPropsFile.withInputStream { is -> localProperties.load(is) } localProperties.each { prop -> if (prop.key == "signing.secretKeyRingFile") { // If this is the key ring, treat it as a relative path project.ext.set(prop.key, rootProject.file(prop.value).absolutePath) } else { project.ext.set(prop.key, prop.value) } } } // Must be afterEvaluate or else com.vanniktech.maven.publish will overwrite our // dokka and version configuration. afterEvaluate { if (tasks.findByName('dokkaHtmlPartial') == null) { // If dokka isn't enabled on this module, skip return } tasks.named('dokkaHtmlPartial') { dokkaSourceSets.configureEach { reportUndocumented.set(true) skipEmptyPackages.set(true) skipDeprecated.set(true) jdkVersion.set(8) // Add Android SDK packages noAndroidSdkLink.set(false) // Add samples from :sample module samples.from(rootProject.file("sample/src/main/java/")) // AndroidX + Compose docs externalDocumentationLink { url.set(new URL("https://developer.android.com/reference/")) packageListUrl.set(new URL("https://developer.android.com/reference/androidx/package-list")) } externalDocumentationLink { url.set(new URL("https://developer.android.com/reference/kotlin/")) packageListUrl.set(new URL("https://developer.android.com/reference/kotlin/androidx/package-list")) } sourceLink { localDirectory.set(project.file("src/main/java")) // URL showing where the source code can be accessed through the web browser remoteUrl.set(new URL("https://github.com/google/accompanist/blob/main/${project.name}/src/main/java")) // Suffix which is used to append the line number to the URL. Use #L for GitHub remoteLineSuffix.set("#L") } } } } afterEvaluate { def composeSnapshot = libs.versions.composesnapshot.get() if (composeSnapshot.length() > 1) { // We're depending on a Jetpack Compose snapshot, update the library version name // to indicate it's from a Compose snapshot def versionName = project.properties.get('VERSION_NAME') if (versionName.contains("SNAPSHOT")) { version = versionName.replace('-SNAPSHOT', ".compose-${composeSnapshot}-SNAPSHOT") } } if (!version.endsWith('SNAPSHOT')) { // If we're not a SNAPSHOT library version, we fail the build if we're relying on // any snapshot dependencies configurations.configureEach { configuration -> configuration.dependencies.configureEach { dependency -> if (dependency instanceof ProjectDependency) { // We don't care about internal project dependencies return } def depVersion = dependency.version if (depVersion != null && depVersion.endsWith('SNAPSHOT')) { throw new IllegalArgumentException( "Using SNAPSHOT dependency with non-SNAPSHOT library version: $dependency" ) } } } } } } ================================================ FILE: checksum.sh ================================================ #!/bin/bash # Copyright 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 # # 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. RESULT_FILE=$1 if [ -f $RESULT_FILE ]; then rm $RESULT_FILE fi touch $RESULT_FILE checksum_file() { echo $(openssl md5 $1 | awk '{print $2}') } FILES=() while read -r -d ''; do FILES+=("$REPLY") done < <(find . -type f \( -name "build.gradle*" -o -name "*.versions.toml" -o -name "gradle-wrapper.properties" \) -print0) # Loop through files and append MD5 to result file for FILE in ${FILES[@]}; do echo $(checksum_file $FILE) >> $RESULT_FILE done # Now sort the file so that it is idempotent sort $RESULT_FILE -o $RESULT_FILE ================================================ FILE: docs/adaptive.md ================================================ # Adaptive utilities for Jetpack Compose [![Maven Central](https://img.shields.io/maven-central/v/com.google.accompanist/accompanist-adaptive)](https://search.maven.org/search?q=g:com.google.accompanist) A library providing a collection of utilities for adaptive layouts. ## calculateDisplayFeatures [`calculateDisplayFeatures(activity)`](../api/adaptive/com.google.accompanist.adaptive/calculate-display-features.html) returns the current list of `DisplayFeature`s, as reported by the [Jetpack WindowManager library](https://developer.android.com/jetpack/androidx/releases/window). These contain the list of folds (if any), and can be used to drive components like [`TwoPane`](#TwoPane). ## TwoPane [`TwoPane`](../api/adaptive/com.google.accompanist.adaptive/-two-pane.html) is a UI component that positions exactly two slots on the screen. The default positioning of these two slots is driven by a [`TwoPaneStrategy`](../api/adaptive/com.google.accompanist.adaptive/-two-pane-strategy.html), which can decide to orient the two slots side-by-side horizontally or vertically, and also configure the gap between them. The built-in [`HorizontalTwoPaneStrategy`](../api/adaptive/com.google.accompanist.adaptive/-horizontal-two-pane-strategy.html) and [`VerticalTwoPaneStrategy`](../api/adaptive/com.google.accompanist.adaptive/-vertical-two-pane-strategy.html) allow positioning the slots based on a fixed offset, or as some fraction of the space. [`TwoPane`](../api/adaptive/com.google.accompanist.adaptive/-two-pane.html) also requires a list of display features (to be retrieved with [`calculateDisplayFeatures`](#calculateDisplayFeatures)), and optionally a [`FoldAwareConfiguration`](../api/adaptive/com.google.accompanist.adaptive/-fold-aware-configuration.html) to determine which folds to handle automatically. When there is a fold that intersects with the [`TwoPane`](../api/adaptive/com.google.accompanist.adaptive/-two-pane.html) component that is obscuring or separating, the [`TwoPane`](../api/adaptive/com.google.accompanist.adaptive/-two-pane.html) will automatically place the slots to avoid the fold. When there is no fold, the default supplied strategy will be used instead. ## FoldAwareColumn [`FoldAwareColumn`](../api/adaptive/com.google.accompanist.adaptive/-fold-aware-column.html) is a simplified version of [Column](https://developer.android.com/reference/kotlin/androidx/compose/foundation/layout/package-summary#Column(androidx.compose.ui.Modifier,androidx.compose.foundation.layout.Arrangement.Vertical,androidx.compose.ui.Alignment.Horizontal,kotlin.Function1)) that places children in a fold-aware manner. [`FoldAwareColumn`](../api/adaptive/com.google.accompanist.adaptive/-fold-aware-column.html) requires a list of display features (to be retrieved with [`calculateDisplayFeatures`](#calculatedisplayfeatures)) to determine which folds to handle automatically. The built-in `foldPadding` parameter is zero, and the values of the vertical padding are used in the layout determine how much space should be left around a fold when placing children. When there is a horizontal fold that is obscuring or separating, the layout will begin placing children from the top of the available space. If a child is projected to overlap the fold, then its y-coordinate is increased so it will be placed fully below the fold, as will any other remaining children. When there is no fold, the children will be placed consecutively with no y-coordinate adjustments. Optionally, children can be modified with the `ignoreFold()` attribute, which means that they will be placed as if no fold is present even if they overlap a fold. ## Download [![Maven Central](https://img.shields.io/maven-central/v/com.google.accompanist/accompanist-adaptive)](https://search.maven.org/search?q=g:com.google.accompanist) ```groovy repositories { mavenCentral() } dependencies { implementation "com.google.accompanist:accompanist-adaptive:" } ``` ================================================ FILE: docs/appcompat-theme.md ================================================ # AppCompat Compose Theme Adapter [![Maven Central](https://img.shields.io/maven-central/v/com.google.accompanist/accompanist-appcompat-theme)](https://search.maven.org/search?q=g:com.google.accompanist) !!! warning **This library is deprecated in favor of the new [`themeadapter-appcompat`][themeadapterappcompatlib] artifact.** The migration guide and original documentation is below. ## Migration Accompanist AppCompat Theme Adapter has moved from the [`appcompat-theme`][appcompatthemelib] artifact to the [`themeadapter-appcompat`][themeadapterappcompatlib] artifact. The implementation is identical but the dependency and import package have changed. ### Migration steps 1. Change the dependency from `com.google.accompanist:accompanist-appcompat-theme:` to `com.google.accompanist:accompanist-themeadapter-appcompat:` 2. Change any `com.google.accompanist.appcompattheme.*` imports to `com.google.accompanist.themeadapter.appcompat.*` ## Original Docs A library that enables reuse of [AppCompat][appcompat] XML themes for theming in [Jetpack Compose][compose]. The basis of theming in [Jetpack Compose][compose] is the [`MaterialTheme`][materialtheme] composable, where you provide [`Colors`][colors], [`Shapes`][shapes] and [`Typography`][typography] instances containing your styling parameters: ``` kotlin MaterialTheme( typography = type, colors = colors, shapes = shapes ) { // Surface, Scaffold, etc } ``` [AppCompat][appcompat] XML themes allow for similar but coarser theming via XML theme attributes, like so: ``` xml ``` This library attempts to bridge the gap between [AppCompat][appcompat] XML themes, and themes in [Jetpack Compose][compose], allowing your composable [`MaterialTheme`][materialtheme] to be based on the `Activity`'s XML theme: ``` kotlin AppCompatTheme { // MaterialTheme.colors, MaterialTheme.shapes, MaterialTheme.typography // will now contain copies of the context's theme } ``` This is especially handy when you're migrating an existing app, a fragment (or other UI container) at a time. !!! caution If you are using [Material Design Components](https://material.io/develop/android/) in your app, you should use the [MDC Compose Theme Adapter](https://github.com/material-components/material-components-android-compose-theme-adapter) instead, as it allows much finer-grained reading of your theme. ### Customizing the theme The [`AppCompatTheme()`][appcompattheme] function will automatically read the host context's AppCompat theme and pass them to [`MaterialTheme`][materialtheme] on your behalf, but if you want to customize the generated values, you can do so via the [`createAppCompatTheme()`][createappcompattheme] function: ``` kotlin val context = LocalContext.current var (colors, type) = context.createAppCompatTheme() // Modify colors or type as required. Then pass them // through to MaterialTheme... MaterialTheme( colors = colors, typography = type ) { // rest of layout } ``` ## Generated theme Synthesizing a material theme from a `Theme.AppCompat` theme is not perfect, since `Theme.AppCompat` does not expose the same level of customization as is available in material theming. Going through the pillars of material theming: ### Colors AppCompat has a limited set of top-level color attributes, which means that [`AppCompatTheme()`][appcompattheme] has to generate/select alternative colors in certain situations. The mapping is currently: | MaterialTheme color | AppCompat attribute | |---------------------|-------------------------------------------------------| | primary | `colorPrimary` | | primaryVariant | `colorPrimaryDark` | | onPrimary | Calculated black/white | | secondary | `colorAccent` | | secondaryVariant | `colorAccent` | | onSecondary | Calculated black/white | | surface | Default | | onSurface | `android:textColorPrimary`, else calculated black/white | | background | `android:colorBackground` | | onBackground | `android:textColorPrimary`, else calculated black/white | | error | `colorError` | | onError | Calculated black/white | Where the table says "calculated black/white", this means either black/white, depending on which provides the greatest contrast against the corresponding background color. ### Typography AppCompat does not provide any semantic text appearances (such as headline6, body1, etc), and instead relies on text appearances for specific widgets or use cases. As such, the only thing we read from an AppCompat theme is the default `app:fontFamily` or `android:fontFamily`. For example: ``` xml ``` Compose does not currently support downloadable fonts, so any font referenced from the theme should from your resources. See [here](https://developer.android.com/guide/topics/resources/font-resource) for more information. ### Shape AppCompat has no concept of shape theming, therefore we use the default value from [`MaterialTheme.shapes`][shapes]. If you wish to provide custom values, use the `shapes` parameter on `AppCompatTheme`. ## Limitations There are some known limitations with the implementation at the moment: * This relies on your `Activity`/`Context` theme extending one of the `Theme.AppCompat` themes. * Variable fonts are not supported in Compose yet, meaning that the value of `android:fontVariationSettings` are currently ignored. * You can modify the resulting `MaterialTheme` in Compose as required, but this _only_ works in Compose. Any changes you make will not be reflected in the Activity theme. --- ## Usage [![Maven Central](https://img.shields.io/maven-central/v/com.google.accompanist/accompanist-appcompat-theme)](https://search.maven.org/search?q=g:com.google.accompanist) ``` groovy repositories { mavenCentral() } dependencies { implementation "com.google.accompanist:accompanist-appcompat-theme:" } ``` ### Library Snapshots Snapshots of the current development version of this library are available, which track the latest commit. See [here](../using-snapshot-version) for more information on how to use them. --- ## Contributions Please contribute! We will gladly review any pull requests. Make sure to read the [Contributing](../contributing) page first though. ## License ``` Copyright 2020 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 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. ``` [appcompatthemelib]: ../appcompat-theme [themeadapterappcompatlib]: ../themeadapter-appcompat [compose]: https://developer.android.com/jetpack/compose [appcompat]: https://developer.android.com/jetpack/androidx/releases/appcompat [appcompattheme]: ../api/appcompat-theme/appcompat-theme/com.google.accompanist.appcompattheme/-app-compat-theme.html [createappcompattheme]: ../api/appcompat-theme/appcompat-theme/com.google.accompanist.appcompattheme/create-app-compat-theme.html [materialtheme]: https://developer.android.com/reference/kotlin/androidx/compose/material/MaterialTheme [shapes]: https://developer.android.com/reference/kotlin/androidx/compose/material/Shapes [colors]: https://developer.android.com/reference/kotlin/androidx/compose/material/Colors [typography]: https://developer.android.com/reference/kotlin/androidx/compose/material/Typography ================================================ FILE: docs/drawablepainter.md ================================================ # Drawable Painter [![Maven Central](https://img.shields.io/maven-central/v/com.google.accompanist/accompanist-drawablepainter)](https://search.maven.org/search?q=g:com.google.accompanist) A library which provides a way to use Android [drawables](https://developer.android.com/guide/topics/resources/drawable-resource) as Jetpack Compose [Painters](https://developer.android.com/reference/kotlin/androidx/compose/ui/graphics/painter/Painter). This library attempts to support most Drawable configuration, as well as [Animatable](https://developer.android.com/reference/android/graphics/drawable/Animatable) drawables, such as [AnimatedVectorDrawable](https://developer.android.com/reference/android/graphics/drawable/AnimatedVectorDrawable). ## Usage ``` kotlin @Composable fun DrawDrawable() { val drawable = AppCompatResources.getDrawable(LocalContext.current, R.drawable.rectangle) Image( painter = rememberDrawablePainter(drawable = drawable), contentDescription = "content description", ) } ``` ## Download [![Maven Central](https://img.shields.io/maven-central/v/com.google.accompanist/accompanist-drawablepainter)](https://search.maven.org/search?q=g:com.google.accompanist) ```groovy repositories { mavenCentral() } dependencies { implementation "com.google.accompanist:accompanist-drawablepainter:" } ``` Snapshots of the development version are available in [Sonatype's `snapshots` repository][snap]. These are updated on every commit. [compose]: https://developer.android.com/jetpack/compose [snap]: https://oss.sonatype.org/content/repositories/snapshots/com/google/accompanist/accompanist-drawablepainter/ ================================================ FILE: docs/migration.md ================================================ # Migration from dev.chrisbanes.accompanist In March 2021, the Accompanist project moved from [github.com/chrisbanes/accompanist](https://github.com/chrisbanes/accompanist) to [github.com/google/accompanist](https://github.com/google/accompanist). At the same time we migrated the libraries over to a new package name and Maven group ID. As a summary: - All code was refactored from the `dev.chrisbanes.accompanist` root package to `com.google.accompanist` package. - The Maven group ID was changed from `dev.chrisbanes.accompanist` to `com.google.accompanist`. ## Semi-automatic migration... The following methods below are available for your information only, but may help if you need to migrate from the old package name. !!! warning Use these at your own risk, but they have worked on multiple projects from my testing. It's a good idea to make sure that you've made a backup or committed any changes before running these. ### Android Studio / IntelliJ You can use the [Replace in Path](https://www.jetbrains.com/help/idea/finding-and-replacing-text-in-project.html#replace_search_string_in_project) pane (⇧⌘R on Mac) in Android Studio to do a project-wide search and replace. ![Android Studio Replace in Path pane](studio.png) - Find query: `dev.chrisbanes.accompanist` - Replace string: `com.google.accompanist` - _Optional:_ Set the file mask to `*.kt` so that only Kotlin files are searched. Repeat for `*.gradle`. Similar can be achieved in [Visual Studio Code](https://code.visualstudio.com/docs/editor/codebasics#_search-across-files). Other IDEs / text editors are available. ### YOLO commands These commands while automatically replace any imports and Gradle dependencies for the project in the current directory. #### MacOS ``` bash find . -type f \( -name '*.kt' -or -name '*.gradle*' \) \ -exec sed -i '' 's/dev\.chrisbanes\.accompanist/com\.google\.accompanist/' {} \; ``` #### Linux ``` bash find . -type f \( -name '*.kt' -or -name '*.gradle*' \) \ -exec sed -i 's/dev\.chrisbanes\.accompanist/com\.google\.accompanist/' {} \; ``` ================================================ FILE: docs/navigation-animation.md ================================================ # Jetpack Navigation Compose Animation [![Maven Central](https://img.shields.io/maven-central/v/com.google.accompanist/accompanist-navigation-animation)](https://search.maven.org/search?q=g:com.google.accompanist) A library which provides [Compose Animation](https://developer.android.com/jetpack/compose/animation) support for [Jetpack Navigation Compose](https://developer.android.com/jetpack/compose/navigation). !!! warning **This library is deprecated, with official navigation-compose support in [androidx.navigation.compose](https://developer.android.com/jetpack/compose/navigation).** The original documentation is below the migration guide. ## Migration The official `androidx.navigation.compose` version 2.7.0-alpha01+ offers all of the same functionality as Accompanist Navigation Animation. 1. Make sure you are using Compose 1.5.0-beta01+ before migrating to `androidx.navigation.compose`. 2. Replace dependency `com.google.accompanist:accompanist-navigation-animation:` with `androidx.navigation:navigation-compose:` 3. Replace `rememberAnimatedNavController` with `rememberNavController` and change import to `androidx.navigation.compose.rememberNavController` 4. Replace `AnimatedNavHost` with `NavHost` and change import to `androidx.navigation.compose.NavHost` 5. Replace `AnimatedComposeNavigator` with `ComposeNavigator` and change import to `androidx.navigation.compose.ComposeNavigator` 6. Replace `AnimatedComposeNavigator()` constructor with `ComposeNavigator()` constructor 7. Replace `AnimatedComposeNavigator.Destination` with `ComposeNavigator.Destination` 8. Change import for composable from `com.google.accompanist.navigation.animation.composable` to `androidx.navigation.compose.composable` 9. Change import for navigation from `com.google.accompanist.navigation.animation.navigation` to `androidx.navigation.compose.navigation` ## Migration Table The following is a mapping of Navigation classes and functions from accompanist to androidx.compose: | accompanist navigation-animation | androidx.navigation.compose | |----------------------------------------|--------------------------------| | `AnimatedNavHost` | `NavHost` | | `AnimatedComposeNavigator` | `ComposeNavigator` | | `AnimatedComposeNavigator.Destination` | `ComposeNavigator.Destination` | | `rememberAnimatedNavController()` | `rememberNavController()` | | `NavGraphBuilder.composable()` | `NavGraphBuilder.composable()` | | `NavGraphBuilder.navigation()` | `NavGraphBuilder.navigation()` | Of note, ComposeNavigation.Destination allows use of `AnimatedContentScope` instead of just `AnimatedVisibilityScope`. # Deprecated Guidance for Accompanist Navigation The following is the deprecated guide for using Navigation in Accompanist. Please see above migration section for how to use the `androidx.navigation.compose` Navigation. ## Download [![Maven Central](https://img.shields.io/maven-central/v/com.google.accompanist/accompanist-navigation-animation)](https://search.maven.org/search?q=g:com.google.accompanist) ```groovy repositories { mavenCentral() } dependencies { implementation "com.google.accompanist:accompanist-navigation-animation:" } ``` Follow the steps below to either [add](#usage) Jetpack Navigation Compose to your app, or to [migrate](#migration) an existing Jetpack Navigation Compose implementation. ## Usage The `AnimatedNavHost` composable offers a way to add custom transitions to composables in Navigation Compose via parameters that can be attached to either an individual `composable` destination, a `navigation` element, or to the `AnimatedNavHost` itself. Each lambda has an [`AnimatedContentScope`](https://developer.android.com/reference/kotlin/androidx/compose/animation/AnimatedContentScope) receiver scope that allows you to use special transitions (such as [`slideIntoContainer`](https://developer.android.com/reference/kotlin/androidx/compose/animation/AnimatedContentScope#slideIntoContainer(androidx.compose.animation.AnimatedContentScope.SlideDirection,androidx.compose.animation.core.FiniteAnimationSpec,kotlin.Function1)) and [`slideOutOfContainer`](https://developer.android.com/reference/kotlin/androidx/compose/animation/AnimatedContentScope#slideOutOfContainer(androidx.compose.animation.AnimatedContentScope.SlideDirection,androidx.compose.animation.core.FiniteAnimationSpec,kotlin.Function1))) and gives you access to the [`initialState`](https://developer.android.com/reference/kotlin/androidx/compose/animation/AnimatedContentScope#initialState()) and [`targetState`](https://developer.android.com/reference/kotlin/androidx/compose/animation/AnimatedContentScope#targetState()) properties that let you customize what transitions are run based on what screen you are transitioning from (the `initialState`) and transitioning to (the `targetState`). - `enterTransition` controls what [`EnterTransition`](https://developer.android.com/reference/kotlin/androidx/compose/animation/EnterTransition.html) is run when the `targetState` `NavBackStackEntry` is appearing on the screen. - `exitTransition` controls what [`ExitTransition`](https://developer.android.com/reference/kotlin/androidx/compose/animation/ExitTransition) is run when the `initialState` `NavBackStackEntry` is disappearing from the screen. - `popEnterTransition` defaults to `enterTransition`, but can be overridden to provide a separate [`EnterTransition`](https://developer.android.com/reference/kotlin/androidx/compose/animation/EnterTransition.html) when the `targetState` `NavBackStackEntry` is appearing on the screen due to a pop operation (i.e., `popBackStack()`). - `popExitTransition` defaults to `exitTransition`, but can be overridden to provide a separate [`ExitTransition`](https://developer.android.com/reference/kotlin/androidx/compose/animation/ExitTransition) when the `initialState` `NavBackStackEntry` is disappearing from the screen due to a pop operation (i.e., `popBackStack()`). For each transition, if a `composable` destination returns `null`, the parent `navigation` element's transition will be used, thus allowing you to set a global set of transitions at the navigation graph level that will apply to every `composable` in that graph. This continues up the hierarchy until you reach the root `AnimatedNavHost`, which controls the global transitions for all destinations and nested graphs that do not specify one. Note: this means that if a destination wants to instantly jump cut between destinations, it should return [`EnterTransition.None`](https://developer.android.com/reference/kotlin/androidx/compose/animation/EnterTransition#None()) or [`ExitTransition.None`](https://developer.android.com/reference/kotlin/androidx/compose/animation/ExitTransition#None()) to signify that no transition should be run, rather than return `null`. ```kotlin @Composable private fun ExperimentalAnimationNav() { val navController = rememberAnimatedNavController() AnimatedNavHost(navController, startDestination = "Blue") { composable( "Blue", enterTransition = { when (initialState.destination.route) { "Red" -> slideIntoContainer(AnimatedContentScope.SlideDirection.Left, animationSpec = tween(700)) else -> null } }, exitTransition = { when (targetState.destination.route) { "Red" -> slideOutOfContainer(AnimatedContentScope.SlideDirection.Left, animationSpec = tween(700)) else -> null } }, popEnterTransition = { when (initialState.destination.route) { "Red" -> slideIntoContainer(AnimatedContentScope.SlideDirection.Right, animationSpec = tween(700)) else -> null } }, popExitTransition = { when (targetState.destination.route) { "Red" -> slideOutOfContainer(AnimatedContentScope.SlideDirection.Right, animationSpec = tween(700)) else -> null } } ) { BlueScreen(navController) } composable( "Red", enterTransition = { when (initialState.destination.route) { "Blue" -> slideIntoContainer(AnimatedContentScope.SlideDirection.Left, animationSpec = tween(700)) else -> null } }, exitTransition = { when (targetState.destination.route) { "Blue" -> slideOutOfContainer(AnimatedContentScope.SlideDirection.Left, animationSpec = tween(700)) else -> null } }, popEnterTransition = { when (initialState.destination.route) { "Blue" -> slideIntoContainer(AnimatedContentScope.SlideDirection.Right, animationSpec = tween(700)) else -> null } }, popExitTransition = { when (targetState.destination.route) { "Blue" -> slideOutOfContainer(AnimatedContentScope.SlideDirection.Right, animationSpec = tween(700)) else -> null } } ) { RedScreen(navController) } } } ``` For more examples, refer to the [samples](https://github.com/google/accompanist/tree/main/sample/src/main/java/com/google/accompanist/sample/navigation/animation). ## Migration To migrate from using the Navigation Compose APIs do the following: * Replace `rememberNavController()` with `rememberAnimatedNavController()` * Replace `NavHost` with `AnimatedNavHost` * Replace `import androidx.navigation.compose.navigation` with `import com.google.accompanist.navigation.animation.navigation` * Replace `import androidx.navigation.compose.composable` with `import com.google.accompanist.navigation.animation.composable` Snapshots of the development version are available in [Sonatype's `snapshots` repository][snap]. These are updated on every commit. [compose]: https://developer.android.com/jetpack/compose [snap]: https://oss.sonatype.org/content/repositories/snapshots/com/google/accompanist/accompanist-navigation-animation/ For more details see [Animations in Navigation Compose](https://medium.com/androiddevelopers/animations-in-navigation-compose-36d48870776b) ================================================ FILE: docs/navigation-material.md ================================================ # Jetpack Navigation Compose Material [![Maven Central](https://img.shields.io/maven-central/v/com.google.accompanist/accompanist-navigation-material)](https://search.maven.org/search?q=g:com.google.accompanist) A library which provides [Compose Material](https://developer.android.com/jetpack/androidx/releases/compose-material) support for [Jetpack Navigation Compose](https://developer.android.com/jetpack/compose/navigation). This features composable bottom sheet destinations. !!! warning **This library is deprecated, with official material-navigation support in [androidx.compose.material.navigation](https://developer.android.com/jetpack/androidx/releases/compose-material#1.7.0-alpha04).** The original documentation is below the migration guide. ## Migration The official `androidx.compose.material.navigation` version 1.7.0-alpha04+ offers all of the same functionality as Accompanist Navigation Material. All class names are the same, the only needed changes are import related. 1. Replace dependency `com.google.accompanist:accompanist-navigation-material:` with `androidx.compose.material:material-navigation:` 2. Change import for ModalBottomSheetLayout from `com.google.accompanist.navigation.material.ModalBottomSheetLayout` to `androidx.compose.material.navigation.ModalBottomSheetLayout` 3. Change import for bottomSheet from `com.google.accompanist.navigation.material.bottomSheet` to `androidx.compose.material.navigation.bottomSheet` 4. Change import for rememberBottomSheetNavigator from `com.google.accompanist.navigation.material.rememberBottomSheetNavigator` to `androidx.compose.material.navigation.rememberBottomSheetNavigator` 5. Change import for BottomSheetNavigator from `com.google.accompanist.navigation.material.BottomSheetNavigator` to `androidx.compose.material.navigation.BottomSheetNavigator` 6. Change import for BottomSheetNavigatorSheetState from `com.google.accompanist.navigation.material.BottomSheetNavigatorSheetState` to `androidx.compose.material.navigation.BottomSheetNavigatorSheetState` # Deprecated Guidance for Accompanist Navigation Material The following is the deprecated guide for using Navigation Material in Accompanist. Please see above migration section for how to use the `androidx.compose.material.navigation` Material Navigation. ## Usage ### Bottom Sheet Destinations 1. Create a `BottomSheetNavigator` and add it to the `NavController`: ```kotlin @Composable fun MyApp() { val bottomSheetNavigator = rememberBottomSheetNavigator() val navController = rememberNavController(bottomSheetNavigator) } ``` 2. Wrap your `NavHost` in the `ModalBottomSheetLayout` composable that accepts a `BottomSheetNavigator`. ```kotlin @Composable fun MyApp() { val bottomSheetNavigator = rememberBottomSheetNavigator() val navController = rememberNavController(bottomSheetNavigator) ModalBottomSheetLayout(bottomSheetNavigator) { NavHost(navController, "home") { // We'll define our graph here in a bit! } } } ``` 3. Register a bottom sheet destination ```kotlin @Composable fun MyApp() { val bottomSheetNavigator = rememberBottomSheetNavigator() val navController = rememberNavController(bottomSheetNavigator) ModalBottomSheetLayout(bottomSheetNavigator) { NavHost(navController, "home") { composable(route = "home") { ... } bottomSheet(route = "sheet") { Text("This is a cool bottom sheet!") } } } } ``` For more examples, refer to the [samples](https://github.com/google/accompanist/tree/main/sample/src/main/java/com/google/accompanist/sample/navigation/material). ## Download [![Maven Central](https://img.shields.io/maven-central/v/com.google.accompanist/accompanist-navigation-material)](https://search.maven.org/search?q=g:com.google.accompanist) ```groovy repositories { mavenCentral() } dependencies { implementation "com.google.accompanist:accompanist-navigation-material:" } ``` Snapshots of the development version are available in [Sonatype's `snapshots` repository][snap]. These are updated on every commit. [compose]: https://developer.android.com/jetpack/compose [snap]: https://oss.sonatype.org/content/repositories/snapshots/com/google/accompanist/accompanist-navigation-material/ ================================================ FILE: docs/permissions.md ================================================ # Jetpack Compose Permissions [![Maven Central](https://img.shields.io/maven-central/v/com.google.accompanist/accompanist-permissions)](https://search.maven.org/search?q=g:com.google.accompanist) A library which provides [Android runtime permissions](https://developer.android.com/guide/topics/permissions/overview) support for Jetpack Compose. !!! warning The permission APIs are currently experimental and they could change at any time. All of the APIs are marked with the `@ExperimentalPermissionsApi` annotation. ## Usage ### `rememberPermissionState` and `rememberMultiplePermissionsState` APIs The `rememberPermissionState(permission: String)` API allows you to request a certain permission to the user and check for the status of the permission. `rememberMultiplePermissionsState(permissions: List)` offers the same but for multiple permissions at the same time. Both APIs expose properties for you to follow the workflow as described in the [permissions documentation](https://developer.android.com/training/permissions/requesting#workflow_for_requesting_permissions). !!! caution The call to the method that requests the permission to the user (e.g. `PermissionState.launchPermissionRequest()`) needs to be invoked from a non-composable scope. For example, from a side-effect or from a non-composable callback such as a `Button`'s `onClick` lambda. The following code exercises the [permission request workflow](https://developer.android.com/training/permissions/requesting#workflow_for_requesting_permissions). ```kotlin @OptIn(ExperimentalPermissionsApi::class) @Composable private fun FeatureThatRequiresCameraPermission() { // Camera permission state val cameraPermissionState = rememberPermissionState( android.Manifest.permission.CAMERA ) if (cameraPermissionState.status.isGranted) { Text("Camera permission Granted") } else { Column { val textToShow = if (cameraPermissionState.status.shouldShowRationale) { // If the user has denied the permission but the rationale can be shown, // then gently explain why the app requires this permission "The camera is important for this app. Please grant the permission." } else { // If it's the first time the user lands on this feature, or the user // doesn't want to be asked again for this permission, explain that the // permission is required "Camera permission required for this feature to be available. " + "Please grant the permission" } Text(textToShow) Button(onClick = { cameraPermissionState.launchPermissionRequest() }) { Text("Request permission") } } } } ``` For more examples, refer to the [samples](https://github.com/google/accompanist/tree/main/sample/src/main/java/com/google/accompanist/sample/permissions). ## Limitations This permissions wrapper is built on top of the available Android platform APIs. We cannot extend the platform's capabilities. For example, it's not possible to differentiate between the _it's the first time requesting the permission_ vs _the user doesn't want to be asked again_ use cases. ## Download [![Maven Central](https://img.shields.io/maven-central/v/com.google.accompanist/accompanist-permissions)](https://search.maven.org/search?q=g:com.google.accompanist) ```groovy repositories { mavenCentral() } dependencies { implementation "com.google.accompanist:accompanist-permissions:" } ``` Snapshots of the development version are available in [Sonatype's `snapshots` repository][snap]. These are updated on every commit. [compose]: https://developer.android.com/jetpack/compose [snap]: https://oss.sonatype.org/content/repositories/snapshots/com/google/accompanist/accompanist-permissions/ ================================================ FILE: docs/systemuicontroller.md ================================================ # System UI Controller for Jetpack Compose [![Maven Central](https://img.shields.io/maven-central/v/com.google.accompanist/accompanist-systemuicontroller)](https://search.maven.org/search?q=g:com.google.accompanist) !!! warning **This library is deprecated, and the API is no longer maintained. We recommend forking the implementation and customising it to your needs.** The original documentation is below. ## Migration Recommendation: If you were using SystemUIController to go edge-to-edge in your activity and change the system bar colors and system bar icon colors, use the new [Activity.enableEdgeToEdge](https://developer.android.com/reference/androidx/activity/ComponentActivity#(androidx.activity.ComponentActivity).enableEdgeToEdge(androidx.activity.SystemBarStyle,androidx.activity.SystemBarStyle)) method available in androidx.activity 1.8.0-alpha03 and later. This method backports the scrims used on some versions of Android. [This](https://github.com/android/nowinandroid/pull/817) is a sample PR of the migration to the new method and removing the dependency on SystemUIController in Now in Android. For other usages, migrate to using WindowInsetsControllerCompat or window APIs directly. ## Original Documentation System UI Controller provides easy-to-use utilities for updating the System UI bar colors within Jetpack Compose. ## Usage To control the system UI in your composables, you need to get a [`SystemUiController`](../api/systemuicontroller/systemuicontroller/com.google.accompanist.systemuicontroller/-system-ui-controller/) instance. The library provides the [`rememberSystemUiController()`](../api/systemuicontroller/systemuicontroller/com.google.accompanist.systemuicontroller/remember-system-ui-controller.html) function which returns an instance for the current system (currently only Android). In your layouts you can update the system bar colors like so: ``` kotlin // Remember a SystemUiController val systemUiController = rememberSystemUiController() val useDarkIcons = !isSystemInDarkTheme() DisposableEffect(systemUiController, useDarkIcons) { // Update all of the system bar colors to be transparent, and use // dark icons if we're in light theme systemUiController.setSystemBarsColor( color = Color.Transparent, darkIcons = useDarkIcons ) // setStatusBarColor() and setNavigationBarColor() also exist onDispose {} } ``` ## System bar icon colors The library automatically handles API level differences when running on Android devices. If we look at the example of status bar icons, Android only natively supports dark icons on API 23+. This library handles this by automatically altering the requested color with a scrim, to maintain contrast: ![](api-scrim.png) Similar happens on navigation bar color, which is only available on API 26+. ### Modifying scrim logic The scrim logic can be modified if needed: ``` kotlin systemUiController.setStatusBarColor( color = Color.Transparent, darkIcons = true ) { requestedColor -> // TODO: return a darkened color to be used when the system doesn't // natively support dark icons } ``` ## Samples For complete samples, check out the [Insets samples](https://github.com/google/accompanist/tree/main/sample/src/main/java/com/google/accompanist/sample/insets) which all use `SystemUiController` to set transparent system bars. ## Download [![Maven Central](https://img.shields.io/maven-central/v/com.google.accompanist/accompanist-systemuicontroller)](https://search.maven.org/search?q=g:com.google.accompanist) ```groovy repositories { mavenCentral() } dependencies { implementation "com.google.accompanist:accompanist-systemuicontroller:" } ``` Snapshots of the development version are available in [Sonatype's `snapshots` repository][snap]. These are updated on every commit. [compose]: https://developer.android.com/jetpack/compose [snap]: https://oss.sonatype.org/content/repositories/snapshots/com/google/accompanist/accompanist-systemuicontroller/ ================================================ FILE: docs/updating.md ================================================ # Updating & releasing Accompanist This doc is mostly for maintainers. ## New features & bugfixes All new features should be uploaded as PRs against the `main` branch. Once merged into `main`, they will be automatically merged into the `snapshot` branch. ## Jetpack Compose Snapshots We publish snapshot versions of Accompanist, which depend on a `SNAPSHOT` versions of Jetpack Compose. These are built from the `snapshot` branch. ### Updating to a newer Compose snapshot As mentioned above, updating to a new Compose snapshot is done by submitting a new PR against the `snapshot` branch: ``` sh git checkout snapshot && git pull # Create branch for PR git checkout -b update_snapshot ``` Now edit the project to depend on the new Compose SNAPSHOT version: Edit [`/gradle/libs.versions.toml`](https://github.com/google/accompanist/blob/main/gradle/libs.versions.toml): Under `[versions]`: 1. Update the `composesnapshot` property to be the snapshot number 2. Ensure that the `compose` property is correct Make sure the project builds and test pass: ``` ./gradlew check ``` Now `git commit` the changes and push to GitHub. Finally create a PR (with the base branch as `snapshot`) and send for review. ## Releasing Once the next Jetpack Compose version is out, we're ready to push a new release: ### #1: Merge `snapshot` into `main` First we merge the `snapshot` branch into `main`: ``` sh git checkout snapshot && git pull git checkout main && git pull # Create branch for PR git checkout -b main_snapshot_merge # Merge in the snapshot branch git merge snapshot ``` ### #2: Update dependencies Edit [`/gradle/libs.versions.toml`](https://github.com/google/accompanist/blob/main/gradle/libs.versions.toml): Under `[versions]`: 1. Update the `composesnapshot` property to a single character (usually `-`). This disables the snapshot repository. 2. Update the `compose` property to match the new release (i.e. `1.0.0-beta06`) Make sure the project builds and test pass: ``` ./gradlew check ``` Commit the changes. ### #3: Bump the version number Edit [gradle.properties](https://github.com/google/accompanist/blob/main/gradle.properties): * Update the `VERSION_NAME` property and remove the `-SNAPSHOT` suffix. Commit the changes, using the commit message containing the new version name. ### #4: Push to GitHub Push the branch to GitHub and create a PR against the `main` branch, and send for review. Once approved and merged, it will be automatically deployed to Maven Central. ### #5: Create release Once the above PR has been approved and merged, we need to create the GitHub release: * Open up the [Releases](https://github.com/google/accompanist/releases) page. * At the top you should see a 'Draft' release, auto populated with any PRs since the last release. Click 'Edit'. * Make sure that the version number matches what we released (the tool guesses but is not always correct). * Double check everything, then press 'Publish release'. At this point the release is published. This will trigger the docs action to run, which will auto-deploy a new version of the [website](https://google.github.io/accompanist/). ### #6: Prepare the next development version The current release is now finished, but we need to update the version for the next development version: Edit [gradle.properties](https://github.com/google/accompanist/blob/main/gradle.properties): * Update the `VERSION_NAME` property, by increasing the version number, and adding the `-SNAPSHOT` suffix. * Example: released version: `0.3.0`. Update to `0.3.1-SNAPSHOT` `git commit` and push to `main`. Finally, merge all of these changes back to `snapshot`: ``` git checkout snapshot && git pull git merge main git push ``` ================================================ FILE: docs/using-snapshot-version.md ================================================ # Using a Snapshot Version of the Library If you would like to depend on the cutting edge version of the Accompanist library, you can use the [snapshot versions][snap] that are published to [Sonatype OSSRH](https://central.sonatype.org/)'s snapshot repository. These are updated on every commit to `main`. To do so: ```groovy repositories { // ... maven { url 'https://oss.sonatype.org/content/repositories/snapshots' } } dependencies { // Check the latest SNAPSHOT version from the link above classpath 'com.google.accompanist:accompanist-coil:XXX-SNAPSHOT' } ``` You might see a number of different versioned snapshots. If we use an example: * `0.3.0-SNAPSHOT` is a build from the `main` branch, and depends on the latest tagged Jetpack Compose release (i.e. [alpha03](https://developer.android.com/jetpack/androidx/releases/compose#1.0.0-alpha03)). * `0.3.0.compose-6574163-SNAPSHOT` is a build from the `snapshot` branch. This depends on the [SNAPSHOT build](https://androidx.dev) of Jetpack Compose from build `6574163`. You should only use these if you are using Jetpack Compose snapshot versions (see below). ### Using Jetpack Compose Snapshots If you're using [`SNAPSHOT`](https://androidx.dev) versions of the `androidx.compose` libraries, you might run into issues with the current stable Accompanist release forcing an older version of those libraries. We publish snapshot versions of Accompanist which depend on recent Jetpack Compose SNAPSHOT repositories. To find a recent build, look through the [snapshot repository][snap] for any versions in the scheme `x.x.x.compose-YYYY-SNAPSHOT` (for example: `0.3.0.compose-6574163-SNAPSHOT`). The `YYYY` in the scheme is the snapshot build being used from [AndroidX](https://androidx.dev) (from the example: build [`6574163`](https://androidx.dev/snapshots/builds/6574163/artifacts)). You can then use it like so: ``` groovy repositories { // ... maven { url 'https://oss.sonatype.org/content/repositories/snapshots' } } dependencies { // Check the latest SNAPSHOT version from the link above classpath 'com.google.accompanist:accompanist-coil:XXXX.compose-YYYYY-SNAPSHOT' } ``` These builds are updated regularly, but there's no guarantee that we will create one for a given snapshot number. *Note:* you might also see versions in the scheme `x.x.x.ui-YYYY-SNAPSHOT`. These are the same, just using an older suffix. [snap]: https://oss.sonatype.org/content/repositories/snapshots/com/google/accompanist/ ================================================ FILE: docs/web.md ================================================ # WebView wrapper for Jetpack Compose [![Maven Central](https://img.shields.io/maven-central/v/com.google.accompanist/accompanist-webview)](https://search.maven.org/search?q=g:com.google.accompanist) A library which provides a Jetpack Compose wrapper around Android's WebView. !!! warning **This library is deprecated, and the API is no longer maintained. We recommend forking the implementation and customising it to your needs.** The original documentation is below. ## Usage To implement this wrapper there are two key APIs which are needed: [`WebView`](../api/web/com.google.accompanist.web/-web-view.html), which is provides the layout, and [`rememberWebViewState(url)`](../api/web/com.google.accompanist.web/remember-web-view-state.html) which provides some remembered state including the URL to display. The basic usage is as follows: ```kotlin val state = rememberWebViewState("https://example.com") WebView( state ) ``` This will display a WebView in your Compose layout that shows the URL provided. There is a larger sample in the sample app which can be found [here](https://github.com/google/accompanist/blob/main/sample/src/main/java/com/google/accompanist/sample/webview/BasicWebViewSample.kt). This sample also shows how to show a loading state. ### WebView settings including JavaScript By default, JavaScript is disabled in the WebView. To enable it or any other settings you can use the `onCreated` callback. ```kotlin WebView( state = webViewState, onCreated = { it.settings.javaScriptEnabled = true } ) ``` ### Capturing back presses By default the WebView will capture back presses/swipes when relevant and navigate the WebView back. This can be disabled via the parameter on the Composable. ```kotlin WebView( ... captureBackPresses = false ) ``` ### Using a subclass of WebView If you want to use a subclass of `WebView`, or simply require more control over its instantiation, you can provide a factory. ```kotlin WebView( ... factory = { context -> CustomWebView(context) } ) ``` ## Download [![Maven Central](https://img.shields.io/maven-central/v/com.google.accompanist/accompanist-webview)](https://search.maven.org/search?q=g:com.google.accompanist) ```groovy repositories { mavenCentral() } dependencies { implementation "com.google.accompanist:accompanist-webview:" } ``` ================================================ FILE: drawablepainter/README.md ================================================ # Accompanist Drawable Painter [![Maven Central](https://maven-badges.herokuapp.com/maven-central/com.google.accompanist/accompanist-imageloading-core/badge.svg)](https://search.maven.org/search?q=g:com.google.accompanist) For more information, visit the documentation: https://google.github.io/accompanist/drawablepainter ## Download ```groovy repositories { mavenCentral() } dependencies { implementation "com.google.accompanist:accompanist-drawablepainter:" } ``` ================================================ FILE: drawablepainter/api/current.api ================================================ // Signature format: 4.0 package com.google.accompanist.drawablepainter { public final class DrawablePainter extends androidx.compose.ui.graphics.painter.Painter implements androidx.compose.runtime.RememberObserver { ctor public DrawablePainter(android.graphics.drawable.Drawable drawable); method public android.graphics.drawable.Drawable getDrawable(); method public long getIntrinsicSize(); method public void onAbandoned(); method protected void onDraw(androidx.compose.ui.graphics.drawscope.DrawScope); method public void onForgotten(); method public void onRemembered(); property public final android.graphics.drawable.Drawable drawable; property public long intrinsicSize; } public final class DrawablePainterKt { method @androidx.compose.runtime.Composable public static androidx.compose.ui.graphics.painter.Painter rememberDrawablePainter(android.graphics.drawable.Drawable? drawable); } } ================================================ FILE: drawablepainter/build.gradle.kts ================================================ /* * Copyright 2023 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 * * 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. */ @file:Suppress("UnstableApiUsage") plugins { alias(libs.plugins.accompanist.android.library) alias(libs.plugins.accompanist.android.library.compose) alias(libs.plugins.accompanist.android.library.published) } android { namespace = "com.google.accompanist.drawablepainter" } dependencies { implementation(libs.compose.ui.ui) implementation(libs.kotlin.coroutines.android) } ================================================ FILE: drawablepainter/gradle.properties ================================================ POM_ARTIFACT_ID=accompanist-drawablepainter POM_NAME=Accompanist Drawable Painter library POM_PACKAGING=aar ================================================ FILE: drawablepainter/src/main/AndroidManifest.xml ================================================ ================================================ FILE: drawablepainter/src/main/java/com/google/accompanist/drawablepainter/DrawablePainter.kt ================================================ /* * Copyright 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 * * 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. */ package com.google.accompanist.drawablepainter import android.graphics.drawable.Animatable import android.graphics.drawable.AnimatedImageDrawable import android.graphics.drawable.ColorDrawable import android.graphics.drawable.Drawable import android.os.Build import android.os.Handler import android.os.Looper import android.view.View import androidx.compose.runtime.Composable import androidx.compose.runtime.RememberObserver import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.asAndroidColorFilter import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.drawIntoCanvas import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.graphics.painter.ColorPainter import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.withSave import androidx.compose.ui.unit.LayoutDirection import kotlin.math.roundToInt private val MAIN_HANDLER by lazy(LazyThreadSafetyMode.NONE) { Handler(Looper.getMainLooper()) } /** * A [Painter] which draws an Android [Drawable] and supports [Animatable] drawables. Instances * should be remembered to be able to start and stop [Animatable] animations. * * Instances are usually retrieved from [rememberDrawablePainter]. */ public class DrawablePainter( public val drawable: Drawable ) : Painter(), RememberObserver { private var drawInvalidateTick by mutableStateOf(0) private var drawableIntrinsicSize by mutableStateOf(drawable.intrinsicSize) private val callback: Drawable.Callback by lazy { object : Drawable.Callback { override fun invalidateDrawable(d: Drawable) { // Update the tick so that we get re-drawn drawInvalidateTick++ // Update our intrinsic size too drawableIntrinsicSize = drawable.intrinsicSize } override fun scheduleDrawable(d: Drawable, what: Runnable, time: Long) { MAIN_HANDLER.postAtTime(what, time) } override fun unscheduleDrawable(d: Drawable, what: Runnable) { MAIN_HANDLER.removeCallbacks(what) } } } init { if (drawable.intrinsicWidth >= 0 && drawable.intrinsicHeight >= 0) { // Update the drawable's bounds to match the intrinsic size drawable.setBounds(0, 0, drawable.intrinsicWidth, drawable.intrinsicHeight) } } override fun onRemembered() { drawable.callback = callback drawable.setVisible(true, true) if (drawable is Animatable) drawable.start() } override fun onAbandoned(): Unit = onForgotten() override fun onForgotten() { if (drawable is Animatable) drawable.stop() drawable.setVisible(false, false) drawable.callback = null } override fun applyAlpha(alpha: Float): Boolean { drawable.alpha = (alpha * 255).roundToInt().coerceIn(0, 255) return true } override fun applyColorFilter(colorFilter: ColorFilter?): Boolean { drawable.colorFilter = colorFilter?.asAndroidColorFilter() return true } override fun applyLayoutDirection(layoutDirection: LayoutDirection): Boolean { if (Build.VERSION.SDK_INT >= 23) { return drawable.setLayoutDirection( when (layoutDirection) { LayoutDirection.Ltr -> View.LAYOUT_DIRECTION_LTR LayoutDirection.Rtl -> View.LAYOUT_DIRECTION_RTL } ) } return false } override val intrinsicSize: Size get() = drawableIntrinsicSize override fun DrawScope.onDraw() { drawIntoCanvas { canvas -> // Reading this ensures that we invalidate when invalidateDrawable() is called drawInvalidateTick canvas.withSave { // AnimatedImageDrawable is not respecting the bounds below Android 12, so this is // a workaround to make the render size correct in this specific case if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && Build.VERSION.SDK_INT < Build.VERSION_CODES.S && drawable is AnimatedImageDrawable ) { canvas.scale( size.width / intrinsicSize.width, size.height / intrinsicSize.height ) } else { // Update the Drawable's bounds drawable.setBounds(0, 0, size.width.roundToInt(), size.height.roundToInt()) } drawable.draw(canvas.nativeCanvas) } } } } /** * Remembers [Drawable] wrapped up as a [Painter]. This function attempts to un-wrap the * drawable contents and use Compose primitives where possible. * * If the provided [drawable] is `null`, an empty no-op painter is returned. * * This function tries to dispatch lifecycle events to [drawable] as much as possible from * within Compose. * * @sample com.google.accompanist.sample.drawablepainter.BasicSample */ @Composable public fun rememberDrawablePainter(drawable: Drawable?): Painter = remember(drawable) { when (drawable) { null -> EmptyPainter is ColorDrawable -> ColorPainter(Color(drawable.color)) // Since the DrawablePainter will be remembered and it implements RememberObserver, it // will receive the necessary events else -> DrawablePainter(drawable.mutate()) } } private val Drawable.intrinsicSize: Size get() = when { // Only return a finite size if the drawable has an intrinsic size intrinsicWidth >= 0 && intrinsicHeight >= 0 -> { Size(width = intrinsicWidth.toFloat(), height = intrinsicHeight.toFloat()) } else -> Size.Unspecified } internal object EmptyPainter : Painter() { override val intrinsicSize: Size get() = Size.Unspecified override fun DrawScope.onDraw() {} } ================================================ FILE: generate_docs.sh ================================================ #!/bin/bash # Copyright 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 # # 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. # Fail on any error set -ex DOCS_ROOT=docs-gen [ -d $DOCS_ROOT ] && rm -r $DOCS_ROOT mkdir $DOCS_ROOT # Clear out the old API docs [ -d docs/api ] && rm -r docs/api # Build the docs with dokka ./gradlew dokkaHtmlMultiModule --stacktrace # Create a copy of our docs at our $DOCS_ROOT cp -a docs/* $DOCS_ROOT cp README.md $DOCS_ROOT/index.md cp CONTRIBUTING.md $DOCS_ROOT/contributing.md sed -i.bak 's/CONTRIBUTING.md/contributing/' $DOCS_ROOT/index.md sed -i.bak 's/README.md//' $DOCS_ROOT/index.md sed -i.bak 's/docs\/header.png/header.png/' $DOCS_ROOT/index.md # Convert docs/xxx.md links to just xxx/ sed -i.bak 's/docs\/\([a-zA-Z-]*\).md/\1/' $DOCS_ROOT/index.md # Finally delete all of the backup files find . -name '*.bak' -delete ================================================ FILE: gradle/libs.versions.toml ================================================ [versions] compose = "1.7.0" composeMaterial3 = "1.0.1" composesnapshot = "-" # a single character = no snapshot desugar_jdk_libs = "2.1.3" dokka = "1.8.10" # gradlePlugin and lint need to be updated together androidTools = "31.7.2" gradlePlugin = "8.7.3" lintMinCompose = "30.0.0" ktlint = "0.45.2" kotlin = "2.0.20" coroutines = "1.6.4" coil = "1.3.2" androidlint = "25.3.0" androidxtest = "1.6.1" androidxnavigation = "2.7.7" androidxWindow = "1.0.0" metalava = "0.3.5" vanniktechPublish = "0.30.0" [libraries] compose-ui-ui = { module = "androidx.compose.ui:ui", version.ref = "compose" } compose-ui-util = { module = "androidx.compose.ui:ui-util", version.ref = "compose" } compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" } compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "compose" } compose-ui-test = { module = "androidx.compose.ui:ui-test", version.ref = "compose" } compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "compose" } compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest", version.ref = "compose" } compose-foundation-foundation = { module = "androidx.compose.foundation:foundation", version.ref = "compose" } compose-foundation-layout = { module = "androidx.compose.foundation:foundation-layout", version.ref = "compose" } compose-material-material = { module = "androidx.compose.material:material", version.ref = "compose" } compose-material-iconsext = { module = "androidx.compose.material:material-icons-extended", version.ref = "compose" } compose-material3-material3 = { module = "androidx.compose.material3:material3", version.ref = "composeMaterial3" } compose-animation-animation = { module = "androidx.compose.animation:animation", version.ref = "compose" } compose-gradlePlugin = { module = "org.jetbrains.kotlin:compose-compiler-gradle-plugin", version.ref = "kotlin" } apacheAnt = { module = "org.apache.ant:ant", version = "1.10.11" } android-gradlePlugin = { module = "com.android.tools.build:gradle", version.ref = "gradlePlugin" } desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" } gradleMavenPublishPlugin = { module = "com.vanniktech:gradle-maven-publish-plugin", version.ref = "vanniktechPublish" } metalavaGradle = { module = "me.tylerbwong.gradle.metalava:plugin", version.ref = "metalava" } shadow = { module = "com.gradleup.shadow:shadow-gradle-plugin", version = "8.3.3" } kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } kotlin-stdlibJdk8 = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk8", version.ref = "kotlin" } kotlin-gradlePlugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" } kotlin-metadataJvm = { module = "org.jetbrains.kotlin:kotlin-metadata-jvm", version.ref = "kotlin" } kotlin-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } coil-gif = { module = "io.coil-kt:coil-gif", version.ref = "coil" } coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" } androidx-appcompat = "androidx.appcompat:appcompat:1.4.2" androidx-core = "androidx.core:core-ktx:1.8.0" androidx-activity-compose = "androidx.activity:activity-compose:1.9.0" androidx-fragment = "androidx.fragment:fragment-ktx:1.8.1" androidx-lifecycle-runtime = "androidx.lifecycle:lifecycle-runtime-ktx:2.6.1" androidx-lifecycle-viewmodel-compose = "androidx.lifecycle:lifecycle-viewmodel-compose:2.6.1" androidx-window = { module = "androidx.window:window", version.ref = "androidxWindow" } androidx-window-testing = { module = "androidx.window:window-testing", version.ref = "androidxWindow" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidxnavigation" } androidx-navigation-testing = { module = "androidx.navigation:navigation-testing", version.ref = "androidxnavigation" } mdc = "com.google.android.material:material:1.8.0" androidx-test-core = "androidx.test:core-ktx:1.6.1" androidx-test-runner = "androidx.test:runner:1.6.1" androidx-test-rules = { module = "androidx.test:rules", version.ref = "androidxtest" } androidx-test-orchestrator = "androidx.test:orchestrator:1.5.0" androidx-test-uiAutomator = "androidx.test.uiautomator:uiautomator:2.3.0" junit = "junit:junit:4.13.2" truth = "com.google.truth:truth:1.1.3" robolectric = "org.robolectric:robolectric:4.12.1" affectedmoduledetector = "com.dropbox.affectedmoduledetector:affectedmoduledetector:0.1.2" android-tools-common = { module = "com.android.tools:common", version.ref = "androidTools" } android-tools-lint-lint = { module = "com.android.tools.lint:lint", version.ref = "lintMinCompose" } android-tools-lint-api = { module = "com.android.tools.lint:lint-api", version.ref = "lintMinCompose" } android-tools-lint-tests = { module = "com.android.tools.lint:lint-tests", version.ref = "lintMinCompose" } [plugins] android-application = { id = "com.android.application", version.ref = "gradlePlugin" } android-kotlin = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } android-library = { id = "com.android.library", version.ref = "gradlePlugin" } android-lint = { id = "com.android.lint", version.ref = "androidlint"} jetbrains-dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" } gradle-metalava = { id = "me.tylerbwong.gradle.metalava", version.ref = "metalava" } vanniktech-maven-publish = { id = "com.vanniktech.maven.publish", version.ref = "vanniktechPublish" } compose-plugin = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } #build-logic plugins accompanist-android-library = { id = "accompanist.android.library" } accompanist-android-library-compose = { id = "accompanist.android.library.compose" } accompanist-android-library-published = { id = "accompanist.android.library.published" } accompanist-android-lint = { id = "accompanist.android.lint" } ================================================ FILE: gradle/wrapper/gradle-wrapper.properties ================================================ #Wed Jul 10 11:49:25 AEST 2024 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-8.10-bin.zip networkTimeout=10000 zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists ================================================ FILE: gradle.properties ================================================ # # Copyright 2020 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 # # 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. # # Turn on parallel compilation, caching and on-demand configuration org.gradle.configureondemand=true org.gradle.caching=true org.gradle.parallel=true # Declare we support AndroidX android.useAndroidX=true # Increase memory org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=2048m -XX:+HeapDumpOnOutOfMemoryError # Required to publish to Nexus (see https://github.com/gradle/gradle/issues/11308) systemProp.org.gradle.internal.publish.checksums.insecure=true # Increase timeout when pushing to Sonatype (otherwise we get timeouts) systemProp.org.gradle.internal.http.socketTimeout=120000 GROUP=com.google.accompanist # !! No longer need to update this manually when using a Compose SNAPSHOT VERSION_NAME=0.37.4-SNAPSHOT POM_DESCRIPTION=Utilities for Jetpack Compose POM_URL=https://github.com/google/accompanist/ POM_SCM_URL=https://github.com/google/accompanist/ POM_SCM_CONNECTION=scm:git:git://github.com/google/accompanist.git POM_SCM_DEV_CONNECTION=scm:git:git://github.com/google/accompanist.git POM_LICENCE_NAME=The Apache Software License, Version 2.0 POM_LICENCE_URL=http://www.apache.org/licenses/LICENSE-2.0.txt POM_LICENCE_DIST=repo POM_DEVELOPER_ID=google POM_DEVELOPER_NAME=Google SONATYPE_HOST=DEFAULT RELEASE_SIGNING_ENABLED=true ================================================ FILE: gradlew ================================================ #!/bin/sh # # Copyright © 2015-2021 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # https://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # ############################################################################## # # Gradle start up script for POSIX generated by Gradle. # # Important for running: # # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is # noncompliant, but you have some other compliant shell such as ksh or # bash, then to run this script, type that shell name before the whole # command line, like: # # ksh Gradle # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: # * functions; # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», # «${var#prefix}», «${var%suffix}», and «$( cmd )»; # * compound commands having a testable exit status, especially «case»; # * various built-in commands including «command», «set», and «ulimit». # # Important for patching: # # (2) This script targets any POSIX shell, so it avoids extensions provided # by Bash, Ksh, etc; in particular arrays are avoided. # # The "traditional" practice of packing multiple parameters into a # space-separated string is a well documented source of bugs and security # problems, so this is (mostly) avoided, by progressively accumulating # options in "$@", and eventually passing that to Java. # # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; # see the in-line comments for details. # # There are tweaks for specific operating systems such as AIX, CygWin, # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template # https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. # ############################################################################## # Attempt to set APP_HOME # Resolve links: $0 may be a link app_path=$0 # Need this for daisy-chained symlinks. while APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path [ -h "$app_path" ] do ls=$( ls -ld "$app_path" ) link=${ls#*' -> '} case $link in #( /*) app_path=$link ;; #( *) app_path=$APP_HOME$link ;; esac done # This is normally unused # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum warn () { echo "$*" } >&2 die () { echo echo "$*" echo exit 1 } >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false case "$( uname )" in #( CYGWIN* ) cygwin=true ;; #( Darwin* ) darwin=true ;; #( MSYS* | MINGW* ) msys=true ;; #( NONSTOP* ) nonstop=true ;; esac CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables JAVACMD=$JAVA_HOME/jre/sh/java else JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else JAVACMD=java which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi # Increase the maximum file descriptors if we can. if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. # shellcheck disable=SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac case $MAX_FD in #( '' | soft) :;; #( *) # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. # shellcheck disable=SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac fi # Collect all arguments for the java command, stacking in reverse order: # * args from the command line # * the main class name # * -classpath # * -D...appname settings # * --module-path (only if needed) # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) # Now convert the arguments - kludge to limit ourselves to /bin/sh for arg do if case $arg in #( -*) false ;; # don't mess with options #( /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath [ -e "$t" ] ;; #( *) false ;; esac then arg=$( cygpath --path --ignore --mixed "$arg" ) fi # Roll the args list around exactly as many times as the number of # args, so each arg winds up back in the position where it started, but # possibly modified. # # NB: a `for` loop captures its iteration list before it begins, so # changing the positional parameters here affects neither the number of # iterations, nor the values presented in `arg`. shift # remove old arg set -- "$@" "$arg" # push replacement arg done fi # 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"' # Collect all arguments for the java command; # * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of # shell script including quotes and variable substitutions, so put them in # double quotes to make sure that they get re-expanded; and # * put everything else in single quotes, so that it's not re-expanded. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ -classpath "$CLASSPATH" \ org.gradle.wrapper.GradleWrapperMain \ "$@" # Stop when "xargs" is not available. if ! command -v xargs >/dev/null 2>&1 then die "xargs is not available" fi # Use "xargs" to parse quoted args. # # With -n1 it outputs one arg per line, with the quotes and backslashes removed. # # In Bash we could simply go: # # readarray ARGS < <( xargs -n1 <<<"$var" ) && # set -- "${ARGS[@]}" "$@" # # but POSIX shell has neither arrays nor command substitution, so instead we # post-process each arg (as a line of input to sed) to backslash-escape any # character that might be a shell metacharacter, then use eval to reverse # that process (while maintaining the separation between arguments), and wrap # the whole thing up as a single "set" statement. # # This will of course break if any of these variables contains a newline or # an unmatched quote. # eval "set -- $( printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | xargs -n1 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | tr '\n' ' ' )" '"$@"' exec "$JAVACMD" "$@" ================================================ FILE: gradlew.bat ================================================ @rem @rem Copyright 2015 the original author or authors. @rem @rem Licensed under the Apache License, Version 2.0 (the "License"); @rem you may not use this file except in compliance with the License. @rem You may obtain a copy of the License at @rem @rem https://www.apache.org/licenses/LICENSE-2.0 @rem @rem Unless required by applicable law or agreed to in writing, software @rem distributed under the License is distributed on an "AS IS" BASIS, @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem @if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @rem @rem ########################################################################## @rem Set local scope for the variables with windows NT shell if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 if "%DIRNAME%"=="" set DIRNAME=. @rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @rem Resolve any "." and ".." in APP_HOME to make it shorter. for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" @rem Find java.exe if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if %ERRORLEVEL% equ 0 goto execute echo. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. echo. echo Please set the JAVA_HOME variable in your environment to match the echo location of your Java installation. goto fail :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute echo. echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% echo. echo Please set the JAVA_HOME variable in your environment to match the echo location of your Java installation. goto fail :execute @rem Setup the command line set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar @rem Execute Gradle "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* :end @rem End local scope for the variables with windows NT shell if %ERRORLEVEL% equ 0 goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! set EXIT_CODE=%ERRORLEVEL% if %EXIT_CODE% equ 0 set EXIT_CODE=1 if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal :omega ================================================ FILE: internal-testutils/build.gradle.kts ================================================ /* * Copyright 2023 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 * * 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. */ @file:Suppress("UnstableApiUsage") plugins { alias(libs.plugins.accompanist.android.library) alias(libs.plugins.accompanist.android.library.compose) } android { namespace = "com.google.accompanist.internal.test" } dependencies { implementation(libs.kotlin.stdlib) implementation(libs.kotlin.coroutines.android) implementation(libs.compose.foundation.foundation) api(libs.compose.ui.test.junit4) api(libs.androidx.test.core) implementation(libs.truth) } ================================================ FILE: internal-testutils/src/main/AndroidManifest.xml ================================================ ================================================ FILE: internal-testutils/src/main/java/com/google/accompanist/internal/test/ActivityScenario.kt ================================================ /* * Copyright 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 * * 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. */ package com.google.accompanist.internal.test import android.app.Activity import androidx.test.core.app.ActivityScenario public fun ActivityScenario.withActivity( action: (A) -> T ): T { lateinit var result: T onActivity { result = action(it) } return result } ================================================ FILE: internal-testutils/src/main/java/com/google/accompanist/internal/test/Assertions.kt ================================================ /* * Copyright 2020 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 * * 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. */ package com.google.accompanist.internal.test import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.toPixelMap import androidx.compose.ui.test.SemanticsNodeInteraction import androidx.compose.ui.test.assertHeightIsAtLeast import androidx.compose.ui.test.assertWidthIsAtLeast import androidx.compose.ui.unit.Dp import com.google.common.truth.Truth.assertThat /** * Assert that all of the pixels in this image as of the [expected] color. */ public fun ImageBitmap.assertPixels(expected: Color, tolerance: Float = 0.001f) { toPixelMap().buffer.forEach { pixel -> val color = Color(pixel) assertThat(color.red).isWithin(tolerance).of(expected.red) assertThat(color.green).isWithin(tolerance).of(expected.green) assertThat(color.blue).isWithin(tolerance).of(expected.blue) assertThat(color.alpha).isWithin(tolerance).of(expected.alpha) } } /** * Run the [SemanticsNodeInteraction] provided by [block] repeatedly until either * the assertion succeeds, or the execution runs past [timeoutMillis]. */ public fun SemanticsNodeInteraction.assertWithTimeout( timeoutMillis: Long, block: SemanticsNodeInteraction.() -> SemanticsNodeInteraction, ): SemanticsNodeInteraction { val startTime = System.nanoTime() while (System.nanoTime() - startTime <= timeoutMillis * 1_000_000) { try { return block() } catch (error: AssertionError) { // If the assertion failed, sleep for 10ms before the next loop iteration Thread.sleep(10) } } // If we reach here, each assertion has failed and we've reached the time out. // Run block one last time... return block() } public val SemanticsNodeInteraction.exists: Boolean get() = try { assertExists() true } catch (t: Throwable) { false } public val SemanticsNodeInteraction.isLaidOut: Boolean get() = try { assertWidthIsAtLeast(Dp.Hairline).assertHeightIsAtLeast(Dp.Hairline) true } catch (t: Throwable) { false } ================================================ FILE: internal-testutils/src/main/java/com/google/accompanist/internal/test/IgnoreOnRobolectric.kt ================================================ /* * Copyright 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 * * 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. */ package com.google.accompanist.internal.test /** * Marker interface to use as a category for filtering out tests when running on Robolectric. */ public interface IgnoreOnRobolectric ================================================ FILE: internal-testutils/src/main/java/com/google/accompanist/internal/test/TestUtils.kt ================================================ /* * Copyright 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 * * 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. */ @file:Suppress("NOTHING_TO_INLINE") package com.google.accompanist.internal.test public inline fun parameterizedParams(): List> = emptyList() public inline fun List>.combineWithParameters( vararg values: T ): List> { if (isEmpty()) { return values.map { arrayOf(it) } } return fold(emptyList()) { acc, args -> val result = acc.toMutableList() values.forEach { v -> result += ArrayList().apply { addAll(args) add(v) }.toTypedArray() } result.toList() } } ================================================ FILE: internal-testutils/src/main/java/com/google/accompanist/internal/test/WaitUntil.kt ================================================ /* * Copyright 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 * * 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. */ package com.google.accompanist.internal.test import android.os.Looper import com.google.common.truth.Truth.assertThat import java.util.concurrent.TimeoutException public fun waitUntil(timeoutMillis: Long = 2_000, condition: () -> Boolean) { if (Looper.getMainLooper() == Looper.myLooper()) { assertThat(condition()).isTrue() } val startTime = System.nanoTime() while (!condition()) { // Let Android run measure, draw and in general any other async operations. Thread.sleep(10) if (System.nanoTime() - startTime > timeoutMillis * 1_000_000) { throw TimeoutException("Condition still not satisfied after $timeoutMillis ms") } } } ================================================ FILE: internal-testutils/src/main/res/values/themes.xml ================================================ ================================================ FILE: mkdocs.yml ================================================ # Project information site_name: 'Accompanist' site_description: 'A group of libraries to help write Jetpack Compose apps.' site_author: 'Google' site_url: 'https://google.github.io/accompanist/' edit_uri: 'tree/main/docs/' remote_branch: gh-pages docs_dir: docs-gen # Repository repo_name: 'Accompanist' repo_url: 'https://github.com/google/accompanist' # Navigation nav: - 'Overview': index.md - 'System UI Controller': - 'Guide': systemuicontroller.md - 'API': api/systemuicontroller/ - 'Drawable Painter': - 'Guide': drawablepainter.md - 'API': api/drawablepainter/ - 'Permissions': - 'Guide': permissions.md - 'API': api/permissions/ - 'Navigation Animation': - 'Guide': navigation-animation.md - 'API': api/navigation-animation/ - 'Navigation Material': - 'Guide': navigation-material.md - 'API': api/navigation-material/ - 'Adaptive': - 'Guide': adaptive.md - 'API': api/adaptive - 'Snapshots': using-snapshot-version.md - 'Contributing': contributing.md - 'Maintainers': - 'Update guide': updating.md # Configuration theme: name: 'material' language: 'en' icon: logo: material/music-clef-treble palette: primary: 'black' accent: 'deep orange' font: text: 'Roboto' code: 'JetBrains Mono' # Extensions markdown_extensions: - admonition - attr_list - codehilite: guess_lang: false - footnotes - toc: permalink: true - pymdownx.betterem - pymdownx.superfences - pymdownx.tabbed: alternate_style: true - pymdownx.details ================================================ FILE: permissions/README.md ================================================ # Permissions for Jetpack Compose [![Maven Central](https://img.shields.io/maven-central/v/com.google.accompanist/accompanist-permissions)](https://search.maven.org/search?q=g:com.google.accompanist) For more information, visit the documentation: https://google.github.io/accompanist/permissions ## Download ```groovy repositories { mavenCentral() } dependencies { implementation "com.google.accompanist:accompanist-permissions:" } ``` Snapshots of the development version are available in [Sonatype's `snapshots` repository][snap]. These are updated on every commit. [snap]: https://oss.sonatype.org/content/repositories/snapshots/com/google/accompanist/accompanist-permissions/ ================================================ FILE: permissions/api/current.api ================================================ // Signature format: 4.0 package com.google.accompanist.permissions { @kotlin.RequiresOptIn(message="Accompanist Permissions is experimental. The API may be changed in the future.") @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.BINARY) public @interface ExperimentalPermissionsApi { } @androidx.compose.runtime.Stable @com.google.accompanist.permissions.ExperimentalPermissionsApi public interface MultiplePermissionsState { method public boolean getAllPermissionsGranted(); method public java.util.List getPermissions(); method public java.util.List getRevokedPermissions(); method public boolean getShouldShowRationale(); method public void launchMultiplePermissionRequest(); property public abstract boolean allPermissionsGranted; property public abstract java.util.List permissions; property public abstract java.util.List revokedPermissions; property public abstract boolean shouldShowRationale; } public final class MultiplePermissionsStateKt { method @androidx.compose.runtime.Composable @com.google.accompanist.permissions.ExperimentalPermissionsApi public static com.google.accompanist.permissions.MultiplePermissionsState rememberMultiplePermissionsState(java.util.List permissions, optional kotlin.jvm.functions.Function1,kotlin.Unit> onPermissionsResult); method @androidx.compose.runtime.Composable @com.google.accompanist.permissions.ExperimentalPermissionsApi public static com.google.accompanist.permissions.MultiplePermissionsState rememberMultiplePermissionsState(java.util.List permissions, optional kotlin.jvm.functions.Function1,kotlin.Unit> onPermissionsResult, optional java.util.Map previewPermissionStatuses); } @androidx.compose.runtime.Stable @com.google.accompanist.permissions.ExperimentalPermissionsApi public interface PermissionState { method public String getPermission(); method public com.google.accompanist.permissions.PermissionStatus getStatus(); method public void launchPermissionRequest(); property public abstract String permission; property public abstract com.google.accompanist.permissions.PermissionStatus status; } public final class PermissionStateKt { method @androidx.compose.runtime.Composable @com.google.accompanist.permissions.ExperimentalPermissionsApi public static com.google.accompanist.permissions.PermissionState rememberPermissionState(String permission, optional kotlin.jvm.functions.Function1 onPermissionResult); method @androidx.compose.runtime.Composable @com.google.accompanist.permissions.ExperimentalPermissionsApi public static com.google.accompanist.permissions.PermissionState rememberPermissionState(String permission, optional kotlin.jvm.functions.Function1 onPermissionResult, optional com.google.accompanist.permissions.PermissionStatus previewPermissionStatus); } @androidx.compose.runtime.Stable @com.google.accompanist.permissions.ExperimentalPermissionsApi public sealed interface PermissionStatus { } public static final class PermissionStatus.Denied implements com.google.accompanist.permissions.PermissionStatus { ctor public PermissionStatus.Denied(boolean shouldShowRationale); method public boolean component1(); method public com.google.accompanist.permissions.PermissionStatus.Denied copy(boolean shouldShowRationale); method public boolean getShouldShowRationale(); property public final boolean shouldShowRationale; } public static final class PermissionStatus.Granted implements com.google.accompanist.permissions.PermissionStatus { field public static final com.google.accompanist.permissions.PermissionStatus.Granted INSTANCE; } public final class PermissionsUtilKt { method public static boolean getShouldShowRationale(com.google.accompanist.permissions.PermissionStatus); method public static boolean isGranted(com.google.accompanist.permissions.PermissionStatus); } } ================================================ FILE: permissions/build.gradle.kts ================================================ /* * Copyright 2023 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 * * 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. */ @file:Suppress("UnstableApiUsage") plugins { alias(libs.plugins.accompanist.android.library) alias(libs.plugins.accompanist.android.library.compose) alias(libs.plugins.accompanist.android.library.published) } android { namespace = "com.google.accompanist.permissions" defaultConfig { // The following argument makes the Android Test Orchestrator run its // "pm clear" command after each test invocation. This command ensures // that the app's state is completely cleared between tests. testInstrumentationRunnerArguments["clearPackageData"] = "true" } testOptions { execution = "ANDROIDX_TEST_ORCHESTRATOR" } } dependencies { implementation(libs.androidx.activity.compose) implementation(libs.compose.foundation.foundation) implementation(libs.kotlin.coroutines.android) lintChecks(project(":permissions-lint")) lintPublish(project(":permissions-lint")) // ====================== // Test dependencies // ====================== androidTestUtil(libs.androidx.test.orchestrator) androidTestImplementation(project(":internal-testutils")) androidTestImplementation(libs.androidx.activity.compose) androidTestImplementation(libs.compose.material.material) androidTestImplementation(libs.junit) androidTestImplementation(libs.truth) androidTestImplementation(libs.compose.ui.test.junit4) androidTestImplementation(libs.compose.ui.test.manifest) androidTestImplementation(libs.compose.foundation.foundation) androidTestImplementation(libs.androidx.test.runner) androidTestImplementation(libs.androidx.test.rules) androidTestImplementation(libs.androidx.test.uiAutomator) } ================================================ FILE: permissions/gradle.properties ================================================ POM_ARTIFACT_ID=accompanist-permissions POM_NAME=Accompanist Permissions POM_PACKAGING=aar ================================================ FILE: permissions/src/androidTest/AndroidManifest.xml ================================================ ================================================ FILE: permissions/src/androidTest/java/com/google/accompanist/permissions/FakeTests.kt ================================================ /* * Copyright 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 * * 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. */ package com.google.accompanist.permissions import androidx.test.filters.SdkSuppress import org.junit.Test /** * Fake tests to avoid the "No tests found error" when running in Build.VERSION.SDK_INT < 23 */ class FakeTests { @SdkSuppress(maxSdkVersion = 22) @Test fun fakeTestToAvoidNoTestsFoundErrorInAPI22AndBelow() = Unit // More Fake tests to help with sharding: https://github.com/android/android-test/issues/973 @Test fun fake1() = Unit @Test fun fake2() = Unit @Test fun fake3() = Unit @Test fun fake4() = Unit @Test fun fake5() = Unit @Test fun fake6() = Unit @Test fun fake7() = Unit @Test fun fake8() = Unit @Test fun fake9() = Unit } ================================================ FILE: permissions/src/androidTest/java/com/google/accompanist/permissions/MultipleAndSinglePermissionsTest.kt ================================================ /* * Copyright 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 * * 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. */ package com.google.accompanist.permissions import android.content.Intent import android.os.Build import androidx.activity.ComponentActivity import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.material.Button import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.unit.dp import androidx.test.filters.FlakyTest import androidx.test.filters.SdkSuppress import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.UiDevice import com.google.accompanist.permissions.test.PermissionsTestActivity import org.junit.Rule import org.junit.Test @OptIn(ExperimentalPermissionsApi::class) @FlakyTest(detail = "https://github.com/google/accompanist/issues/490") @SdkSuppress(minSdkVersion = 23) class MultipleAndSinglePermissionsTest { @get:Rule val composeTestRule = createAndroidComposeRule() private val instrumentation by lazy { InstrumentationRegistry.getInstrumentation() } private val uiDevice by lazy { UiDevice.getInstance(instrumentation) } @Test fun singlePermission_granted() { composeTestRule.setContent { ComposableUnderTest(listOf(android.Manifest.permission.CAMERA)) } composeTestRule.onNodeWithText("MultipleAndSinglePermissionsTest").assertIsDisplayed() composeTestRule.onNodeWithText("No permission").assertIsDisplayed() composeTestRule.onNodeWithText("Request").performClick() grantPermissionInDialog() composeTestRule.onNodeWithText("Granted").assertIsDisplayed() composeTestRule.onNodeWithText("Navigate").performClick() instrumentation.waitForIdleSync() composeTestRule.onNodeWithText("PermissionsTestActivity").assertIsDisplayed() composeTestRule.onNodeWithText("Granted").assertIsDisplayed() } @Test fun singlePermission_deniedAndGrantedInSecondActivity() { composeTestRule.setContent { ComposableUnderTest(listOf(android.Manifest.permission.CAMERA)) } composeTestRule.onNodeWithText("MultipleAndSinglePermissionsTest").assertIsDisplayed() composeTestRule.onNodeWithText("No permission").assertIsDisplayed() composeTestRule.onNodeWithText("Request").performClick() denyPermissionInDialog() composeTestRule.onNodeWithText("ShowRationale").assertIsDisplayed() composeTestRule.onNodeWithText("Navigate").performClick() instrumentation.waitForIdleSync() composeTestRule.onNodeWithText("PermissionsTestActivity").assertIsDisplayed() composeTestRule.onNodeWithText("ShowRationale").assertIsDisplayed() composeTestRule.onNodeWithText("Request").performClick() grantPermissionInDialog() composeTestRule.onNodeWithText("Granted").assertIsDisplayed() uiDevice.pressBack() instrumentation.waitForIdleSync() composeTestRule.onNodeWithText("MultipleAndSinglePermissionsTest").assertIsDisplayed() composeTestRule.onNodeWithText("Granted").assertIsDisplayed() } @Test fun singlePermission_deniedAndGrantedInFirstActivity() { composeTestRule.setContent { ComposableUnderTest(listOf(android.Manifest.permission.CAMERA)) } composeTestRule.onNodeWithText("MultipleAndSinglePermissionsTest").assertIsDisplayed() composeTestRule.onNodeWithText("No permission").assertIsDisplayed() composeTestRule.onNodeWithText("Request").performClick() denyPermissionInDialog() composeTestRule.onNodeWithText("ShowRationale").assertIsDisplayed() composeTestRule.onNodeWithText("Navigate").performClick() instrumentation.waitForIdleSync() composeTestRule.onNodeWithText("PermissionsTestActivity").assertIsDisplayed() composeTestRule.onNodeWithText("ShowRationale").assertIsDisplayed() uiDevice.pressBack() instrumentation.waitForIdleSync() composeTestRule.onNodeWithText("MultipleAndSinglePermissionsTest").assertIsDisplayed() composeTestRule.onNodeWithText("Request").performClick() grantPermissionInDialog() composeTestRule.onNodeWithText("Granted").assertIsDisplayed() composeTestRule.onNodeWithText("Navigate").performClick() instrumentation.waitForIdleSync() composeTestRule.onNodeWithText("PermissionsTestActivity").assertIsDisplayed() composeTestRule.onNodeWithText("Granted").assertIsDisplayed() } @Test fun singlePermission_deniedAndGrantedInFirstActivity_singlePermission() { composeTestRule.setContent { ComposableUnderTest( listOf(android.Manifest.permission.CAMERA), requestSinglePermission = true ) } composeTestRule.onNodeWithText("MultipleAndSinglePermissionsTest").assertIsDisplayed() composeTestRule.onNodeWithText("No permission").assertIsDisplayed() composeTestRule.onNodeWithText("Request").performClick() denyPermissionInDialog() composeTestRule.onNodeWithText("ShowRationale").assertIsDisplayed() composeTestRule.onNodeWithText("Navigate").performClick() instrumentation.waitForIdleSync() composeTestRule.onNodeWithText("PermissionsTestActivity").assertIsDisplayed() composeTestRule.onNodeWithText("ShowRationale").assertIsDisplayed() uiDevice.pressBack() instrumentation.waitForIdleSync() composeTestRule.onNodeWithText("MultipleAndSinglePermissionsTest").assertIsDisplayed() composeTestRule.onNodeWithText("Request").performClick() grantPermissionInDialog() composeTestRule.onNodeWithText("Granted").assertIsDisplayed() composeTestRule.onNodeWithText("Navigate").performClick() instrumentation.waitForIdleSync() composeTestRule.onNodeWithText("PermissionsTestActivity").assertIsDisplayed() composeTestRule.onNodeWithText("Granted").assertIsDisplayed() } @Test fun multiplePermissions_granted() { composeTestRule.setContent { ComposableUnderTest( listOf( android.Manifest.permission.ACCESS_FINE_LOCATION, android.Manifest.permission.CAMERA ) ) } composeTestRule.onNodeWithText("MultipleAndSinglePermissionsTest").assertIsDisplayed() composeTestRule.onNodeWithText("No permission").assertIsDisplayed() composeTestRule.onNodeWithText("Request").performClick() grantPermissionInDialog() // Grant first permission grantPermissionInDialog() // Grant second permission composeTestRule.onNodeWithText("Granted").assertIsDisplayed() composeTestRule.onNodeWithText("Navigate").performClick() instrumentation.waitForIdleSync() composeTestRule.onNodeWithText("PermissionsTestActivity").assertIsDisplayed() composeTestRule.onNodeWithText("Granted").assertIsDisplayed() } @Test fun multiplePermissions_denied() { composeTestRule.setContent { ComposableUnderTest( listOf( android.Manifest.permission.ACCESS_FINE_LOCATION, android.Manifest.permission.CAMERA ) ) } composeTestRule.onNodeWithText("MultipleAndSinglePermissionsTest").assertIsDisplayed() composeTestRule.onNodeWithText("No permission").assertIsDisplayed() composeTestRule.onNodeWithText("Request").performClick() denyPermissionInDialog() // Deny first permission denyPermissionInDialog() // Deny second permission composeTestRule.onNodeWithText("ShowRationale").assertIsDisplayed() composeTestRule.onNodeWithText("Navigate").performClick() instrumentation.waitForIdleSync() composeTestRule.onNodeWithText("PermissionsTestActivity").assertIsDisplayed() composeTestRule.onNodeWithText("ShowRationale").assertIsDisplayed() composeTestRule.onNodeWithText("Request").performClick() grantPermissionInDialog() // Grant the permission composeTestRule.onNodeWithText("Granted").assertIsDisplayed() uiDevice.pressBack() instrumentation.waitForIdleSync() composeTestRule.onNodeWithText("MultipleAndSinglePermissionsTest").assertIsDisplayed() composeTestRule.onNodeWithText("Request").performClick() grantPermissionInDialog() // only one permission to grant now if (Build.VERSION.SDK_INT == 23) { // API 23 shows all permissions again grantPermissionInDialog() } composeTestRule.onNodeWithText("Granted").assertIsDisplayed() } @Composable private fun ComposableUnderTest( permissions: List, requestSinglePermission: Boolean = false ) { val state = rememberMultiplePermissionsState(permissions) Column { Text("MultipleAndSinglePermissionsTest") Spacer(Modifier.height(16.dp)) if (state.allPermissionsGranted) { Text("Granted") } else { Column { val textToShow = if (state.shouldShowRationale) { "ShowRationale" } else { "No permission" } Text(textToShow) Button( onClick = { if (requestSinglePermission && state.revokedPermissions.size == 1) { state.revokedPermissions[0].launchPermissionRequest() } else { state.launchMultiplePermissionRequest() } } ) { Text("Request") } } } Spacer(Modifier.height(16.dp)) Button( onClick = { composeTestRule.activity.startActivity( Intent(composeTestRule.activity, PermissionsTestActivity::class.java) ) } ) { Text("Navigate") } } } } ================================================ FILE: permissions/src/androidTest/java/com/google/accompanist/permissions/MultiplePermissionsStateTest.kt ================================================ /* * Copyright 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 * * 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. */ package com.google.accompanist.permissions import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.test.filters.SdkSuppress import androidx.test.rule.GrantPermissionRule import com.google.accompanist.permissions.test.EmptyPermissionsTestActivity import com.google.common.truth.Truth.assertThat import org.junit.Rule import org.junit.Test /** * Simple tests that prove the data comes from the right place */ @OptIn(ExperimentalPermissionsApi::class) @SdkSuppress(minSdkVersion = 23) class MultiplePermissionsStateTest { @get:Rule val composeTestRule = createAndroidComposeRule() @get:Rule val permissionRule: GrantPermissionRule = GrantPermissionRule.grant( "android.permission.CAMERA", "android.permission.ACCESS_FINE_LOCATION" ) @Test fun permissionState_hasPermission() { composeTestRule.setContent { val state = rememberMultiplePermissionsState( listOf( android.Manifest.permission.ACCESS_FINE_LOCATION, android.Manifest.permission.CAMERA ) ) assertThat(state.allPermissionsGranted).isTrue() assertThat(state.shouldShowRationale).isFalse() } } @Test fun permissionTest_shouldShowRationale() { composeTestRule.activity.shouldShowRequestPermissionRationale = mapOf( android.Manifest.permission.WRITE_EXTERNAL_STORAGE to true ) composeTestRule.setContent { val state = rememberMultiplePermissionsState( listOf( android.Manifest.permission.WRITE_EXTERNAL_STORAGE, android.Manifest.permission.ACCESS_FINE_LOCATION, android.Manifest.permission.CAMERA ) ) assertThat(state.allPermissionsGranted).isFalse() assertThat(state.shouldShowRationale).isTrue() assertThat(state.permissions).hasSize(3) assertThat(state.revokedPermissions).hasSize(1) assertThat(state.revokedPermissions[0].permission) .isEqualTo("android.permission.WRITE_EXTERNAL_STORAGE") } } } ================================================ FILE: permissions/src/androidTest/java/com/google/accompanist/permissions/PermissionStateTest.kt ================================================ /* * Copyright 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 * * 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. */ package com.google.accompanist.permissions import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.test.filters.SdkSuppress import androidx.test.rule.GrantPermissionRule import com.google.accompanist.permissions.test.EmptyPermissionsTestActivity import com.google.common.truth.Truth.assertThat import org.junit.Rule import org.junit.Test /** * Simple tests that prove the data comes from the right place */ @OptIn(ExperimentalPermissionsApi::class) @SdkSuppress(minSdkVersion = 23) class PermissionStateTest { @get:Rule val composeTestRule = createAndroidComposeRule() @get:Rule val permissionRule: GrantPermissionRule = GrantPermissionRule.grant("android.permission.CAMERA") @Test fun permissionState_hasPermission() { composeTestRule.setContent { val state = rememberPermissionState(android.Manifest.permission.CAMERA) assertThat(state.status.isGranted).isTrue() assertThat(state.status.shouldShowRationale).isFalse() } } @Test fun permissionTest_shouldShowRationale() { val permission = android.Manifest.permission.ACCESS_FINE_LOCATION composeTestRule.activity.shouldShowRequestPermissionRationale = mapOf( permission to true ) composeTestRule.setContent { val state = rememberPermissionState(permission) assertThat(state.status.isGranted).isFalse() assertThat(state.status.shouldShowRationale).isTrue() } } } ================================================ FILE: permissions/src/androidTest/java/com/google/accompanist/permissions/RequestMultiplePermissionsTest.kt ================================================ /* * Copyright 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 * * 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. */ package com.google.accompanist.permissions import android.os.Build import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.foundation.layout.Column import androidx.compose.material.Button import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.test.filters.SdkSuppress import org.junit.Before import org.junit.Rule import org.junit.Test @OptIn(ExperimentalPermissionsApi::class) @SdkSuppress(minSdkVersion = 27) // Flaky on 26 class RequestMultiplePermissionsTest { @get:Rule val composeTestRule = createAndroidComposeRule() @Before fun setup() { composeTestRule.setContent { ComposableUnderTest() } } @Test fun permissionTest_grantPermissions() { composeTestRule.onNodeWithText("No permission").assertIsDisplayed() composeTestRule.onNodeWithText("Request").performClick() grantPermissionInDialog() // Grant first permission grantPermissionInDialog() // Grant second permission composeTestRule.onNodeWithText("Granted").assertIsDisplayed() } @Test fun permissionTest_denyOnePermission() { composeTestRule.onNodeWithText("No permission").assertIsDisplayed() composeTestRule.onNodeWithText("Request").performClick() grantPermissionInDialog() // Grant first permission denyPermissionInDialog() // Deny second permission composeTestRule.onNodeWithText("ShowRationale").assertIsDisplayed() composeTestRule.onNodeWithText("Request").performClick() grantPermissionInDialog() // Grant second permission if (Build.VERSION.SDK_INT == 23) { // API 23 shows all permissions again grantPermissionInDialog() } composeTestRule.onNodeWithText("Granted").assertIsDisplayed() } @Test fun permissionTest_doNotAskAgainPermission() { composeTestRule.onNodeWithText("No permission").assertIsDisplayed() composeTestRule.onNodeWithText("Request").performClick() grantPermissionInDialog() // Grant first permission denyPermissionInDialog() // Deny second permission composeTestRule.onNodeWithText("ShowRationale").assertIsDisplayed() composeTestRule.onNodeWithText("Request").performClick() if (Build.VERSION.SDK_INT == 23) { // API 23 shows all permissions again grantPermissionInDialog() } doNotAskAgainPermissionInDialog() // Do not ask again second permission composeTestRule.onNodeWithText("No permission").assertIsDisplayed() } @Test fun permissionTest_grantInTheBackground() { composeTestRule.onNodeWithText("No permission").assertIsDisplayed() composeTestRule.onNodeWithText("Request").performClick() grantPermissionInDialog() // Grant first permission denyPermissionInDialog() // Deny second permission composeTestRule.onNodeWithText("ShowRationale").assertIsDisplayed() composeTestRule.onNodeWithText("Request").performClick() if (Build.VERSION.SDK_INT == 23) { // API 23 shows all permissions again grantPermissionInDialog() } doNotAskAgainPermissionInDialog() // Do not ask again second permission composeTestRule.onNodeWithText("No permission").assertIsDisplayed() // This simulates the user going to the Settings screen and granting both permissions. // This is cheating, I know, but the order in which the system request the permissions // is unpredictable. Therefore, we need to grant both to make this test deterministic. grantPermissionProgrammatically("android.permission.CAMERA") grantPermissionProgrammatically("android.permission.ACCESS_FINE_LOCATION") simulateAppComingFromTheBackground(composeTestRule) composeTestRule.activityRule.scenario.onActivity { it.setContent { ComposableUnderTest() } } composeTestRule.waitForIdle() composeTestRule.onNodeWithText("Granted").assertIsDisplayed() } @Composable private fun ComposableUnderTest() { val state = rememberMultiplePermissionsState( listOf( android.Manifest.permission.ACCESS_FINE_LOCATION, android.Manifest.permission.CAMERA ) ) if (state.allPermissionsGranted) { Text("Granted") } else { Column { val textToShow = if (state.shouldShowRationale) { "ShowRationale" } else { "No permission" } Text(textToShow) Button(onClick = { state.launchMultiplePermissionRequest() }) { Text("Request") } } } } } ================================================ FILE: permissions/src/androidTest/java/com/google/accompanist/permissions/RequestPermissionTest.kt ================================================ /* * Copyright 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 * * 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. */ package com.google.accompanist.permissions import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.foundation.layout.Column import androidx.compose.material.Button import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.test.filters.FlakyTest import androidx.test.filters.SdkSuppress import org.junit.Before import org.junit.Rule import org.junit.Test @OptIn(ExperimentalPermissionsApi::class) @SdkSuppress(minSdkVersion = 23) class RequestPermissionTest { @get:Rule val composeTestRule = createAndroidComposeRule() @Before fun setup() { composeTestRule.setContent { ComposableUnderTest() } } @Test fun permissionTest_grantPermission() { composeTestRule.onNodeWithText("No permission").assertIsDisplayed() composeTestRule.onNodeWithText("Request").performClick() grantPermissionInDialog() composeTestRule.onNodeWithText("Granted").assertIsDisplayed() } @Test fun permissionTest_denyPermission() { composeTestRule.onNodeWithText("No permission").assertIsDisplayed() composeTestRule.onNodeWithText("Request").performClick() denyPermissionInDialog() composeTestRule.onNodeWithText("ShowRationale").assertIsDisplayed() composeTestRule.onNodeWithText("Request").performClick() grantPermissionInDialog() composeTestRule.onNodeWithText("Granted").assertIsDisplayed() } @Test fun permissionTest_doNotAskAgainPermission() { composeTestRule.onNodeWithText("No permission").assertIsDisplayed() composeTestRule.onNodeWithText("Request").performClick() denyPermissionInDialog() composeTestRule.onNodeWithText("ShowRationale").assertIsDisplayed() composeTestRule.onNodeWithText("Request").performClick() doNotAskAgainPermissionInDialog() composeTestRule.onNodeWithText("No permission").assertIsDisplayed() } @SdkSuppress(minSdkVersion = 29) // Flaky below @FlakyTest @Test fun permissionTest_grantInTheBackground() { composeTestRule.onNodeWithText("No permission").assertIsDisplayed() composeTestRule.onNodeWithText("Request").performClick() denyPermissionInDialog() composeTestRule.onNodeWithText("ShowRationale").assertIsDisplayed() composeTestRule.onNodeWithText("Request").performClick() doNotAskAgainPermissionInDialog() composeTestRule.onNodeWithText("No permission").assertIsDisplayed() // This simulates the user going to the Settings screen and granting the permission grantPermissionProgrammatically(android.Manifest.permission.CAMERA) simulateAppComingFromTheBackground(composeTestRule) composeTestRule.activityRule.scenario.onActivity { it.setContent { ComposableUnderTest() } } composeTestRule.waitForIdle() composeTestRule.onNodeWithText("Granted").assertIsDisplayed() } @Composable private fun ComposableUnderTest() { val state = rememberPermissionState(android.Manifest.permission.CAMERA) when (state.status) { PermissionStatus.Granted -> { Text("Granted") } is PermissionStatus.Denied -> { Column { val textToShow = if (state.status.shouldShowRationale) { "ShowRationale" } else { "No permission" } Text(textToShow) Button(onClick = { state.launchPermissionRequest() }) { Text("Request") } } } } } } ================================================ FILE: permissions/src/androidTest/java/com/google/accompanist/permissions/TestUtils.kt ================================================ /* * Copyright 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 * * 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. */ package com.google.accompanist.permissions import android.app.Instrumentation import android.os.Build import androidx.activity.ComponentActivity import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.lifecycle.Lifecycle import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.By import androidx.test.uiautomator.UiDevice import androidx.test.uiautomator.UiObject2 import androidx.test.uiautomator.Until import java.io.ByteArrayOutputStream internal fun simulateAppComingFromTheBackground( composeTestRule: AndroidComposeTestRule, T> ) { // Make Activity go through ON_START, and ON_RESUME composeTestRule.activityRule.scenario.moveToState(Lifecycle.State.STARTED) composeTestRule.activityRule.scenario.moveToState(Lifecycle.State.RESUMED) } internal fun grantPermissionProgrammatically( permission: String, instrumentation: Instrumentation = InstrumentationRegistry.getInstrumentation() ) { if (Build.VERSION.SDK_INT < 28) { val fileDescriptor = instrumentation.uiAutomation.executeShellCommand( "pm grant ${instrumentation.targetContext.packageName} $permission" ) fileDescriptor.checkError() fileDescriptor.close() } else { instrumentation.uiAutomation.grantRuntimePermission( instrumentation.targetContext.packageName, permission ) } } internal fun grantPermissionInDialog( instrumentation: Instrumentation = InstrumentationRegistry.getInstrumentation() ) { val uiDevice = UiDevice.getInstance(instrumentation) val sdkVersion = Build.VERSION.SDK_INT val button = uiDevice.findPermissionButton( when (sdkVersion) { in 24..28 -> "ALLOW" 29 -> "Allow" else -> "While using the app" } ) button.clickForPermission(instrumentation) } internal fun denyPermissionInDialog( instrumentation: Instrumentation = InstrumentationRegistry.getInstrumentation() ) { val text = when (Build.VERSION.SDK_INT) { in 24..28 -> "DENY" in 29..30 -> "Deny" else -> "t allow" // Different sdks and devices seem to have either ' or ’ } val permissionButton = UiDevice.getInstance(instrumentation).findPermissionButton(text) permissionButton.clickForPermission(instrumentation) } internal fun doNotAskAgainPermissionInDialog( instrumentation: Instrumentation = InstrumentationRegistry.getInstrumentation() ) { val uiDevice = UiDevice.getInstance(instrumentation) when { Build.VERSION.SDK_INT >= 30 -> { denyPermissionInDialog(instrumentation) } Build.VERSION.SDK_INT > 28 -> { uiDevice .findPermissionButton("Deny & don’t ask again") .clickForPermission(instrumentation) } Build.VERSION.SDK_INT == 23 -> { uiDevice.findPermissionButton("Never ask again") .clickForPermission(instrumentation) denyPermissionInDialog(instrumentation) } else -> { uiDevice.findPermissionButton( "Don't ask again" ).clickForPermission(instrumentation) denyPermissionInDialog(instrumentation) } } } private fun UiDevice.findPermissionButton( text: String ): UiObject2 { val selector = By .textContains(text) .clickable(true) val found = wait(Until.hasObject(selector), 3000) if (!found) { val output = ByteArrayOutputStream() dumpWindowHierarchy(output) println(output.toByteArray().decodeToString()) error("Could not find button with text $text") } return findObject(selector) } private fun UiObject2.clickForPermission( instrumentation: Instrumentation = InstrumentationRegistry.getInstrumentation() ): Boolean { click() // Make sure that the tests waits for this click to be processed instrumentation.waitForIdleSync() return true } ================================================ FILE: permissions/src/androidTest/java/com/google/accompanist/permissions/test/EmptyPermissionsTestActivity.kt ================================================ /* * Copyright 2022 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 * * 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. */ package com.google.accompanist.permissions.test import androidx.activity.ComponentActivity import com.google.accompanist.permissions.ExperimentalPermissionsApi @OptIn(ExperimentalPermissionsApi::class) class EmptyPermissionsTestActivity : ComponentActivity() { var shouldShowRequestPermissionRationale: Map = emptyMap() override fun shouldShowRequestPermissionRationale(permission: String): Boolean { if (permission in shouldShowRequestPermissionRationale.keys) { return shouldShowRequestPermissionRationale[permission]!! } return super.shouldShowRequestPermissionRationale(permission) } } ================================================ FILE: permissions/src/androidTest/java/com/google/accompanist/permissions/test/PermissionsTestActivity.kt ================================================ /* * Copyright 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 * * 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. */ package com.google.accompanist.permissions.test import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.foundation.layout.Column import androidx.compose.material.Button import androidx.compose.material.Text import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.PermissionStatus import com.google.accompanist.permissions.rememberPermissionState import com.google.accompanist.permissions.shouldShowRationale @OptIn(ExperimentalPermissionsApi::class) class PermissionsTestActivity : ComponentActivity() { var shouldShowRequestPermissionRationale: Map = emptyMap() override fun shouldShowRequestPermissionRationale(permission: String): Boolean { if (permission in shouldShowRequestPermissionRationale.keys) { return shouldShowRequestPermissionRationale[permission]!! } return super.shouldShowRequestPermissionRationale(permission) } /** * Code used in `MultipleAndSinglePermissionsTest` */ override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { Column { Text("PermissionsTestActivity") val state = rememberPermissionState(android.Manifest.permission.CAMERA,) when (state.status) { PermissionStatus.Granted -> { Text("Granted") } is PermissionStatus.Denied -> { val textToShow = if (state.status.shouldShowRationale) { "ShowRationale" } else { "No permission" } Text(textToShow) Button(onClick = { state.launchPermissionRequest() }) { Text("Request") } } } } } } } ================================================ FILE: permissions/src/main/AndroidManifest.xml ================================================ ================================================ FILE: permissions/src/main/java/com/google/accompanist/permissions/MultiplePermissionsState.kt ================================================ /* * Copyright 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 * * 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. */ package com.google.accompanist.permissions import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.util.fastMap /** * Creates a [MultiplePermissionsState] that is remembered across compositions. * * It's recommended that apps exercise the permissions workflow as described in the * [documentation](https://developer.android.com/training/permissions/requesting#workflow_for_requesting_permissions). * * @param permissions the permissions to control and observe. * @param onPermissionsResult will be called with whether or not the user granted the permissions * after [MultiplePermissionsState.launchMultiplePermissionRequest] is called. */ @ExperimentalPermissionsApi @Composable public fun rememberMultiplePermissionsState( permissions: List, onPermissionsResult: (Map) -> Unit = {} ): MultiplePermissionsState { return rememberMultiplePermissionsState(permissions, onPermissionsResult, emptyMap()) } /** * Creates a [MultiplePermissionsState] that is remembered across compositions. * * It's recommended that apps exercise the permissions workflow as described in the * [documentation](https://developer.android.com/training/permissions/requesting#workflow_for_requesting_permissions). * * @param permissions the permissions to control and observe. * @param onPermissionsResult will be called with whether or not the user granted the permissions * after [MultiplePermissionsState.launchMultiplePermissionRequest] is called. * @param previewPermissionStatuses provides a [PermissionStatus] for a given permission when running * in a preview. */ @ExperimentalPermissionsApi @Composable public fun rememberMultiplePermissionsState( permissions: List, onPermissionsResult: (Map) -> Unit = {}, previewPermissionStatuses: Map = emptyMap() ): MultiplePermissionsState { return when { LocalInspectionMode.current -> PreviewMultiplePermissionsState(permissions, previewPermissionStatuses) else -> rememberMutableMultiplePermissionsState(permissions, onPermissionsResult) } } /** * A state object that can be hoisted to control and observe multiple [permissions] status changes. * * In most cases, this will be created via [rememberMultiplePermissionsState]. * * It's recommended that apps exercise the permissions workflow as described in the * [documentation](https://developer.android.com/training/permissions/requesting#workflow_for_requesting_permissions). */ @ExperimentalPermissionsApi @Stable public interface MultiplePermissionsState { /** * List of all permissions to request. */ public val permissions: List /** * List of permissions revoked by the user. */ public val revokedPermissions: List /** * When `true`, the user has granted all [permissions]. */ public val allPermissionsGranted: Boolean /** * When `true`, the user should be presented with a rationale. */ public val shouldShowRationale: Boolean /** * Request the [permissions] to the user. * * This should always be triggered from non-composable scope, for example, from a side-effect * or a non-composable callback. Otherwise, this will result in an IllegalStateException. * * This triggers a system dialog that asks the user to grant or revoke the permission. * Note that this dialog might not appear on the screen if the user doesn't want to be asked * again or has denied the permission multiple times. * This behavior varies depending on the Android level API. */ public fun launchMultiplePermissionRequest(): Unit } @OptIn(ExperimentalPermissionsApi::class) @Immutable private class PreviewMultiplePermissionsState( permissions: List, permissionStatuses: Map ) : MultiplePermissionsState { override val permissions: List = permissions.fastMap { permission -> PreviewPermissionState( permission = permission, status = permissionStatuses[permission] ?: PermissionStatus.Granted, ) } override val revokedPermissions: List = emptyList() override val allPermissionsGranted: Boolean = false override val shouldShowRationale: Boolean = false override fun launchMultiplePermissionRequest() {} } ================================================ FILE: permissions/src/main/java/com/google/accompanist/permissions/MutableMultiplePermissionsState.kt ================================================ /* * Copyright 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 * * 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. */ package com.google.accompanist.permissions import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.Stable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext /** * Creates a [MultiplePermissionsState] that is remembered across compositions. * * It's recommended that apps exercise the permissions workflow as described in the * [documentation](https://developer.android.com/training/permissions/requesting#workflow_for_requesting_permissions). * * @param permissions the permissions to control and observe. * @param onPermissionsResult will be called with whether or not the user granted the permissions * after [MultiplePermissionsState.launchMultiplePermissionRequest] is called. */ @ExperimentalPermissionsApi @Composable internal fun rememberMutableMultiplePermissionsState( permissions: List, onPermissionsResult: (Map) -> Unit = {} ): MultiplePermissionsState { // Create mutable permissions that can be requested individually val mutablePermissions = rememberMutablePermissionsState(permissions) // Refresh permissions when the lifecycle is resumed. PermissionsLifecycleCheckerEffect(mutablePermissions) val multiplePermissionsState = remember(permissions) { MutableMultiplePermissionsState(mutablePermissions) } // Remember RequestMultiplePermissions launcher and assign it to multiplePermissionsState val launcher = rememberLauncherForActivityResult( ActivityResultContracts.RequestMultiplePermissions() ) { permissionsResult -> multiplePermissionsState.updatePermissionsStatus(permissionsResult) onPermissionsResult(permissionsResult) } DisposableEffect(multiplePermissionsState, launcher) { multiplePermissionsState.launcher = launcher onDispose { multiplePermissionsState.launcher = null } } return multiplePermissionsState } @ExperimentalPermissionsApi @Composable private fun rememberMutablePermissionsState( permissions: List ): List { // Create list of MutablePermissionState for each permission val context = LocalContext.current val activity = context.findActivity() val mutablePermissions: List = remember(permissions) { return@remember permissions.map { MutablePermissionState(it, context, activity) } } // Update each permission with its own launcher for (permissionState in mutablePermissions) { key(permissionState.permission) { // Remember launcher and assign it to the permissionState val launcher = rememberLauncherForActivityResult( ActivityResultContracts.RequestPermission() ) { permissionState.refreshPermissionStatus() } DisposableEffect(launcher) { permissionState.launcher = launcher onDispose { permissionState.launcher = null } } } } return mutablePermissions } /** * A state object that can be hoisted to control and observe multiple permission status changes. * * In most cases, this will be created via [rememberMutableMultiplePermissionsState]. * * @param mutablePermissions list of mutable permissions to control and observe. */ @ExperimentalPermissionsApi @Stable internal class MutableMultiplePermissionsState( private val mutablePermissions: List ) : MultiplePermissionsState { override val permissions: List = mutablePermissions override val revokedPermissions: List by derivedStateOf { permissions.filter { it.status != PermissionStatus.Granted } } override val allPermissionsGranted: Boolean by derivedStateOf { permissions.all { it.status.isGranted } || // Up to date when the lifecycle is resumed revokedPermissions.isEmpty() // Up to date when the user launches the action } override val shouldShowRationale: Boolean by derivedStateOf { permissions.any { it.status.shouldShowRationale } && permissions.none { !it.status.isGranted && !it.status.shouldShowRationale } } override fun launchMultiplePermissionRequest() { launcher?.launch( permissions.map { it.permission }.toTypedArray() ) ?: throw IllegalStateException("ActivityResultLauncher cannot be null") } internal var launcher: ActivityResultLauncher>? = null internal fun updatePermissionsStatus(permissionsStatus: Map) { // Update all permissions with the result for (permission in permissionsStatus.keys) { mutablePermissions.firstOrNull { it.permission == permission }?.apply { permissionsStatus[permission]?.let { this.refreshPermissionStatus() } } } } } ================================================ FILE: permissions/src/main/java/com/google/accompanist/permissions/MutablePermissionState.kt ================================================ /* * Copyright 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 * * 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. */ package com.google.accompanist.permissions import android.app.Activity import android.content.Context import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext /** * Creates a [MutablePermissionState] that is remembered across compositions. * * It's recommended that apps exercise the permissions workflow as described in the * [documentation](https://developer.android.com/training/permissions/requesting#workflow_for_requesting_permissions). * * @param permission the permission to control and observe. * @param onPermissionResult will be called with whether or not the user granted the permission * after [PermissionState.launchPermissionRequest] is called. */ @ExperimentalPermissionsApi @Composable internal fun rememberMutablePermissionState( permission: String, onPermissionResult: (Boolean) -> Unit = {} ): MutablePermissionState { val context = LocalContext.current val permissionState = remember(permission) { MutablePermissionState(permission, context, context.findActivity()) } // Refresh the permission status when the lifecycle is resumed PermissionLifecycleCheckerEffect(permissionState) // Remember RequestPermission launcher and assign it to permissionState val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { permissionState.refreshPermissionStatus() onPermissionResult(it) } DisposableEffect(permissionState, launcher) { permissionState.launcher = launcher onDispose { permissionState.launcher = null } } return permissionState } /** * A mutable state object that can be used to control and observe permission status changes. * * In most cases, this will be created via [rememberMutablePermissionState]. * * @param permission the permission to control and observe. * @param context to check the status of the [permission]. * @param activity to check if the user should be presented with a rationale for [permission]. */ @ExperimentalPermissionsApi @Stable internal class MutablePermissionState( override val permission: String, private val context: Context, private val activity: Activity ) : PermissionState { override var status: PermissionStatus by mutableStateOf(getPermissionStatus()) override fun launchPermissionRequest() { launcher?.launch( permission ) ?: throw IllegalStateException("ActivityResultLauncher cannot be null") } internal var launcher: ActivityResultLauncher? = null internal fun refreshPermissionStatus() { status = getPermissionStatus() } private fun getPermissionStatus(): PermissionStatus { val hasPermission = context.checkPermission(permission) return if (hasPermission) { PermissionStatus.Granted } else { PermissionStatus.Denied(activity.shouldShowRationale(permission)) } } } ================================================ FILE: permissions/src/main/java/com/google/accompanist/permissions/PermissionState.kt ================================================ /* * Copyright 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 * * 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. */ package com.google.accompanist.permissions import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import androidx.compose.ui.platform.LocalInspectionMode /** * Creates a [PermissionState] that is remembered across compositions. * * It's recommended that apps exercise the permissions workflow as described in the * [documentation](https://developer.android.com/training/permissions/requesting#workflow_for_requesting_permissions). * * @param permission the permission to control and observe. * @param onPermissionResult will be called with whether or not the user granted the permission * after [PermissionState.launchPermissionRequest] is called. */ @ExperimentalPermissionsApi @Composable public fun rememberPermissionState( permission: String, onPermissionResult: (Boolean) -> Unit = {} ): PermissionState { return rememberPermissionState(permission, onPermissionResult, PermissionStatus.Granted) } /** * Creates a [PermissionState] that is remembered across compositions. * * It's recommended that apps exercise the permissions workflow as described in the * [documentation](https://developer.android.com/training/permissions/requesting#workflow_for_requesting_permissions). * * @param permission the permission to control and observe. * @param onPermissionResult will be called with whether or not the user granted the permission * after [PermissionState.launchPermissionRequest] is called. * @param previewPermissionStatus provides a [PermissionStatus] when running in a preview. */ @ExperimentalPermissionsApi @Composable public fun rememberPermissionState( permission: String, onPermissionResult: (Boolean) -> Unit = {}, previewPermissionStatus: PermissionStatus = PermissionStatus.Granted ): PermissionState { return when { LocalInspectionMode.current -> PreviewPermissionState(permission, previewPermissionStatus) else -> rememberMutablePermissionState(permission, onPermissionResult) } } /** * A state object that can be hoisted to control and observe [permission] status changes. * * In most cases, this will be created via [rememberPermissionState]. * * It's recommended that apps exercise the permissions workflow as described in the * [documentation](https://developer.android.com/training/permissions/requesting#workflow_for_requesting_permissions). */ @ExperimentalPermissionsApi @Stable public interface PermissionState { /** * The permission to control and observe. */ public val permission: String /** * [permission]'s status */ public val status: PermissionStatus /** * Request the [permission] to the user. * * This should always be triggered from non-composable scope, for example, from a side-effect * or a non-composable callback. Otherwise, this will result in an IllegalStateException. * * This triggers a system dialog that asks the user to grant or revoke the permission. * Note that this dialog might not appear on the screen if the user doesn't want to be asked * again or has denied the permission multiple times. * This behavior varies depending on the Android level API. */ public fun launchPermissionRequest(): Unit } @OptIn(ExperimentalPermissionsApi::class) @Immutable internal class PreviewPermissionState( override val permission: String, override val status: PermissionStatus ) : PermissionState { override fun launchPermissionRequest() {} } ================================================ FILE: permissions/src/main/java/com/google/accompanist/permissions/PermissionsUtil.kt ================================================ /* * Copyright 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 * * 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. */ package com.google.accompanist.permissions import android.app.Activity import android.content.Context import android.content.ContextWrapper import android.content.pm.PackageManager import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.Stable import androidx.compose.runtime.remember import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver @RequiresOptIn(message = "Accompanist Permissions is experimental. The API may be changed in the future.") @Retention(AnnotationRetention.BINARY) public annotation class ExperimentalPermissionsApi /** * Model of the status of a permission. It can be granted or denied. * If denied, the user might need to be presented with a rationale. */ @ExperimentalPermissionsApi @Stable public sealed interface PermissionStatus { public object Granted : PermissionStatus public data class Denied( val shouldShowRationale: Boolean ) : PermissionStatus } /** * `true` if the permission is granted. */ @ExperimentalPermissionsApi public val PermissionStatus.isGranted: Boolean get() = this == PermissionStatus.Granted /** * `true` if a rationale should be presented to the user. */ @ExperimentalPermissionsApi public val PermissionStatus.shouldShowRationale: Boolean get() = when (this) { PermissionStatus.Granted -> false is PermissionStatus.Denied -> shouldShowRationale } /** * Effect that updates the `hasPermission` state of a revoked [MutablePermissionState] permission * when the lifecycle gets called with [lifecycleEvent]. */ @ExperimentalPermissionsApi @Composable internal fun PermissionLifecycleCheckerEffect( permissionState: MutablePermissionState, lifecycleEvent: Lifecycle.Event = Lifecycle.Event.ON_RESUME ) { // Check if the permission was granted when the lifecycle is resumed. // The user might've gone to the Settings screen and granted the permission. val permissionCheckerObserver = remember(permissionState) { LifecycleEventObserver { _, event -> if (event == lifecycleEvent) { // If the permission is revoked, check again. // We don't check if the permission was denied as that triggers a process restart. if (permissionState.status != PermissionStatus.Granted) { permissionState.refreshPermissionStatus() } } } } val lifecycle = androidx.lifecycle.compose.LocalLifecycleOwner.current.lifecycle DisposableEffect(lifecycle, permissionCheckerObserver) { lifecycle.addObserver(permissionCheckerObserver) onDispose { lifecycle.removeObserver(permissionCheckerObserver) } } } /** * Effect that updates the `hasPermission` state of a list of permissions * when the lifecycle gets called with [lifecycleEvent] and the permission is revoked. */ @ExperimentalPermissionsApi @Composable internal fun PermissionsLifecycleCheckerEffect( permissions: List, lifecycleEvent: Lifecycle.Event = Lifecycle.Event.ON_RESUME ) { // Check if the permission was granted when the lifecycle is resumed. // The user might've gone to the Settings screen and granted the permission. val permissionsCheckerObserver = remember(permissions) { LifecycleEventObserver { _, event -> if (event == lifecycleEvent) { for (permission in permissions) { // If the permission is revoked, check again. We don't check if the permission // was denied as that triggers a process restart. if (permission.status != PermissionStatus.Granted) { permission.refreshPermissionStatus() } } } } } val lifecycle = androidx.lifecycle.compose.LocalLifecycleOwner.current.lifecycle DisposableEffect(lifecycle, permissionsCheckerObserver) { lifecycle.addObserver(permissionsCheckerObserver) onDispose { lifecycle.removeObserver(permissionsCheckerObserver) } } } /** * Find the closest Activity in a given Context. */ internal fun Context.findActivity(): Activity { var context = this while (context is ContextWrapper) { if (context is Activity) return context context = context.baseContext } throw IllegalStateException("Permissions should be called in the context of an Activity") } internal fun Context.checkPermission(permission: String): Boolean { return ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED } internal fun Activity.shouldShowRationale(permission: String): Boolean { return ActivityCompat.shouldShowRequestPermissionRationale(this, permission) } ================================================ FILE: permissions-lint/README.md ================================================ # Lint checks for Permissions for Jetpack Compose Lint checks for preventing calling `PermissionState.launchPermissionRequest` and `MultiplePermissionsState.launchMultiplePermissionRequest()` within the Composition as that throws a runtime exception. These functions should be called inside a regular lambda or a side-effect but never in the Composition. These lint checks will be automatically applied to your project when using 📫 [Permissions](https://google.github.io/accompanist/permissions/). ## Download Permissions for Jetpack Compose ```groovy repositories { mavenCentral() } dependencies { implementation "com.google.accompanist:accompanist-permissions:" } ``` ================================================ FILE: permissions-lint/build.gradle.kts ================================================ /* * Copyright 2023 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 * * 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. */ import com.google.accompanist.BundleInsideHelper import com.google.accompanist.BundleInsideHelper.forInsideLintJar plugins { `java-library` id("kotlin") id(libs.plugins.jetbrains.dokka.get().pluginId) id(libs.plugins.android.lint.get().pluginId) } lint { htmlReport = true htmlOutput = file("lint-report.html") textReport = true absolutePaths = false ignoreTestSources = true } affectedTestConfiguration { jvmTestTask = "test" } /** * Creates a configuration for users to use that will be used bundle these dependency * jars inside of this lint check's jar. This is required because lintPublish does * not currently support dependencies, so instead we need to bundle any dependencies with the * lint jar manually. (b/182319899) */ val bundleInside = forInsideLintJar() dependencies { // Bundle metadataJvm inside the Jar bundleInside(libs.kotlin.metadataJvm) compileOnly(libs.android.tools.lint.api) compileOnly(libs.kotlin.reflect) compileOnly(libs.kotlin.stdlib) compileOnly(libs.kotlin.stdlibJdk8) // Override version from transitive dependencies testImplementation(libs.junit) testImplementation(libs.kotlin.reflect) testImplementation(libs.kotlin.stdlib) testImplementation(libs.kotlin.stdlibJdk8) // Override version from transitive dependencies testImplementation(libs.android.tools.lint.lint) testImplementation(libs.android.tools.lint.tests) } java { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } ================================================ FILE: permissions-lint/src/main/java/com/google/accompanist/permissions/lint/PermissionsIssueRegistry.kt ================================================ /* * Copyright 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 * * 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. */ package com.google.accompanist.permissions.lint import com.android.tools.lint.client.api.IssueRegistry import com.android.tools.lint.client.api.Vendor import com.android.tools.lint.detector.api.CURRENT_API import com.android.tools.lint.detector.api.Issue @Suppress("UnstableApiUsage") public class PermissionsIssueRegistry : IssueRegistry() { override val issues: List = listOf( PermissionsLaunchDetector.PermissionLaunchedDuringComposition ) override val api: Int = 10 override val minApi: Int = CURRENT_API override val vendor: Vendor = Vendor( vendorName = "Accompanist Permissions", identifier = "com.google.accompanist.permissions", feedbackUrl = "https://github.com/google/accompanist/issues/new/choose" ) } ================================================ FILE: permissions-lint/src/main/java/com/google/accompanist/permissions/lint/PermissionsLaunchDetector.kt ================================================ /* * Copyright 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 * * 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. */ @file:Suppress("UnstableApiUsage") package com.google.accompanist.permissions.lint import com.android.tools.lint.detector.api.Category import com.android.tools.lint.detector.api.Detector import com.android.tools.lint.detector.api.Implementation import com.android.tools.lint.detector.api.Issue import com.android.tools.lint.detector.api.JavaContext import com.android.tools.lint.detector.api.Scope import com.android.tools.lint.detector.api.Severity import com.android.tools.lint.detector.api.SourceCodeScanner import com.google.accompanist.permissions.lint.util.Name import com.google.accompanist.permissions.lint.util.Package import com.google.accompanist.permissions.lint.util.PackageName import com.google.accompanist.permissions.lint.util.isInvokedWithinComposable import com.intellij.psi.PsiJavaFile import com.intellij.psi.PsiMethod import org.jetbrains.uast.UCallExpression import java.util.EnumSet /** * [Detector] that checks `PermissionState.launchPermissionRequest` and * `MultiplePermissionsState.launchMultiplePermissionRequest` calls to make sure they don't happen * inside the body of a composable function / lambda. */ public class PermissionsLaunchDetector : Detector(), SourceCodeScanner { override fun getApplicableMethodNames(): List = listOf( LaunchPermissionRequest.shortName, LaunchMultiplePermissionsRequest.shortName ) override fun visitMethodCall(context: JavaContext, node: UCallExpression, method: PsiMethod) { if (!method.isInPackageName(PermissionsPackageName)) return if (node.isInvokedWithinComposable()) { context.report( PermissionLaunchedDuringComposition, node, context.getNameLocation(node), "Calls to ${method.name} should happen inside a regular lambda or " + " a side-effect, but never in the Composition." ) } } public companion object { public val PermissionLaunchedDuringComposition: Issue = Issue.create( "PermissionLaunchedDuringComposition", "Calls to `launchPermissionRequest` or `launchMultiplePermissionRequest` " + "should happen inside a regular lambda or a side-effect but never in the " + "Composition.", "Calls to `launchPermissionRequest` or `launchMultiplePermissionRequest` " + "in the Composition throw a runtime exception. Please call them inside a regular " + "lambda or in a side-effect.", Category.CORRECTNESS, 3, Severity.ERROR, Implementation( PermissionsLaunchDetector::class.java, EnumSet.of(Scope.JAVA_FILE, Scope.TEST_SOURCES) ) ) } } /** * Returns whether [this] has [packageName] as its package name. */ private fun PsiMethod.isInPackageName(packageName: PackageName): Boolean = packageName.javaPackageName == (containingFile as? PsiJavaFile)?.packageName private val PermissionsPackageName = Package("com.google.accompanist.permissions") private val LaunchPermissionRequest = Name(PermissionsPackageName, "launchPermissionRequest") private val LaunchMultiplePermissionsRequest = Name(PermissionsPackageName, "launchMultiplePermissionRequest") ================================================ FILE: permissions-lint/src/main/java/com/google/accompanist/permissions/lint/util/ComposableUtils.kt ================================================ /* * Copyright 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 * * 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. */ @file:Suppress("FunctionName") package com.google.accompanist.permissions.lint.util // FILE COPIED FROM: // https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/lint/common/src/main/java/androidx/compose/lint/ComposableUtils.kt import com.intellij.lang.java.JavaLanguage import com.intellij.psi.PsiMethod import com.intellij.psi.PsiParameter import com.intellij.psi.impl.compiled.ClsParameterImpl import com.intellij.psi.impl.light.LightParameter import org.jetbrains.kotlin.psi.KtAnnotated import org.jetbrains.kotlin.psi.KtFunction import org.jetbrains.kotlin.psi.KtProperty import org.jetbrains.kotlin.psi.KtTypeReference import org.jetbrains.kotlin.psi.psiUtil.getParentOfType import org.jetbrains.uast.UAnnotation import org.jetbrains.uast.UAnonymousClass import org.jetbrains.uast.UCallExpression import org.jetbrains.uast.UDeclaration import org.jetbrains.uast.UElement import org.jetbrains.uast.UExpression import org.jetbrains.uast.ULambdaExpression import org.jetbrains.uast.UMethod import org.jetbrains.uast.UParameter import org.jetbrains.uast.UTypeReferenceExpression import org.jetbrains.uast.UVariable import org.jetbrains.uast.getContainingDeclaration import org.jetbrains.uast.getContainingUClass import org.jetbrains.uast.getParameterForArgument import org.jetbrains.uast.toUElement import org.jetbrains.uast.withContainingElements import kotlin.metadata.jvm.annotations /** * Returns whether this [UCallExpression] is directly invoked within the body of a Composable * function or lambda without being `remember`ed. */ public fun UCallExpression.isNotRemembered(): Boolean = isNotRememberedWithKeys() /** * Returns whether this [UCallExpression] is directly invoked within the body of a Composable * function or lambda without being `remember`ed, or whether it is invoked inside a `remember call * without the provided [keys][keyClassNames]. * - Returns true if this [UCallExpression] is directly invoked inside a Composable function or * lambda without being `remember`ed * - Returns true if this [UCallExpression] is invoked inside a call to `remember`, but without all * of the provided [keys][keyClassNames] being used as key parameters to `remember` * - Returns false if this [UCallExpression] is correctly `remember`ed with the provided * [keys][keyClassNames], or is not called inside a `remember` block, and is not called inside a * Composable function or lambda * * @param keyClassNames [Name]s representing the expected classes that should be used as a key * parameter to the `remember` call */ public fun UCallExpression.isNotRememberedWithKeys(vararg keyClassNames: Name): Boolean { val visitor = ComposableBodyVisitor(this) // The nearest method or lambda expression that contains this call expression val boundaryElement = visitor.parentUElements().last() // Check if the nearest lambda expression is actually a call to remember val rememberCall: UCallExpression? = (boundaryElement.uastParent as? UCallExpression)?.takeIf { it.methodName == Names.Runtime.Remember.shortName && it.resolve()?.isInPackageName(Names.Runtime.PackageName) == true } return if (rememberCall == null) { visitor.isComposable() } else { val parameterTypes = rememberCall.valueArguments.mapNotNull { it.getExpressionType()?.canonicalText } !keyClassNames.all { parameterTypes.contains(it.javaFqn) } } } /** * Returns whether this [UExpression] is invoked within the body of a Composable function or lambda. * * This searches parent declarations until we find a lambda expression or a function, and looks to * see if these are Composable. */ fun UExpression.isInvokedWithinComposable(): Boolean { return ComposableBodyVisitor(this).isComposable() } // TODO: https://youtrack.jetbrains.com/issue/KT-45406 // KotlinUMethodWithFakeLightDelegate.hasAnnotation() (for reified functions for example) // doesn't find annotations, so just look at the annotations directly. /** Returns whether this method is @Composable or not */ val PsiMethod.isComposable get() = annotations.any { it.qualifiedName == Names.Runtime.Composable.javaFqn } /** Returns whether this variable's type is @Composable or not */ val UVariable.isComposable: Boolean get() { // Annotation on the lambda val annotationOnLambda = when (val initializer = uastInitializer) { is ULambdaExpression -> { val source = initializer.sourcePsi if (source is KtFunction) { // Anonymous function, val foo = @Composable fun() {} source.hasComposableAnnotation } else { // Lambda, val foo = @Composable {} initializer.findAnnotation(Names.Runtime.Composable.javaFqn) != null } } else -> false } // Annotation on the type, foo: @Composable () -> Unit = { } val annotationOnType = typeReference?.isComposable == true return annotationOnLambda || annotationOnType } /** Returns whether this parameter's type is @Composable or not */ private val PsiParameter.isComposable: Boolean get() = when { // The parameter is in a class file. Currently type annotations aren't currently added // to // the underlying type (https://youtrack.jetbrains.com/issue/KT-45307), so instead we // use // the metadata annotation. this is ClsParameterImpl || // In some cases when a method is defined in bytecode and the call fails to resolve // to the ClsMethodImpl, we will instead get a LightParameter. Note that some Kotlin // declarations too will also appear as a LightParameter, so we can check to see if // the source language is Java, which means that this is a LightParameter for // bytecode, as opposed to for a Kotlin declaration. // https://youtrack.jetbrains.com/issue/KT-46883 (this is LightParameter && this.language is JavaLanguage) -> { // Find the containing method, so we can get metadata from the containing class val containingMethod = getParentOfType(true) val kmFunction = containingMethod!!.toKmFunction() val kmValueParameter = kmFunction?.valueParameters?.find { it.name == name } kmValueParameter?.type?.annotations?.find { it.className == Names.Runtime.Composable.kmClassName } != null } // The parameter is in a source declaration else -> (toUElement() as? UParameter)?.typeReference?.isComposable == true } /** Returns whether this lambda expression is @Composable or not */ val ULambdaExpression.isComposable: Boolean get() = when (val lambdaParent = uastParent) { // Function call with a lambda parameter is UCallExpression -> { val parameter = lambdaParent.getParameterForArgument(this) parameter?.isComposable == true } // A local / non-local lambda variable is UVariable -> { lambdaParent.isComposable } // Either a new UAST type we haven't handled, or non-Kotlin declarations else -> false } /** * Helper class that visits parent declarations above the provided [expression], until it finds a * lambda or method. This 'boundary' is used as the indicator for whether this [expression] can be * considered to be inside a Composable body or not. * * @see isComposable * @see parentUElements */ private class ComposableBodyVisitor(private val expression: UExpression) { /** @return whether the body can be considered Composable or not */ fun isComposable(): Boolean = when (val element = parentUElements.last()) { is UMethod -> element.isComposable is ULambdaExpression -> element.isComposable else -> false } /** Returns all parent [UElement]s until and including the boundary lambda / method. */ fun parentUElements() = parentUElements /** * The outermost UElement that corresponds to the surrounding UDeclaration that contains * [expression], with the following special cases: * - if the containing UDeclaration is a local property, we ignore it and search above as it * still could be created in the context of a Composable body * - if the containing UDeclaration is an anonymous class (object { }), we ignore it and search * above as it still could be created in the context of a Composable body */ private val boundaryUElement by lazy { // The nearest property / function / etc declaration that contains this call expression var containingDeclaration = expression.getContainingDeclaration() fun UDeclaration.isLocalProperty() = (sourcePsi as? KtProperty)?.isLocal == true fun UDeclaration.isAnonymousClass() = this is UAnonymousClass fun UDeclaration.isPropertyInsideAnonymousClass() = getContainingUClass()?.isAnonymousClass() == true while ( containingDeclaration != null && ( containingDeclaration.isLocalProperty() || containingDeclaration.isAnonymousClass() || containingDeclaration.isPropertyInsideAnonymousClass() ) ) { containingDeclaration = containingDeclaration.getContainingDeclaration() } containingDeclaration } private val parentUElements by lazy { val elements = mutableListOf() // Look through containing elements until we find a lambda or a method for (element in expression.withContainingElements) { elements += element when (element) { // TODO: consider handling the case of a lambda inside an inline function call, // such as `apply` or `forEach`. These calls don't really change the // 'composability' here, but there may be other inline function calls that // capture the lambda and invoke it elsewhere, so we might need to look for // a callsInPlace contract in the metadata for the function, or the body of the // source definition. is ULambdaExpression -> break is UMethod -> break // Stop when we reach the parent declaration to avoid escaping the scope. boundaryUElement -> break } } elements } } /** Returns whether this type reference is @Composable or not */ val UTypeReferenceExpression.isComposable: Boolean get() { if (type.hasAnnotation(Names.Runtime.Composable.javaFqn)) return true // Annotations on the types of local properties (val foo: @Composable () -> Unit = {}) // are currently not present on the PsiType, we so need to manually check the underlying // type reference. (https://youtrack.jetbrains.com/issue/KTIJ-18821) return (sourcePsi as? KtTypeReference)?.hasComposableAnnotation == true } /** Returns whether this annotated declaration has a Composable annotation */ private val KtAnnotated.hasComposableAnnotation: Boolean get() = annotationEntries.any { (it.toUElement() as UAnnotation).qualifiedName == Names.Runtime.Composable.javaFqn } ================================================ FILE: permissions-lint/src/main/java/com/google/accompanist/permissions/lint/util/KotlinMetadataUtils.kt ================================================ /* * Copyright 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 * * 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. */ package com.google.accompanist.permissions.lint.util // FILE COPIED FROM: // https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/lint/common/src/main/java/androidx/compose/lint/KotlinMetadataUtils.kt import com.intellij.lang.jvm.annotation.JvmAnnotationArrayValue import com.intellij.lang.jvm.annotation.JvmAnnotationAttributeValue import com.intellij.lang.jvm.annotation.JvmAnnotationConstantValue import com.intellij.psi.PsiAnnotation import com.intellij.psi.PsiClass import com.intellij.psi.PsiMethod import com.intellij.psi.impl.compiled.ClsMethodImpl import com.intellij.psi.util.ClassUtil import kotlin.metadata.KmDeclarationContainer import kotlin.metadata.KmFunction import kotlin.metadata.jvm.KotlinClassMetadata import kotlin.metadata.jvm.Metadata import kotlin.metadata.jvm.signature /** * @return the corresponding [KmFunction] for this [PsiMethod], or `null` if there is no * corresponding [KmFunction]. This method is only meaningful if this [PsiMethod] represents a * method defined in bytecode (most often a [ClsMethodImpl]). */ public fun PsiMethod.toKmFunction(): KmFunction? = containingClass!!.getKmDeclarationContainer()?.findKmFunctionForPsiMethod(this) // TODO: https://youtrack.jetbrains.com/issue/KT-45310 // Currently there is no built in support for parsing kotlin metadata from kotlin class files, so // we need to manually inspect the annotations and work with Cls* (compiled PSI). /** * Returns the [KmDeclarationContainer] using the kotlin.Metadata annotation present on this * [PsiClass]. Returns null if there is no annotation (not parsing a Kotlin class file), the * annotation data is for an unsupported version of Kotlin, or if the metadata represents a * synthetic class. */ private fun PsiClass.getKmDeclarationContainer(): KmDeclarationContainer? { val classKotlinMetadataPsiAnnotation = annotations.find { // hasQualifiedName() not available on the min version of Lint we compile against it.qualifiedName == KotlinMetadataFqn } ?: return null val metadata = try { KotlinClassMetadata.readStrict(classKotlinMetadataPsiAnnotation.toMetadataAnnotation()) } catch (e: Exception) { // Don't crash if we are trying to parse metadata from a newer version of Kotlin, than // is // supported by the bundled version of kotlin-metadata-jvm return null } return when (metadata) { is KotlinClassMetadata.Class -> metadata.kmClass is KotlinClassMetadata.FileFacade -> metadata.kmPackage is KotlinClassMetadata.SyntheticClass -> null is KotlinClassMetadata.MultiFileClassFacade -> null is KotlinClassMetadata.MultiFileClassPart -> metadata.kmPackage is KotlinClassMetadata.Unknown -> null } } /** Returns a [Metadata] by parsing the attributes of this @kotlin.Metadata PSI annotation. */ private fun PsiAnnotation.toMetadataAnnotation(): Metadata { val attributes = attributes.associate { it.attributeName to it.attributeValue } fun JvmAnnotationAttributeValue.parseString(): String = (this as JvmAnnotationConstantValue).constantValue as String fun JvmAnnotationAttributeValue.parseInt(): Int = (this as JvmAnnotationConstantValue).constantValue as Int fun JvmAnnotationAttributeValue.parseStringArray(): Array = (this as JvmAnnotationArrayValue).values.map { it.parseString() }.toTypedArray() fun JvmAnnotationAttributeValue.parseIntArray(): IntArray = (this as JvmAnnotationArrayValue).values.map { it.parseInt() }.toTypedArray().toIntArray() val kind = attributes["k"]?.parseInt() val metadataVersion = attributes["mv"]?.parseIntArray() val data1 = attributes["d1"]?.parseStringArray() val data2 = attributes["d2"]?.parseStringArray() val extraString = attributes["xs"]?.parseString() val packageName = attributes["pn"]?.parseString() val extraInt = attributes["xi"]?.parseInt() return Metadata(kind, metadataVersion, data1, data2, extraString, packageName, extraInt) } /** * @return the corresponding [KmFunction] in [this] for the given [method], matching by name and * signature. */ private fun KmDeclarationContainer.findKmFunctionForPsiMethod(method: PsiMethod): KmFunction? { // Strip any mangled part of the name in case of value / inline classes val expectedName = method.name.substringBefore("-") val expectedSignature = ClassUtil.getAsmMethodSignature(method) // Since Kotlin 1.6 PSI updates, in some cases what used to be `void` return types are converted // to `kotlin.Unit`, even though in the actual metadata they are still void. Try to match those // cases as well val unitReturnTypeSuffix = "Lkotlin/Unit;" val expectedSignatureConvertedFromUnitToVoid = if (expectedSignature.endsWith(unitReturnTypeSuffix)) { expectedSignature.substringBeforeLast(unitReturnTypeSuffix) + "V" } else { expectedSignature } return functions.find { it.name == expectedName && ( it.signature?.descriptor == expectedSignature || it.signature?.descriptor == expectedSignatureConvertedFromUnitToVoid ) } } private const val KotlinMetadataFqn = "kotlin.Metadata" ================================================ FILE: permissions-lint/src/main/java/com/google/accompanist/permissions/lint/util/Names.kt ================================================ /* * Copyright 2024 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 * * 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. */ package com.google.accompanist.permissions.lint.util /** * File copied from * https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/lint/common/src/main/java/androidx/compose/lint/Names.kt */ import kotlin.metadata.ClassName /** Contains common names used for lint checks. */ object Names { object Animation { val PackageName = Package("androidx.compose.animation") object Core { val PackageName = Package("androidx.compose.animation.core") val Animatable = Name(PackageName, "Animatable") } } object AnimationCore { val PackageName = Package("androidx.compose.animation.core") } object Runtime { val PackageName = Package("androidx.compose.runtime") val Composable = Name(PackageName, "Composable") val CompositionLocal = Name(PackageName, "CompositionLocal") val DerivedStateOf = Name(PackageName, "derivedStateOf") val State = Name(PackageName, "State") val MutableState = Name(PackageName, "MutableState") val MutableStateOf = Name(PackageName, "mutableStateOf") val MutableIntStateOf = Name(PackageName, "mutableIntStateOf") val MutableLongStateOf = Name(PackageName, "mutableLongStateOf") val MutableFloatStateOf = Name(PackageName, "mutableFloatStateOf") val MutableDoubleStateOf = Name(PackageName, "mutableDoubleStateOf") val MutableStateListOf = Name(PackageName, "mutableStateListOf") val MutableStateMapOf = Name(PackageName, "mutableStateMapOf") val ProduceState = Name(PackageName, "produceState") val Remember = Name(PackageName, "remember") val DisposableEffect = Name(PackageName, "DisposableEffect") val RememberSaveable = Name(PackageName, "rememberSaveable") val LaunchedEffect = Name(PackageName, "LaunchedEffect") val ReusableContent = Name(PackageName, "ReusableContent") val Key = Name(PackageName, "key") val StructuralEqualityPolicy = Name(PackageName, "structuralEqualityPolicy") } object Ui { val PackageName = Package("androidx.compose.ui") val Composed = Name(PackageName, "composed") val Modifier = Name(PackageName, "Modifier") object Layout { val PackageName = Package("androidx.compose.ui.layout") val ParentDataModifier = Name(PackageName, "ParentDataModifier") } object Pointer { val PackageName = Package(Ui.PackageName, "input.pointer") val PointerInputScope = Name(PackageName, "PointerInputScope") val PointerInputScopeModifier = Name(PackageName, "pointerInput") val AwaitPointerEventScope = Name(PackageName, "awaitPointerEventScope") } object Unit { val PackageName = Package("androidx.compose.ui.unit") val Dp = Name(PackageName, "Dp") } object Node { val PackageName = Package(Ui.PackageName, "node") val CurrentValueOf = Name(PackageName, "currentValueOf") } } object UiGraphics { val PackageName = Package("androidx.compose.ui.graphics") val Color = Name(PackageName, "Color") } } /** * Represents a qualified package * * @property segments the segments representing the package */ class PackageName internal constructor(internal val segments: List) { /** The Java-style package name for this [Name], separated with `.` */ val javaPackageName: String get() = segments.joinToString(".") } /** * Represents the qualified name for an element * * @property pkg the package for this element * @property nameSegments the segments representing the element - there can be multiple in the case * of nested classes. */ class Name internal constructor(private val pkg: PackageName, private val nameSegments: List) { /** The short name for this [Name] */ val shortName: String get() = nameSegments.last() /** The Java-style fully qualified name for this [Name], separated with `.` */ val javaFqn: String get() = pkg.segments.joinToString(".", postfix = ".") + nameSegments.joinToString(".") /** * The [ClassName] for use with kotlin.metadata. Note that in kotlin.metadata the actual type * might be different from the underlying JVM type, for example: kotlin/Int -> java/lang/Integer */ val kmClassName: ClassName get() = pkg.segments.joinToString("/", postfix = "/") + nameSegments.joinToString(".") /** The [PackageName] of this element. */ val packageName: PackageName get() = pkg } /** @return a [PackageName] with a Java-style (separated with `.`) [packageName]. */ fun Package(packageName: String): PackageName = PackageName(packageName.split(".")) /** @return a [PackageName] with a Java-style (separated with `.`) [packageName]. */ fun Package(packageName: PackageName, shortName: String): PackageName = PackageName(packageName.segments + shortName.split(".")) /** @return a [Name] with the provided [pkg] and Java-style (separated with `.`) [shortName]. */ fun Name(pkg: PackageName, shortName: String): Name = Name(pkg, shortName.split(".")) ================================================ FILE: permissions-lint/src/main/java/com/google/accompanist/permissions/lint/util/PsiUtils.kt ================================================ /* * Copyright 2024 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 * * 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. */ package com.google.accompanist.permissions.lint.util import com.intellij.psi.PsiClass import com.intellij.psi.PsiClassOwner import com.intellij.psi.PsiMethod import com.intellij.psi.PsiType import com.intellij.psi.util.InheritanceUtil /** * File copied from * https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/lint/common/src/main/java/androidx/compose/lint/PsiUtils.kt */ /** Returns whether [this] has [packageName] as its package name. */ fun PsiMethod.isInPackageName(packageName: PackageName): Boolean { val actual = (containingFile as? PsiClassOwner)?.packageName return packageName.javaPackageName == actual } /** Whether this [PsiMethod] returns Unit */ val PsiMethod.returnsUnit get() = returnType.isVoidOrUnit /** * Whether this [PsiType] is `void` or [Unit] * * In Kotlin 1.6 some expressions now explicitly return [Unit] instead of just being [PsiType.VOID], * so this returns whether this type is either. */ val PsiType?.isVoidOrUnit get() = this == PsiType.VOID || this?.canonicalText == "kotlin.Unit" /** @return whether [this] inherits from [name]. Returns `true` if [this] _is_ directly [name]. */ fun PsiType.inheritsFrom(name: Name) = InheritanceUtil.isInheritor(this, name.javaFqn) /** @return whether [this] inherits from [name]. Returns `true` if [this] _is_ directly [name]. */ fun PsiClass.inheritsFrom(name: Name) = InheritanceUtil.isInheritor(this, name.javaFqn) ================================================ FILE: permissions-lint/src/main/resources/META-INF/services/com.android.tools.lint.client.api.IssueRegistry ================================================ # # Copyright 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 # # 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. # com.google.accompanist.permissions.lint.PermissionsIssueRegistry ================================================ FILE: permissions-lint/src/test/java/com/google/accompanist/permissions/lint/PermissionsLaunchDetectorTest.kt ================================================ /* * Copyright 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 * * 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. */ @file:Suppress("UnstableApiUsage") package com.google.accompanist.permissions.lint import com.android.tools.lint.checks.infrastructure.TestFiles import com.android.tools.lint.checks.infrastructure.TestLintResult import com.android.tools.lint.checks.infrastructure.TestLintTask import org.junit.Test import org.junit.runner.RunWith import org.junit.runners.JUnit4 /* ktlint-disable max-line-length */ /** * Test for [PermissionsLaunchDetector]. */ @RunWith(JUnit4::class) internal class PermissionsLaunchDetectorTest { private fun check(fileToAdd: String): TestLintResult { return TestLintTask.lint() .files( LaunchPermissionsStub, ComposableStub, TestFiles.kt(fileToAdd) ) .allowMissingSdk() .issues(PermissionsLaunchDetector.PermissionLaunchedDuringComposition) .run() } @Test fun errors() { check( """ import androidx.compose.runtime.Composable import com.google.accompanist.permissions.* @Composable fun Test() { PermissionState().launchPermissionRequest() MultiplePermissionsState().launchMultiplePermissionRequest() } val lambda = @Composable { PermissionState().launchPermissionRequest() MultiplePermissionsState().launchMultiplePermissionRequest() } val lambda2: @Composable () -> Unit = { PermissionState().launchPermissionRequest() MultiplePermissionsState().launchMultiplePermissionRequest() } @Composable fun LambdaParameter(content: @Composable () -> Unit) {} @Composable fun Test2() { LambdaParameter(content = { PermissionState().launchPermissionRequest() MultiplePermissionsState().launchMultiplePermissionRequest() }) LambdaParameter { PermissionState().launchPermissionRequest() MultiplePermissionsState().launchMultiplePermissionRequest() } } fun test3() { val localLambda1 = @Composable { PermissionState().launchPermissionRequest() MultiplePermissionsState().launchMultiplePermissionRequest() } val localLambda2: @Composable () -> Unit = { PermissionState().launchPermissionRequest() MultiplePermissionsState().launchMultiplePermissionRequest() } } """ ) .expect( """ src/test.kt:7: Error: Calls to launchPermissionRequest should happen inside a regular lambda or a side-effect, but never in the Composition. [PermissionLaunchedDuringComposition] PermissionState().launchPermissionRequest() ~~~~~~~~~~~~~~~~~~~~~~~ src/test.kt:8: Error: Calls to launchMultiplePermissionRequest should happen inside a regular lambda or a side-effect, but never in the Composition. [PermissionLaunchedDuringComposition] MultiplePermissionsState().launchMultiplePermissionRequest() ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ src/test.kt:12: Error: Calls to launchPermissionRequest should happen inside a regular lambda or a side-effect, but never in the Composition. [PermissionLaunchedDuringComposition] PermissionState().launchPermissionRequest() ~~~~~~~~~~~~~~~~~~~~~~~ src/test.kt:13: Error: Calls to launchMultiplePermissionRequest should happen inside a regular lambda or a side-effect, but never in the Composition. [PermissionLaunchedDuringComposition] MultiplePermissionsState().launchMultiplePermissionRequest() ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ src/test.kt:17: Error: Calls to launchPermissionRequest should happen inside a regular lambda or a side-effect, but never in the Composition. [PermissionLaunchedDuringComposition] PermissionState().launchPermissionRequest() ~~~~~~~~~~~~~~~~~~~~~~~ src/test.kt:18: Error: Calls to launchMultiplePermissionRequest should happen inside a regular lambda or a side-effect, but never in the Composition. [PermissionLaunchedDuringComposition] MultiplePermissionsState().launchMultiplePermissionRequest() ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ src/test.kt:27: Error: Calls to launchPermissionRequest should happen inside a regular lambda or a side-effect, but never in the Composition. [PermissionLaunchedDuringComposition] PermissionState().launchPermissionRequest() ~~~~~~~~~~~~~~~~~~~~~~~ src/test.kt:28: Error: Calls to launchMultiplePermissionRequest should happen inside a regular lambda or a side-effect, but never in the Composition. [PermissionLaunchedDuringComposition] MultiplePermissionsState().launchMultiplePermissionRequest() ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ src/test.kt:31: Error: Calls to launchPermissionRequest should happen inside a regular lambda or a side-effect, but never in the Composition. [PermissionLaunchedDuringComposition] PermissionState().launchPermissionRequest() ~~~~~~~~~~~~~~~~~~~~~~~ src/test.kt:32: Error: Calls to launchMultiplePermissionRequest should happen inside a regular lambda or a side-effect, but never in the Composition. [PermissionLaunchedDuringComposition] MultiplePermissionsState().launchMultiplePermissionRequest() ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ src/test.kt:38: Error: Calls to launchPermissionRequest should happen inside a regular lambda or a side-effect, but never in the Composition. [PermissionLaunchedDuringComposition] PermissionState().launchPermissionRequest() ~~~~~~~~~~~~~~~~~~~~~~~ src/test.kt:39: Error: Calls to launchMultiplePermissionRequest should happen inside a regular lambda or a side-effect, but never in the Composition. [PermissionLaunchedDuringComposition] MultiplePermissionsState().launchMultiplePermissionRequest() ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ src/test.kt:43: Error: Calls to launchPermissionRequest should happen inside a regular lambda or a side-effect, but never in the Composition. [PermissionLaunchedDuringComposition] PermissionState().launchPermissionRequest() ~~~~~~~~~~~~~~~~~~~~~~~ src/test.kt:44: Error: Calls to launchMultiplePermissionRequest should happen inside a regular lambda or a side-effect, but never in the Composition. [PermissionLaunchedDuringComposition] MultiplePermissionsState().launchMultiplePermissionRequest() ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 14 errors, 0 warnings """.trimIndent() ) } @Test fun noErrors() { check( """ import com.google.accompanist.permissions.* fun test() { PermissionState().launchPermissionRequest() MultiplePermissionsState().launchMultiplePermissionRequest() } val lambda = { PermissionState().launchPermissionRequest() MultiplePermissionsState().launchMultiplePermissionRequest() } val lambda2: () -> Unit = { PermissionState().launchPermissionRequest() MultiplePermissionsState().launchMultiplePermissionRequest() } fun lambdaParameter(action: () -> Unit) {} fun test2() { lambdaParameter(action = { PermissionState().launchPermissionRequest() MultiplePermissionsState().launchMultiplePermissionRequest() }) lambdaParameter { PermissionState().launchPermissionRequest() MultiplePermissionsState().launchMultiplePermissionRequest() } } fun test3() { val localLambda1 = { PermissionState().launchPermissionRequest() MultiplePermissionsState().launchMultiplePermissionRequest() } val localLambda2: () -> Unit = { PermissionState().launchPermissionRequest() MultiplePermissionsState().launchMultiplePermissionRequest() } } """ ) .expectClean() } } private val LaunchPermissionsStub = TestFiles.kt( "com/google/accompanist/permissions/LaunchPermissions.kt", """ package com.google.accompanist.permissions class PermissionState { fun launchPermissionRequest() } class MultiplePermissionsState { fun launchMultiplePermissionRequest() } """ ).indented().within("src") private val ComposableStub = TestFiles.kt( "androidx/compose/runtime/Composable.kt", """ package androidx.compose.runtime annotation class Composable """ ).indented().within("src") /* ktlint-enable max-line-length */ ================================================ FILE: release/signing-cleanup.sh ================================================ #!/bin/sh # Copyright 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 # # 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. rm -f release/*.gpg rm -f release/*.properties ================================================ FILE: release/signing-setup.sh ================================================ #!/bin/bash # Copyright 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 # # 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. ENCRYPT_KEY=$1 if [[ ! -z "$ENCRYPT_KEY" ]]; then # Decrypt GnuPG keyring openssl aes-256-cbc -md sha256 -d -in release/secring.gpg.aes -out release/secring.gpg -k ${ENCRYPT_KEY} # Decrypt Play Store key openssl aes-256-cbc -md sha256 -d -in release/signing.properties.aes -out release/signing.properties -k ${ENCRYPT_KEY} else echo "ENCRYPT_KEY is empty" fi ================================================ FILE: sample/build.gradle.kts ================================================ /* * Copyright 2023 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 * * 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. */ @file:Suppress("UnstableApiUsage") plugins { alias(libs.plugins.android.application) alias(libs.plugins.android.kotlin) alias(libs.plugins.compose.plugin) } android { compileSdk = 35 defaultConfig { applicationId = "com.google.accompanist.sample" minSdk = 21 targetSdk = 35 versionCode = 1 versionName = "1.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 isCoreLibraryDesugaringEnabled = true } buildFeatures { compose = true } buildTypes { getByName("release") { signingConfig = signingConfigs.getByName("debug") } } kotlinOptions { jvmTarget = "17" } namespace = "com.google.accompanist.sample" } dependencies { coreLibraryDesugaring(libs.desugar.jdk.libs) implementation(project(":adaptive")) implementation(project(":drawablepainter")) implementation(project(":permissions")) implementation(libs.compose.material.iconsext) implementation(libs.compose.material3.material3) implementation(libs.compose.foundation.layout) debugImplementation(libs.compose.ui.tooling) implementation(libs.compose.ui.tooling.preview) implementation(libs.compose.ui.util) implementation(libs.androidx.core) implementation(libs.androidx.activity.compose) implementation(libs.androidx.lifecycle.runtime) implementation(libs.kotlin.stdlib) lintChecks(project(":permissions-lint")) } ================================================ FILE: sample/src/main/AndroidManifest.xml ================================================ ================================================ FILE: sample/src/main/java/com/google/accompanist/sample/ImageLoadingSampleUtils.kt ================================================ /* * Copyright 2020 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 * * 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. */ package com.google.accompanist.sample import androidx.compose.runtime.Composable import androidx.compose.runtime.remember private val rangeForRandom = (0..100000) fun randomSampleImageUrl( seed: Int = rangeForRandom.random(), width: Int = 300, height: Int = width, ): String { return "https://picsum.photos/seed/$seed/$width/$height" } /** * Remember a URL generate by [randomSampleImageUrl]. */ @Composable fun rememberRandomSampleImageUrl( seed: Int = rangeForRandom.random(), width: Int = 300, height: Int = width, ): String = remember { randomSampleImageUrl(seed, width, height) } ================================================ FILE: sample/src/main/java/com/google/accompanist/sample/MainActivity.kt ================================================ /* * Copyright 2020 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 * * 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. */ package com.google.accompanist.sample import android.annotation.SuppressLint import android.content.Intent import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge /** * A list which automatically populates the list of sample activities in this app * with the category `com.google.accompanist.sample.SAMPLE_CODE`. */ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() val data = getData(intent.getStringExtra(EXTRA_PATH)) setContent { AccompanistSampleTheme { MainScreen( listData = data, onItemClick = { startActivity(it) } ) } } } private fun getData(prefix: String?): List { val myData = mutableListOf() val mainIntent = Intent(Intent.ACTION_MAIN, null) mainIntent.addCategory("com.google.accompanist.sample.SAMPLE_CODE") @SuppressLint("QueryPermissionsNeeded") // Only querying our own Activities val list = packageManager.queryIntentActivities(mainIntent, 0) val prefixPath: Array? var prefixWithSlash = prefix if (prefix.isNullOrEmpty()) { prefixPath = null } else { prefixPath = prefix.split("/".toRegex()).toTypedArray() prefixWithSlash = "$prefix/" } val entries = mutableMapOf() list.forEach { info -> val labelSeq = info.loadLabel(packageManager) val label = labelSeq?.toString() ?: info.activityInfo.name if (prefixWithSlash.isNullOrEmpty() || label.startsWith(prefixWithSlash)) { val labelPath = label.split("/".toRegex()).toTypedArray() val nextLabel = if (prefixPath == null) labelPath[0] else labelPath[prefixPath.size] if ((prefixPath?.size ?: 0) == labelPath.size - 1) { myData.add( AccompanistSample( title = nextLabel, intent = activityIntent( info.activityInfo.applicationInfo.packageName, info.activityInfo.name ) ) ) } else { if (entries[nextLabel] == null) { myData.add( AccompanistSample( title = nextLabel, intent = browseIntent( if (prefix == "") nextLabel else "$prefix/$nextLabel" ) ) ) entries[nextLabel] = true } } } } myData.sortBy { it.title } return myData } private fun activityIntent(pkg: String, componentName: String): Intent { val result = Intent() result.setClassName(pkg, componentName) return result } private fun browseIntent(path: String): Intent { val result = Intent() result.setClass(this, MainActivity::class.java) result.putExtra(EXTRA_PATH, path) return result } } private const val EXTRA_PATH = "com.example.android.apis.Path" ================================================ FILE: sample/src/main/java/com/google/accompanist/sample/MainScreen.kt ================================================ /* * Copyright 2024 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 * * 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. */ package com.google.accompanist.sample import android.content.Intent import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.windowInsetsBottomHeight import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ListItem import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource data class AccompanistSample( val title: String, val intent: Intent ) @OptIn(ExperimentalMaterial3Api::class) @Composable fun MainScreen( listData: List, onItemClick: (Intent) -> Unit, modifier: Modifier = Modifier ) { Surface(modifier) { Column { val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) TopAppBar( title = { Text(stringResource(R.string.app_name)) }, scrollBehavior = scrollBehavior ) ContentList( listData, onItemClick, modifier = Modifier .fillMaxSize() .windowInsetsPadding( WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal) ) .nestedScroll(scrollBehavior.nestedScrollConnection) ) } } } @OptIn(ExperimentalMaterial3Api::class) @Composable private fun ContentList( listData: List, onItemClick: (Intent) -> Unit, modifier: Modifier = Modifier ) { LazyColumn( modifier = modifier ) { items(listData) { ListItem( headlineText = { Text(it.title) }, modifier = Modifier.clickable { onItemClick(it.intent) } ) } item { Spacer( Modifier.windowInsetsBottomHeight( WindowInsets.systemBars ) ) } } } ================================================ FILE: sample/src/main/java/com/google/accompanist/sample/Theme.kt ================================================ /* * Copyright 2020 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 * * 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. */ package com.google.accompanist.sample import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @Composable fun AccompanistSample( contentPadding: PaddingValues = PaddingValues(16.dp), content: @Composable () -> Unit ) { AccompanistSampleTheme { Surface( modifier = Modifier.fillMaxSize() ) { Box( modifier = Modifier.padding(contentPadding).safeDrawingPadding(), propagateMinConstraints = true ) { content() } } } } @Composable fun AccompanistSampleTheme( darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit ) { MaterialTheme( colorScheme = if (darkTheme) darkColorScheme() else lightColorScheme(), content = content ) } ================================================ FILE: sample/src/main/java/com/google/accompanist/sample/adaptive/BasicTwoPaneSample.kt ================================================ /* * Copyright 2022 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 * * 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. */ package com.google.accompanist.sample.adaptive import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.Card import androidx.compose.material3.Text import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.google.accompanist.adaptive.HorizontalTwoPaneStrategy import com.google.accompanist.adaptive.TwoPane import com.google.accompanist.adaptive.TwoPaneStrategy import com.google.accompanist.adaptive.VerticalTwoPaneStrategy import com.google.accompanist.adaptive.calculateDisplayFeatures import com.google.accompanist.sample.AccompanistSample class BasicTwoPaneSample : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { AccompanistSample { val displayFeatures = calculateDisplayFeatures(this) TwoPane( first = { Card( modifier = Modifier.padding(8.dp) ) { Box( contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize() ) { Text("First") } } }, second = { Card( modifier = Modifier.padding(8.dp) ) { Box( contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize() ) { Text("Second") } } }, strategy = TwoPaneStrategy { density, layoutDirection, layoutCoordinates -> // Split vertically if the height is larger than the width if (layoutCoordinates.size.height >= layoutCoordinates.size.width) { VerticalTwoPaneStrategy( splitFraction = 0.75f ) } else { HorizontalTwoPaneStrategy( splitFraction = 0.75f ) }.calculateSplitResult(density, layoutDirection, layoutCoordinates) }, displayFeatures = displayFeatures, modifier = Modifier.padding(8.dp) ) } } } } ================================================ FILE: sample/src/main/java/com/google/accompanist/sample/adaptive/DraggableFoldAwareColumnSample.kt ================================================ /* * Copyright 2023 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 * * 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. */ package com.google.accompanist.sample.adaptive import android.app.Activity import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.foundation.Image import androidx.compose.foundation.border import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.FavoriteBorder import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import com.google.accompanist.adaptive.FoldAwareColumn import com.google.accompanist.adaptive.calculateDisplayFeatures import com.google.accompanist.sample.AccompanistSample import com.google.accompanist.sample.R import kotlin.math.roundToInt class DraggableFoldAwareColumnSample : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { AccompanistSample { DraggableExample(this@DraggableFoldAwareColumnSample) } } } } @Composable fun DraggableExample(activity: Activity) { var offset by remember { mutableStateOf(Offset(0f, 0f)) } FoldAwareColumn( modifier = Modifier .offset { IntOffset(offset.x.roundToInt(), offset.y.roundToInt()) } .pointerInput(Unit) { detectDragGestures { change, dragAmount -> change.consume() offset = Offset(offset.x + dragAmount.x, offset.y + dragAmount.y) } } .width(400.dp) .border(5.dp, MaterialTheme.colorScheme.secondary), displayFeatures = calculateDisplayFeatures(activity), horizontalAlignment = Alignment.CenterHorizontally, ) { Icon( modifier = Modifier .border(2.dp, MaterialTheme.colorScheme.primary) .padding(20.dp) .align(Alignment.Start), imageVector = Icons.Default.FavoriteBorder, contentDescription = null ) Text( modifier = Modifier .align(Alignment.CenterHorizontally) .border(2.dp, MaterialTheme.colorScheme.primary), text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum." ) Image( modifier = Modifier .ignoreFold() .align(Alignment.End) .border(2.dp, MaterialTheme.colorScheme.primary), painter = painterResource(id = R.drawable.placeholder), contentDescription = null ) } } ================================================ FILE: sample/src/main/java/com/google/accompanist/sample/adaptive/HorizontalTwoPaneSample.kt ================================================ /* * Copyright 2022 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 * * 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. */ package com.google.accompanist.sample.adaptive import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.Card import androidx.compose.material3.Text import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.google.accompanist.adaptive.FoldAwareConfiguration import com.google.accompanist.adaptive.HorizontalTwoPaneStrategy import com.google.accompanist.adaptive.TwoPane import com.google.accompanist.adaptive.calculateDisplayFeatures import com.google.accompanist.sample.AccompanistSample class HorizontalTwoPaneSample : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { AccompanistSample { val displayFeatures = calculateDisplayFeatures(this) TwoPane( first = { Card( modifier = Modifier.padding(8.dp) ) { Box( contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize() ) { Text("First") } } }, second = { Card( modifier = Modifier.padding(8.dp) ) { Box( contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize() ) { Text("Second") } } }, strategy = HorizontalTwoPaneStrategy( splitFraction = 1f / 3f, ), displayFeatures = displayFeatures, foldAwareConfiguration = FoldAwareConfiguration.VerticalFoldsOnly, modifier = Modifier.padding(8.dp) ) } } } } ================================================ FILE: sample/src/main/java/com/google/accompanist/sample/adaptive/NavDrawerFoldAwareColumnSample.kt ================================================ /* * Copyright 2023 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 * * 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. */ package com.google.accompanist.sample.adaptive import android.app.Activity import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.foundation.border import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Done import androidx.compose.material.icons.filled.Face import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.filled.ThumbUp import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalDrawerSheet import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.NavigationDrawerItem import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.google.accompanist.adaptive.FoldAwareColumn import com.google.accompanist.adaptive.calculateDisplayFeatures import com.google.accompanist.sample.AccompanistSample class NavDrawerFoldAwareColumnSample : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { AccompanistSample(contentPadding = PaddingValues(all = 0.dp)) { NavDrawerExample(this) } } } } @OptIn(ExperimentalMaterial3Api::class) @Composable fun NavDrawerExample(activity: Activity) { val icons = listOf( Icons.Default.Done, Icons.Default.Face, Icons.Default.Lock, Icons.Default.Search, Icons.Default.ThumbUp, Icons.Default.Warning, Icons.Default.Star ) var selectedIcon by remember { mutableStateOf(icons[0]) } ModalNavigationDrawer( drawerContent = { ModalDrawerSheet { FoldAwareColumn( displayFeatures = calculateDisplayFeatures(activity), foldPadding = PaddingValues(vertical = 10.dp) ) { icons.forEach { NavigationDrawerItem( modifier = Modifier .padding(5.dp) .border(2.dp, MaterialTheme.colorScheme.primary, CircleShape), icon = { Icon(imageVector = it, contentDescription = it.name) }, label = { Text(it.name.substringAfter('.')) }, selected = it == selectedIcon, onClick = { selectedIcon = it } ) } } } }, content = { Surface(modifier = Modifier.fillMaxSize()) {} } ) } ================================================ FILE: sample/src/main/java/com/google/accompanist/sample/adaptive/NavRailFoldAwareColumnSample.kt ================================================ /* * Copyright 2023 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 * * 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. */ package com.google.accompanist.sample.adaptive import android.app.Activity import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.foundation.border import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Done import androidx.compose.material.icons.filled.Face import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.filled.ThumbUp import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationRail import androidx.compose.material3.NavigationRailItem import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.google.accompanist.adaptive.FoldAwareColumn import com.google.accompanist.adaptive.calculateDisplayFeatures import com.google.accompanist.sample.AccompanistSample class NavRailFoldAwareColumnSample : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { AccompanistSample { Row { NavRail(this@NavRailFoldAwareColumnSample) Surface(modifier = Modifier.fillMaxSize()) {} } } } } } @Composable fun NavRail(activity: Activity) { val icons = listOf( Icons.Default.Done, Icons.Default.Face, Icons.Default.Lock, Icons.Default.Search, Icons.Default.ThumbUp, Icons.Default.Warning, Icons.Default.Star ) var selectedIcon by remember { mutableStateOf(icons[0]) } NavigationRail { FoldAwareColumn(displayFeatures = calculateDisplayFeatures(activity)) { icons.forEach { NavigationRailItem( modifier = Modifier .padding(5.dp) .border(2.dp, MaterialTheme.colorScheme.primary), selected = it == selectedIcon, onClick = { selectedIcon = it }, icon = { Icon(imageVector = it, contentDescription = it.name) }, label = { Text(it.name.substringAfter('.')) } ) } } } } ================================================ FILE: sample/src/main/java/com/google/accompanist/sample/adaptive/VerticalTwoPaneSample.kt ================================================ /* * Copyright 2022 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 * * 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. */ package com.google.accompanist.sample.adaptive import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.Card import androidx.compose.material3.Text import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.google.accompanist.adaptive.FoldAwareConfiguration import com.google.accompanist.adaptive.TwoPane import com.google.accompanist.adaptive.VerticalTwoPaneStrategy import com.google.accompanist.adaptive.calculateDisplayFeatures import com.google.accompanist.sample.AccompanistSample class VerticalTwoPaneSample : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { AccompanistSample { val displayFeatures = calculateDisplayFeatures(this) TwoPane( first = { Card( modifier = Modifier.padding(8.dp) ) { Box( contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize() ) { Text("First") } } }, second = { Card( modifier = Modifier.padding(8.dp) ) { Box( contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize() ) { Text("Second") } } }, strategy = VerticalTwoPaneStrategy( splitOffset = 200.dp, ), displayFeatures = displayFeatures, foldAwareConfiguration = FoldAwareConfiguration.HorizontalFoldsOnly, modifier = Modifier.padding(8.dp) ) } } } } ================================================ FILE: sample/src/main/java/com/google/accompanist/sample/drawablepainter/DocSamples.kt ================================================ /* * Copyright 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 * * 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. */ package com.google.accompanist.sample.drawablepainter import androidx.compose.foundation.Image import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalContext import androidx.core.content.ContextCompat import com.google.accompanist.drawablepainter.rememberDrawablePainter import com.google.accompanist.sample.R @Composable fun BasicSample() { val drawable = ContextCompat.getDrawable(LocalContext.current, R.drawable.rectangle) Image( painter = rememberDrawablePainter(drawable = drawable), contentDescription = "content description", ) } ================================================ FILE: sample/src/main/java/com/google/accompanist/sample/permissions/RequestLocationPermissionsSample.kt ================================================ /* * Copyright 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 * * 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. */ package com.google.accompanist.sample.permissions import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.material3.Button import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.rememberMultiplePermissionsState import com.google.accompanist.sample.AccompanistSample class RequestLocationPermissionsSample : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { AccompanistSample { Sample() } } } } @OptIn(ExperimentalPermissionsApi::class) @Composable private fun Sample() { val locationPermissionsState = rememberMultiplePermissionsState( listOf( android.Manifest.permission.ACCESS_COARSE_LOCATION, android.Manifest.permission.ACCESS_FINE_LOCATION, ) ) if (locationPermissionsState.allPermissionsGranted) { Text("Thanks! I can access your exact location :D") } else { Column { val allPermissionsRevoked = locationPermissionsState.permissions.size == locationPermissionsState.revokedPermissions.size val textToShow = if (!allPermissionsRevoked) { // If not all the permissions are revoked, it's because the user accepted the COARSE // location permission, but not the FINE one. "Yay! Thanks for letting me access your approximate location. " + "But you know what would be great? If you allow me to know where you " + "exactly are. Thank you!" } else if (locationPermissionsState.shouldShowRationale) { // Both location permissions have been denied "Getting your exact location is important for this app. " + "Please grant us fine location. Thank you :D" } else { // First time the user sees this feature or the user doesn't want to be asked again "This feature requires location permission" } val buttonText = if (!allPermissionsRevoked) { "Allow precise location" } else { "Request permissions" } Text(text = textToShow) Spacer(modifier = Modifier.height(8.dp)) Button(onClick = { locationPermissionsState.launchMultiplePermissionRequest() }) { Text(buttonText) } } } } ================================================ FILE: sample/src/main/java/com/google/accompanist/sample/permissions/RequestMultiplePermissionsSample.kt ================================================ /* * Copyright 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 * * 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. */ package com.google.accompanist.sample.permissions import android.Manifest import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.material3.Button import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.MultiplePermissionsState import com.google.accompanist.permissions.PermissionState import com.google.accompanist.permissions.rememberMultiplePermissionsState import com.google.accompanist.sample.AccompanistSample @OptIn(ExperimentalPermissionsApi::class) class RequestMultiplePermissionsSample : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { AccompanistSample { val multiplePermissionsState = rememberMultiplePermissionsState( listOf( Manifest.permission.RECORD_AUDIO, Manifest.permission.CAMERA, ) ) Sample(multiplePermissionsState) } } } } @OptIn(ExperimentalPermissionsApi::class) @Composable private fun Sample(multiplePermissionsState: MultiplePermissionsState) { if (multiplePermissionsState.allPermissionsGranted) { // If all permissions are granted, then show screen with the feature enabled Text("Camera and Read storage permissions Granted! Thank you!") } else { Column { Text( getTextToShowGivenPermissions( multiplePermissionsState.revokedPermissions, multiplePermissionsState.shouldShowRationale ) ) Spacer(modifier = Modifier.height(8.dp)) Button(onClick = { multiplePermissionsState.launchMultiplePermissionRequest() }) { Text("Request permissions") } } } } @OptIn(ExperimentalPermissionsApi::class) private fun getTextToShowGivenPermissions( permissions: List, shouldShowRationale: Boolean ): String { val revokedPermissionsSize = permissions.size if (revokedPermissionsSize == 0) return "" val textToShow = StringBuilder().apply { append("The ") } for (i in permissions.indices) { textToShow.append(permissions[i].permission) when { revokedPermissionsSize > 1 && i == revokedPermissionsSize - 2 -> { textToShow.append(", and ") } i == revokedPermissionsSize - 1 -> { textToShow.append(" ") } else -> { textToShow.append(", ") } } } textToShow.append(if (revokedPermissionsSize == 1) "permission is" else "permissions are") textToShow.append( if (shouldShowRationale) { " important. Please grant all of them for the app to function properly." } else { " denied. The app cannot function without them." } ) return textToShow.toString() } ================================================ FILE: sample/src/main/java/com/google/accompanist/sample/permissions/RequestPermissionSample.kt ================================================ /* * Copyright 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 * * 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. */ package com.google.accompanist.sample.permissions import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.material3.Button import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState import com.google.accompanist.permissions.shouldShowRationale import com.google.accompanist.sample.AccompanistSample class RequestPermissionSample : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { AccompanistSample { Sample() } } } } @OptIn(ExperimentalPermissionsApi::class) @Composable private fun Sample() { val cameraPermissionState = rememberPermissionState(android.Manifest.permission.CAMERA) if (cameraPermissionState.status.isGranted) { Text("Camera permission Granted") } else { Column { val textToShow = if (cameraPermissionState.status.shouldShowRationale) { "The camera is important for this app. Please grant the permission." } else { "Camera not available" } Text(textToShow) Spacer(modifier = Modifier.height(8.dp)) Button(onClick = { cameraPermissionState.launchPermissionRequest() }) { Text("Request permission") } } } } ================================================ FILE: sample/src/main/res/drawable/ic_launcher_background.xml ================================================ ================================================ FILE: sample/src/main/res/drawable/rectangle.xml ================================================ ================================================ FILE: sample/src/main/res/drawable-v24/ic_launcher_foreground.xml ================================================ ================================================ FILE: sample/src/main/res/mipmap-anydpi-v26/ic_launcher.xml ================================================ ================================================ FILE: sample/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml ================================================ ================================================ FILE: sample/src/main/res/values/strings.xml ================================================ Accompanist Sample Insets: Basic Insets: Fragment Insets: Edge-to-edge list Insets: Edge-to-edge list with Bottom Navigation Insets: IME Animations Horizontal Pager: Basic Horizontal Pager: Indicator Horizontal Pager: Transition Horizontal Pager: Looping Horizontal Pager: Looping with Indicators Horizontal Pager: Looping with Tabs Horizontal Pager: Tabs Horizontal Pager: Scrolling content Horizontal Pager: Different paddings Vertical Pager: Basic Vertical Pager: Indicator Pagers: Nested Navigation: Transitions Navigation: Bottom Sheets Permissions: Request permission Permissions: Request multiple permissions Permissions: Request location permissions Flow Layout: Column Flow Layout: Row Swipe Refresh: Basic Swipe Refresh: Edge-to-edge Swipe Refresh: Tweaked indicator Swipe Refresh: Custom indicator Swipe Refresh: VerticalPager System Ui Controller: Color System Ui Controller: Color in Dialog System Ui Controller: Visibility Placeholder: Basic Placeholder: Fade Placeholder: Shimmer WebView: Basic WebView: Wrapped Content WebView: Save State on Navigation Adaptive: TwoPane Basic Adaptive: TwoPane Horizontal Adaptive: TwoPane Vertical Adaptive: Fold Aware Column with Nav Rail Adaptive: Fold Aware Column with Nav Drawer Adaptive: Draggable Fold Aware Column Test Harness This is content\n%s Theme Adapter: Material Theme Adapter: Material 3 ================================================ FILE: sample/src/main/res/values-ar/strings.xml ================================================ هذا مضمون \n%s ================================================ FILE: scripts/run-tests.sh ================================================ #!/bin/bash # Copyright 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 # # 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. # Fail on error and print out commands set -ex # By default we don't shard SHARD_COUNT=0 SHARD_INDEX=0 # By default we don't log LOG_FILE="" # By default we run tests on device DEVICE=true # Parse parameters for i in "$@"; do case $i in --shard-count=*) SHARD_COUNT="${i#*=}" shift ;; --unit-tests) DEVICE=false shift ;; --shard-index=*) SHARD_INDEX="${i#*=}" shift ;; --log-file=*) LOG_FILE="${i#*=}" shift ;; --run-affected) RUN_AFFECTED=true shift ;; --run-flaky-tests) RUN_FLAKY=true shift ;; --affected-base-ref=*) BASE_REF="${i#*=}" shift ;; *) echo "Unknown option" exit 1 ;; esac done # Start logcat if we have a file to log to if [[ ! -z "$LOG_FILE" ]]; then adb logcat >$LOG_FILE & fi FILTER_OPTS="" # Filter out flaky tests if we're not set to run them if [[ -z "$RUN_FLAKY" ]]; then FILTER_OPTS="$FILTER_OPTS -Pandroid.testInstrumentationRunnerArguments.notAnnotation=androidx.test.filters.FlakyTest" fi # If we're set to only run affected test, update the Gradle task if [[ ! -z "$RUN_AFFECTED" ]]; then if [ "$DEVICE" = true ]; then TASK="runAffectedAndroidTests" else TASK="runAffectedUnitTests" fi TASK="$TASK -Paffected_module_detector.enable" # If we have a base branch set, add the Gradle property if [[ ! -z "$BASE_REF" ]]; then TASK="$TASK -Paffected_base_ref=$BASE_REF" fi fi # If we don't have a task yet, use the defaults if [[ -z "$TASK" ]]; then if [ "$DEVICE" = true ]; then TASK="connectedCheck" else TASK="testDebug" fi fi SHARD_OPTS="" if [ "$SHARD_COUNT" -gt "0" ]; then # If we have a shard count value, create the necessary Gradle property args. # We assume that SHARD_INDEX has been set too SHARD_OPTS="$SHARD_OPTS -Pandroid.testInstrumentationRunnerArguments.numShards=$SHARD_COUNT" SHARD_OPTS="$SHARD_OPTS -Pandroid.testInstrumentationRunnerArguments.shardIndex=$SHARD_INDEX" fi ./gradlew --scan --continue --no-configuration-cache --stacktrace $TASK $FILTER_OPTS $SHARD_OPTS ================================================ FILE: settings.gradle.kts ================================================ /* * Copyright 2023 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 * * 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. */ pluginManagement { includeBuild("build-logic") repositories { google() mavenCentral() gradlePluginPortal() } } dependencyResolutionManagement { repositories { google() mavenCentral() } } include(":adaptive") include(":internal-testutils") include(":drawablepainter") include(":permissions") include(":permissions-lint") include(":sample") rootProject.name = "accompanist" check(JavaVersion.current().isCompatibleWith(JavaVersion.VERSION_17)) { """ Accompanist requires JDK 17+ but it is currently using JDK ${JavaVersion.current()}. Java Home: [${System.getProperty("java.home")}] https://developer.android.com/build/jdks#jdk-config-in-studio """.trimIndent() } ================================================ FILE: spotless/copyright.txt ================================================ /* * Copyright $YEAR 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 * * 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. */ ================================================ FILE: spotless/greclipse.properties ================================================ # # Copyright 2020 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 # # 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. # #Whether to use 'space', 'tab' or 'mixed' (both) characters for indentation. #The default value is 'tab'. org.eclipse.jdt.core.formatter.tabulation.char=space #Number of spaces used for indentation in case 'space' characters #have been selected. The default value is 4. org.eclipse.jdt.core.formatter.tabulation.size=4 #Number of spaces used for indentation in case 'mixed' characters #have been selected. The default value is 4. org.eclipse.jdt.core.formatter.indentation.size=4 #Whether or not indentation characters are inserted into empty lines. #The default value is 'true'. org.eclipse.jdt.core.formatter.indent_empty_lines=false #Number of spaces used for multiline indentation. #The default value is 2. groovy.formatter.multiline.indentation=2 #Length after which list are considered too long. These will be wrapped. #The default value is 30. groovy.formatter.longListLength=30 #Whether opening braces position shall be the next line. #The default value is 'same'. groovy.formatter.braces.start=same #Whether closing braces position shall be the next line. #The default value is 'next'. groovy.formatter.braces.end=next #Remove unnecessary semicolons. The default value is 'false'. groovy.formatter.remove.unnecessary.semicolons=false