Merge "Add verbalized toString() method for CameraError" into androidx-main
diff --git a/appsearch/appsearch/api/current.txt b/appsearch/appsearch/api/current.txt
index c3c298f..29645b5 100644
--- a/appsearch/appsearch/api/current.txt
+++ b/appsearch/appsearch/api/current.txt
@@ -109,12 +109,35 @@
method public byte[] getSha256Digest();
}
+ @SuppressCompatibility @androidx.appsearch.app.ExperimentalAppSearchApi public final class AppSearchCommitBlobResponse {
+ ctor public AppSearchCommitBlobResponse(androidx.appsearch.app.AppSearchBatchResult<androidx.appsearch.app.AppSearchBlobHandle!,java.lang.Void!>);
+ method public androidx.appsearch.app.AppSearchBatchResult<androidx.appsearch.app.AppSearchBlobHandle!,java.lang.Void!> getResult();
+ method public void writeToParcel(android.os.Parcel, int);
+ field public static final android.os.Parcelable.Creator<androidx.appsearch.app.AppSearchCommitBlobResponse!> CREATOR;
+ }
+
@AnyThread public abstract class AppSearchDocumentClassMap {
ctor public AppSearchDocumentClassMap();
method @WorkerThread public static java.util.Map<java.lang.String!,java.util.List<java.lang.String!>!> getGlobalMap();
method protected abstract java.util.Map<java.lang.String!,java.util.List<java.lang.String!>!> getMap();
}
+ @SuppressCompatibility @androidx.appsearch.app.ExperimentalAppSearchApi public final class AppSearchOpenBlobForReadResponse implements java.io.Closeable {
+ ctor public AppSearchOpenBlobForReadResponse(androidx.appsearch.app.AppSearchBatchResult<androidx.appsearch.app.AppSearchBlobHandle!,android.os.ParcelFileDescriptor!>);
+ method public void close();
+ method public androidx.appsearch.app.AppSearchBatchResult<androidx.appsearch.app.AppSearchBlobHandle!,android.os.ParcelFileDescriptor!> getResult();
+ method public void writeToParcel(android.os.Parcel, int);
+ field public static final android.os.Parcelable.Creator<androidx.appsearch.app.AppSearchOpenBlobForReadResponse!> CREATOR;
+ }
+
+ @SuppressCompatibility @androidx.appsearch.app.ExperimentalAppSearchApi public final class AppSearchOpenBlobForWriteResponse implements java.io.Closeable {
+ ctor public AppSearchOpenBlobForWriteResponse(androidx.appsearch.app.AppSearchBatchResult<androidx.appsearch.app.AppSearchBlobHandle!,android.os.ParcelFileDescriptor!>);
+ method public void close();
+ method public androidx.appsearch.app.AppSearchBatchResult<androidx.appsearch.app.AppSearchBlobHandle!,android.os.ParcelFileDescriptor!> getResult();
+ method public void writeToParcel(android.os.Parcel, int);
+ field public static final android.os.Parcelable.Creator<androidx.appsearch.app.AppSearchOpenBlobForWriteResponse!> CREATOR;
+ }
+
public final class AppSearchResult<ValueType> {
method public String? getErrorMessage();
method public int getResultCode();
diff --git a/appsearch/appsearch/api/restricted_current.txt b/appsearch/appsearch/api/restricted_current.txt
index c3c298f..29645b5 100644
--- a/appsearch/appsearch/api/restricted_current.txt
+++ b/appsearch/appsearch/api/restricted_current.txt
@@ -109,12 +109,35 @@
method public byte[] getSha256Digest();
}
+ @SuppressCompatibility @androidx.appsearch.app.ExperimentalAppSearchApi public final class AppSearchCommitBlobResponse {
+ ctor public AppSearchCommitBlobResponse(androidx.appsearch.app.AppSearchBatchResult<androidx.appsearch.app.AppSearchBlobHandle!,java.lang.Void!>);
+ method public androidx.appsearch.app.AppSearchBatchResult<androidx.appsearch.app.AppSearchBlobHandle!,java.lang.Void!> getResult();
+ method public void writeToParcel(android.os.Parcel, int);
+ field public static final android.os.Parcelable.Creator<androidx.appsearch.app.AppSearchCommitBlobResponse!> CREATOR;
+ }
+
@AnyThread public abstract class AppSearchDocumentClassMap {
ctor public AppSearchDocumentClassMap();
method @WorkerThread public static java.util.Map<java.lang.String!,java.util.List<java.lang.String!>!> getGlobalMap();
method protected abstract java.util.Map<java.lang.String!,java.util.List<java.lang.String!>!> getMap();
}
+ @SuppressCompatibility @androidx.appsearch.app.ExperimentalAppSearchApi public final class AppSearchOpenBlobForReadResponse implements java.io.Closeable {
+ ctor public AppSearchOpenBlobForReadResponse(androidx.appsearch.app.AppSearchBatchResult<androidx.appsearch.app.AppSearchBlobHandle!,android.os.ParcelFileDescriptor!>);
+ method public void close();
+ method public androidx.appsearch.app.AppSearchBatchResult<androidx.appsearch.app.AppSearchBlobHandle!,android.os.ParcelFileDescriptor!> getResult();
+ method public void writeToParcel(android.os.Parcel, int);
+ field public static final android.os.Parcelable.Creator<androidx.appsearch.app.AppSearchOpenBlobForReadResponse!> CREATOR;
+ }
+
+ @SuppressCompatibility @androidx.appsearch.app.ExperimentalAppSearchApi public final class AppSearchOpenBlobForWriteResponse implements java.io.Closeable {
+ ctor public AppSearchOpenBlobForWriteResponse(androidx.appsearch.app.AppSearchBatchResult<androidx.appsearch.app.AppSearchBlobHandle!,android.os.ParcelFileDescriptor!>);
+ method public void close();
+ method public androidx.appsearch.app.AppSearchBatchResult<androidx.appsearch.app.AppSearchBlobHandle!,android.os.ParcelFileDescriptor!> getResult();
+ method public void writeToParcel(android.os.Parcel, int);
+ field public static final android.os.Parcelable.Creator<androidx.appsearch.app.AppSearchOpenBlobForWriteResponse!> CREATOR;
+ }
+
public final class AppSearchResult<ValueType> {
method public String? getErrorMessage();
method public int getResultCode();
diff --git a/appsearch/appsearch/src/androidTest/java/androidx/appsearch/app/aidl/AppSearchBatchResultGeneralKeyParcelTest.java b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/app/aidl/AppSearchBatchResultGeneralKeyParcelTest.java
new file mode 100644
index 0000000..d2dd377
--- /dev/null
+++ b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/app/aidl/AppSearchBatchResultGeneralKeyParcelTest.java
@@ -0,0 +1,95 @@
+/*
+ * 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
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.appsearch.app.aidl;
+
+import static android.os.ParcelFileDescriptor.MODE_WRITE_ONLY;
+
+import static androidx.appsearch.testutil.AppSearchTestUtils.calculateDigest;
+import static androidx.appsearch.testutil.AppSearchTestUtils.generateRandomBytes;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.os.ParcelFileDescriptor;
+
+import androidx.appsearch.app.AppSearchBatchResult;
+import androidx.appsearch.app.AppSearchBlobHandle;
+import androidx.appsearch.app.AppSearchResult;
+
+import org.junit.Test;
+
+import java.io.File;
+
+public class AppSearchBatchResultGeneralKeyParcelTest {
+
+ @Test
+ public void testFromBlobHandleToPfd() throws Exception {
+ File file = File.createTempFile(/*prefix=*/"appsearch", /*suffix=*/null);
+ ParcelFileDescriptor pfd = ParcelFileDescriptor.open(file, MODE_WRITE_ONLY);
+ AppSearchResult<ParcelFileDescriptor> successResult =
+ AppSearchResult.newSuccessfulResult(pfd);
+ AppSearchResult<ParcelFileDescriptor> failureResult = AppSearchResult.newFailedResult(
+ AppSearchResult.RESULT_NOT_FOUND, "not found");
+ byte[] data1 = generateRandomBytes(10); // 10 Bytes
+ byte[] digest1 = calculateDigest(data1);
+ byte[] data2 = generateRandomBytes(10); // 10 Bytes
+ byte[] digest2 = calculateDigest(data2);
+ AppSearchBlobHandle blobHandle1 = AppSearchBlobHandle.createWithSha256(
+ digest1, "package1", "db1", "ns");
+ AppSearchBlobHandle blobHandle2 = AppSearchBlobHandle.createWithSha256(
+ digest2, "package1", "db1", "ns");
+ AppSearchBatchResult<AppSearchBlobHandle, ParcelFileDescriptor> result =
+ new AppSearchBatchResult.Builder<AppSearchBlobHandle, ParcelFileDescriptor>()
+ .setResult(blobHandle1, successResult)
+ .setResult(blobHandle2, failureResult)
+ .build();
+ AppSearchBatchResultGeneralKeyParcel<AppSearchBlobHandle, ParcelFileDescriptor>
+ resultParcel = AppSearchBatchResultGeneralKeyParcel.fromBlobHandleToPfd(result);
+
+ AppSearchBatchResult<AppSearchBlobHandle, ParcelFileDescriptor> outResult =
+ resultParcel.getResult();
+
+ assertThat(outResult.getSuccesses()).containsExactly(blobHandle1, pfd);
+ assertThat(outResult.getFailures()).containsExactly(blobHandle2, failureResult);
+ }
+
+ @Test
+ public void testFromBlobHandleToVoid() throws Exception {
+ byte[] data1 = generateRandomBytes(10); // 10 Bytes
+ byte[] digest1 = calculateDigest(data1);
+ byte[] data2 = generateRandomBytes(10); // 10 Bytes
+ byte[] digest2 = calculateDigest(data2);
+ AppSearchBlobHandle blobHandle1 = AppSearchBlobHandle.createWithSha256(
+ digest1, "package1", "db1", "ns");
+ AppSearchBlobHandle blobHandle2 = AppSearchBlobHandle.createWithSha256(
+ digest2, "package1", "db1", "ns");
+ AppSearchResult<Void> successResult = AppSearchResult.newSuccessfulResult(null);
+ AppSearchResult<Void> failureResult = AppSearchResult.newFailedResult(
+ AppSearchResult.RESULT_NOT_FOUND, "not found");
+ AppSearchBatchResult<AppSearchBlobHandle, Void> result =
+ new AppSearchBatchResult.Builder<AppSearchBlobHandle, Void>()
+ .setResult(blobHandle1, successResult)
+ .setResult(blobHandle2, failureResult)
+ .build();
+ AppSearchBatchResultGeneralKeyParcel<AppSearchBlobHandle, Void>
+ resultParcel = AppSearchBatchResultGeneralKeyParcel.fromBlobHandleToVoid(result);
+
+ AppSearchBatchResult<AppSearchBlobHandle, Void> outResult = resultParcel.getResult();
+
+ assertThat(outResult.getSuccesses()).containsExactly(blobHandle1, null);
+ assertThat(outResult.getFailures()).containsExactly(blobHandle2, failureResult);
+ }
+}
diff --git a/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/app/AppSearchCommitBlobResponseTest.java b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/app/AppSearchCommitBlobResponseTest.java
new file mode 100644
index 0000000..4fc1370
--- /dev/null
+++ b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/app/AppSearchCommitBlobResponseTest.java
@@ -0,0 +1,86 @@
+/*
+ * 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
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.appsearch.cts.app;
+
+import static androidx.appsearch.testutil.AppSearchTestUtils.calculateDigest;
+import static androidx.appsearch.testutil.AppSearchTestUtils.generateRandomBytes;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import androidx.appsearch.app.AppSearchBatchResult;
+import androidx.appsearch.app.AppSearchBlobHandle;
+import androidx.appsearch.app.AppSearchCommitBlobResponse;
+import androidx.appsearch.app.AppSearchResult;
+import androidx.appsearch.flags.CheckFlagsRule;
+import androidx.appsearch.flags.DeviceFlagsValueProvider;
+import androidx.appsearch.flags.Flags;
+import androidx.appsearch.flags.RequiresFlagsEnabled;
+
+import org.junit.Rule;
+import org.junit.Test;
+
+@RequiresFlagsEnabled(Flags.FLAG_ENABLE_BLOB_STORE)
+public class AppSearchCommitBlobResponseTest {
+ @Rule
+ public final CheckFlagsRule mCheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule();
+
+ @Test
+ public void testBuildAndGet() throws Exception {
+ byte[] data1 = generateRandomBytes(10); // 10 Bytes
+ byte[] digest1 = calculateDigest(data1);
+ byte[] data2 = generateRandomBytes(10); // 10 Bytes
+ byte[] digest2 = calculateDigest(data2);
+ byte[] data3 = generateRandomBytes(10); // 10 Bytes
+ byte[] digest3 = calculateDigest(data3);
+ byte[] data4 = generateRandomBytes(10); // 10 Bytes
+ byte[] digest4 = calculateDigest(data4);
+ AppSearchBlobHandle blobHandle1 = AppSearchBlobHandle.createWithSha256(
+ digest1, "package1", "db1", "ns");
+ AppSearchBlobHandle blobHandle2 = AppSearchBlobHandle.createWithSha256(
+ digest2, "package1", "db1", "ns");
+ AppSearchBlobHandle blobHandle3 = AppSearchBlobHandle.createWithSha256(
+ digest3, "package1", "db1", "ns");
+ AppSearchBlobHandle blobHandle4 = AppSearchBlobHandle.createWithSha256(
+ digest4, "package1", "db1", "ns");
+
+ AppSearchResult<Void> failureResult = AppSearchResult.newFailedResult(
+ AppSearchResult.RESULT_ALREADY_EXISTS, "already exists");
+ AppSearchResult<Void> successResult = AppSearchResult.newSuccessfulResult(null);
+
+ AppSearchBatchResult<AppSearchBlobHandle, Void> batchResult =
+ new AppSearchBatchResult.Builder<AppSearchBlobHandle, Void>()
+ .setSuccess(blobHandle1, null)
+ .setFailure(blobHandle2, AppSearchResult.RESULT_ALREADY_EXISTS,
+ "already exists")
+ .setResult(blobHandle3, successResult)
+ .setResult(blobHandle4, failureResult)
+ .build();
+
+ AppSearchCommitBlobResponse appSearchCommitBlobResponse =
+ new AppSearchCommitBlobResponse(batchResult);
+
+ AppSearchBatchResult<AppSearchBlobHandle, Void> outResult =
+ appSearchCommitBlobResponse.getResult();
+ assertThat(outResult.getSuccesses()).containsExactly(
+ blobHandle1, null, blobHandle3, null);
+ assertThat(outResult.getFailures()).containsExactly(
+ blobHandle2, failureResult, blobHandle4, failureResult);
+ assertThat(outResult.getAll()).containsExactly(
+ blobHandle1, successResult, blobHandle2, failureResult,
+ blobHandle3, successResult, blobHandle4, failureResult);
+ }
+}
diff --git a/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/app/AppSearchOpenBlobForReadResponseTest.java b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/app/AppSearchOpenBlobForReadResponseTest.java
new file mode 100644
index 0000000..22daaca
--- /dev/null
+++ b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/app/AppSearchOpenBlobForReadResponseTest.java
@@ -0,0 +1,130 @@
+/*
+ * 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
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.appsearch.cts.app;
+
+import static android.os.ParcelFileDescriptor.MODE_WRITE_ONLY;
+
+import static androidx.appsearch.testutil.AppSearchTestUtils.calculateDigest;
+import static androidx.appsearch.testutil.AppSearchTestUtils.generateRandomBytes;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.junit.Assert.assertThrows;
+
+import android.os.ParcelFileDescriptor;
+
+import androidx.appsearch.app.AppSearchBatchResult;
+import androidx.appsearch.app.AppSearchBlobHandle;
+import androidx.appsearch.app.AppSearchOpenBlobForReadResponse;
+import androidx.appsearch.app.AppSearchResult;
+import androidx.appsearch.flags.CheckFlagsRule;
+import androidx.appsearch.flags.DeviceFlagsValueProvider;
+import androidx.appsearch.flags.Flags;
+import androidx.appsearch.flags.RequiresFlagsEnabled;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+
+import java.io.File;
+
+@RequiresFlagsEnabled(Flags.FLAG_ENABLE_BLOB_STORE)
+public class AppSearchOpenBlobForReadResponseTest {
+ @Rule
+ public final CheckFlagsRule mCheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule();
+
+ ParcelFileDescriptor mPfd;
+ private AppSearchResult<ParcelFileDescriptor> mSuccessResult;
+ private AppSearchResult<ParcelFileDescriptor> mFailureResult;
+
+ @Before
+ public void setUp() throws Exception {
+ File file = File.createTempFile(/*prefix=*/"appsearch", /*suffix=*/null);
+ mPfd = ParcelFileDescriptor.open(file, MODE_WRITE_ONLY);
+ mSuccessResult = AppSearchResult.newSuccessfulResult(mPfd);
+ mFailureResult = AppSearchResult.newFailedResult(
+ AppSearchResult.RESULT_ALREADY_EXISTS, "already exists");
+ }
+
+ @After
+ public void tearDown() throws Exception {
+ mPfd.close();
+ }
+
+ @Test
+ public void testBuildAndGet() throws Exception {
+ byte[] data1 = generateRandomBytes(10); // 10 Bytes
+ byte[] digest1 = calculateDigest(data1);
+ byte[] data2 = generateRandomBytes(10); // 10 Bytes
+ byte[] digest2 = calculateDigest(data2);
+ byte[] data3 = generateRandomBytes(10); // 10 Bytes
+ byte[] digest3 = calculateDigest(data3);
+ byte[] data4 = generateRandomBytes(10); // 10 Bytes
+ byte[] digest4 = calculateDigest(data4);
+ AppSearchBlobHandle blobHandle1 = AppSearchBlobHandle.createWithSha256(
+ digest1, "package1", "db1", "ns");
+ AppSearchBlobHandle blobHandle2 = AppSearchBlobHandle.createWithSha256(
+ digest2, "package1", "db1", "ns");
+ AppSearchBlobHandle blobHandle3 = AppSearchBlobHandle.createWithSha256(
+ digest3, "package1", "db1", "ns");
+ AppSearchBlobHandle blobHandle4 = AppSearchBlobHandle.createWithSha256(
+ digest4, "package1", "db1", "ns");
+
+ AppSearchBatchResult<AppSearchBlobHandle, ParcelFileDescriptor> batchResult =
+ new AppSearchBatchResult.Builder<AppSearchBlobHandle, ParcelFileDescriptor>()
+ .setSuccess(blobHandle1, mPfd)
+ .setFailure(blobHandle2, AppSearchResult.RESULT_ALREADY_EXISTS,
+ "already exists")
+ .setResult(blobHandle3, mSuccessResult)
+ .setResult(blobHandle4, mFailureResult)
+ .build();
+
+ try (AppSearchOpenBlobForReadResponse response =
+ new AppSearchOpenBlobForReadResponse(batchResult)) {
+
+ AppSearchBatchResult<AppSearchBlobHandle, ParcelFileDescriptor> outResult =
+ response.getResult();
+ assertThat(outResult.getSuccesses()).containsExactly(
+ blobHandle1, mPfd, blobHandle3, mPfd);
+ assertThat(outResult.getFailures()).containsExactly(
+ blobHandle2, mFailureResult, blobHandle4, mFailureResult);
+ assertThat(outResult.getAll()).containsExactly(
+ blobHandle1, mSuccessResult, blobHandle2, mFailureResult,
+ blobHandle3, mSuccessResult, blobHandle4, mFailureResult);
+ }
+ }
+
+ @Test
+ public void testAccessPfdAfterClose() throws Exception {
+ byte[] data = generateRandomBytes(10); // 10 Bytes
+ byte[] digest = calculateDigest(data);
+ AppSearchBlobHandle blobHandle = AppSearchBlobHandle.createWithSha256(
+ digest, "package1", "db1", "ns");
+ AppSearchBatchResult<AppSearchBlobHandle, ParcelFileDescriptor> batchResult =
+ new AppSearchBatchResult.Builder<AppSearchBlobHandle, ParcelFileDescriptor>()
+ .setResult(blobHandle, mSuccessResult)
+ .build();
+ try (AppSearchOpenBlobForReadResponse ignored =
+ new AppSearchOpenBlobForReadResponse(batchResult)) {
+ // Pfd is accessible now
+ mPfd.detachFd();
+ }
+ // Pfd is NOT accessible after close()
+ assertThrows(IllegalStateException.class, () -> mPfd.detachFd());
+ }
+}
diff --git a/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/app/AppSearchOpenBlobForWriteResponseTest.java b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/app/AppSearchOpenBlobForWriteResponseTest.java
new file mode 100644
index 0000000..a4ff1f8
--- /dev/null
+++ b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/app/AppSearchOpenBlobForWriteResponseTest.java
@@ -0,0 +1,130 @@
+/*
+ * 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
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.appsearch.cts.app;
+
+import static android.os.ParcelFileDescriptor.MODE_WRITE_ONLY;
+
+import static androidx.appsearch.testutil.AppSearchTestUtils.calculateDigest;
+import static androidx.appsearch.testutil.AppSearchTestUtils.generateRandomBytes;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.junit.Assert.assertThrows;
+
+import android.os.ParcelFileDescriptor;
+
+import androidx.appsearch.app.AppSearchBatchResult;
+import androidx.appsearch.app.AppSearchBlobHandle;
+import androidx.appsearch.app.AppSearchOpenBlobForWriteResponse;
+import androidx.appsearch.app.AppSearchResult;
+import androidx.appsearch.flags.CheckFlagsRule;
+import androidx.appsearch.flags.DeviceFlagsValueProvider;
+import androidx.appsearch.flags.Flags;
+import androidx.appsearch.flags.RequiresFlagsEnabled;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+
+import java.io.File;
+
+@RequiresFlagsEnabled(Flags.FLAG_ENABLE_BLOB_STORE)
+public class AppSearchOpenBlobForWriteResponseTest {
+ @Rule
+ public final CheckFlagsRule mCheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule();
+
+ ParcelFileDescriptor mPfd;
+ private AppSearchResult<ParcelFileDescriptor> mSuccessResult;
+ private AppSearchResult<ParcelFileDescriptor> mFailureResult;
+
+ @Before
+ public void setUp() throws Exception {
+ File file = File.createTempFile(/*prefix=*/"appsearch", /*suffix=*/null);
+ mPfd = ParcelFileDescriptor.open(file, MODE_WRITE_ONLY);
+ mSuccessResult = AppSearchResult.newSuccessfulResult(mPfd);
+ mFailureResult = AppSearchResult.newFailedResult(
+ AppSearchResult.RESULT_ALREADY_EXISTS, "already exists");
+ }
+
+ @After
+ public void tearDown() throws Exception {
+ mPfd.close();
+ }
+
+ @Test
+ public void testBuildAndGet() throws Exception {
+ byte[] data1 = generateRandomBytes(10); // 10 Bytes
+ byte[] digest1 = calculateDigest(data1);
+ byte[] data2 = generateRandomBytes(10); // 10 Bytes
+ byte[] digest2 = calculateDigest(data2);
+ byte[] data3 = generateRandomBytes(10); // 10 Bytes
+ byte[] digest3 = calculateDigest(data3);
+ byte[] data4 = generateRandomBytes(10); // 10 Bytes
+ byte[] digest4 = calculateDigest(data4);
+ AppSearchBlobHandle blobHandle1 = AppSearchBlobHandle.createWithSha256(
+ digest1, "package1", "db1", "ns");
+ AppSearchBlobHandle blobHandle2 = AppSearchBlobHandle.createWithSha256(
+ digest2, "package1", "db1", "ns");
+ AppSearchBlobHandle blobHandle3 = AppSearchBlobHandle.createWithSha256(
+ digest3, "package1", "db1", "ns");
+ AppSearchBlobHandle blobHandle4 = AppSearchBlobHandle.createWithSha256(
+ digest4, "package1", "db1", "ns");
+
+ AppSearchBatchResult<AppSearchBlobHandle, ParcelFileDescriptor> batchResult =
+ new AppSearchBatchResult.Builder<AppSearchBlobHandle, ParcelFileDescriptor>()
+ .setSuccess(blobHandle1, mPfd)
+ .setFailure(blobHandle2, AppSearchResult.RESULT_ALREADY_EXISTS,
+ "already exists")
+ .setResult(blobHandle3, mSuccessResult)
+ .setResult(blobHandle4, mFailureResult)
+ .build();
+
+ try (AppSearchOpenBlobForWriteResponse response =
+ new AppSearchOpenBlobForWriteResponse(batchResult)) {
+
+ AppSearchBatchResult<AppSearchBlobHandle, ParcelFileDescriptor> outResult =
+ response.getResult();
+ assertThat(outResult.getSuccesses()).containsExactly(
+ blobHandle1, mPfd, blobHandle3, mPfd);
+ assertThat(outResult.getFailures()).containsExactly(
+ blobHandle2, mFailureResult, blobHandle4, mFailureResult);
+ assertThat(outResult.getAll()).containsExactly(
+ blobHandle1, mSuccessResult, blobHandle2, mFailureResult,
+ blobHandle3, mSuccessResult, blobHandle4, mFailureResult);
+ }
+ }
+
+ @Test
+ public void testAccessPfdAfterClose() throws Exception {
+ byte[] data = generateRandomBytes(10); // 10 Bytes
+ byte[] digest = calculateDigest(data);
+ AppSearchBlobHandle blobHandle = AppSearchBlobHandle.createWithSha256(
+ digest, "package1", "db1", "ns");
+ AppSearchBatchResult<AppSearchBlobHandle, ParcelFileDescriptor> batchResult =
+ new AppSearchBatchResult.Builder<AppSearchBlobHandle, ParcelFileDescriptor>()
+ .setResult(blobHandle, mSuccessResult)
+ .build();
+ try (AppSearchOpenBlobForWriteResponse ignored =
+ new AppSearchOpenBlobForWriteResponse(batchResult)) {
+ // Pfd is accessible now
+ mPfd.detachFd();
+ }
+ // Pfd is NOT accessible after close()
+ assertThrows(IllegalStateException.class, () -> mPfd.detachFd());
+ }
+}
diff --git a/appsearch/appsearch/src/main/java/androidx/appsearch/app/AppSearchCommitBlobResponse.java b/appsearch/appsearch/src/main/java/androidx/appsearch/app/AppSearchCommitBlobResponse.java
new file mode 100644
index 0000000..3b693bb
--- /dev/null
+++ b/appsearch/appsearch/src/main/java/androidx/appsearch/app/AppSearchCommitBlobResponse.java
@@ -0,0 +1,94 @@
+/*
+ * 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
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package androidx.appsearch.app;
+
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.RestrictTo;
+import androidx.appsearch.app.aidl.AppSearchBatchResultGeneralKeyParcel;
+import androidx.appsearch.flags.FlaggedApi;
+import androidx.appsearch.flags.Flags;
+import androidx.appsearch.safeparcel.AbstractSafeParcelable;
+import androidx.appsearch.safeparcel.SafeParcelable;
+import androidx.appsearch.safeparcel.stub.StubCreators.CommitBlobResponseCreator;
+import androidx.core.util.Preconditions;
+
+/**
+ * The response to provide batch operation results of
+ * {@link AppSearchSession#commitBlobAsync}.
+ *
+ * <p> This class is used to retrieve the result of a batch commit operation on a collection of
+ * blob handles.
+ */
+@FlaggedApi(Flags.FLAG_ENABLE_BLOB_STORE)
+@SuppressWarnings("HiddenSuperclass")
[email protected](creator = "CommitBlobResponseCreator")
+@ExperimentalAppSearchApi
+public final class AppSearchCommitBlobResponse extends AbstractSafeParcelable {
+
+ @NonNull
+ public static final Parcelable.Creator<AppSearchCommitBlobResponse> CREATOR =
+ new CommitBlobResponseCreator();
+
+ @Field(id = 1, getter = "getResponseParcel")
+ private final AppSearchBatchResultGeneralKeyParcel<AppSearchBlobHandle, Void> mResultParcel;
+
+ /**
+ * Creates a {@link AppSearchCommitBlobResponse} with given {@link AppSearchBatchResult}.
+ */
+ public AppSearchCommitBlobResponse(
+ @NonNull AppSearchBatchResult<AppSearchBlobHandle, Void> result) {
+ this(AppSearchBatchResultGeneralKeyParcel.fromBlobHandleToVoid(result));
+ }
+
+ @Constructor
+ AppSearchCommitBlobResponse(
+ @Param(id = 1) @NonNull AppSearchBatchResultGeneralKeyParcel<AppSearchBlobHandle, Void>
+ resultParcel) {
+ mResultParcel = Preconditions.checkNotNull(resultParcel);
+ }
+
+ /**
+ * Returns the {@link AppSearchBatchResult} object containing the results of the
+ * commit operation for each {@link AppSearchBlobHandle}.
+ *
+ * @return A {@link AppSearchBatchResult} maps {@link AppSearchBlobHandle}s which is a unique
+ * identifier for a specific blob being committed to the outcome of that commit. If the
+ * operation was successful, the result for that handle is {@code null}; if there was an error,
+ * the result contains an {@link AppSearchResult} with details of the failure.
+ */
+ @NonNull
+ public AppSearchBatchResult<AppSearchBlobHandle, Void> getResult() {
+ return mResultParcel.getResult();
+ }
+
+ /**
+ * Retrieves the underlying parcel representation of the batch result.
+ * @exportToFramework:hide
+ */
+ @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+ @NonNull
+ public AppSearchBatchResultGeneralKeyParcel<AppSearchBlobHandle, Void> getResponseParcel() {
+ return mResultParcel;
+ }
+
+ @Override
+ public void writeToParcel(@NonNull Parcel dest, int flags) {
+ CommitBlobResponseCreator.writeToParcel(this, dest, flags);
+ }
+}
diff --git a/appsearch/appsearch/src/main/java/androidx/appsearch/app/AppSearchOpenBlobForReadResponse.java b/appsearch/appsearch/src/main/java/androidx/appsearch/app/AppSearchOpenBlobForReadResponse.java
new file mode 100644
index 0000000..a3152be
--- /dev/null
+++ b/appsearch/appsearch/src/main/java/androidx/appsearch/app/AppSearchOpenBlobForReadResponse.java
@@ -0,0 +1,111 @@
+/*
+ * 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
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package androidx.appsearch.app;
+
+import android.os.Parcel;
+import android.os.ParcelFileDescriptor;
+import android.os.Parcelable;
+
+import androidx.annotation.NonNull;
+import androidx.appsearch.app.aidl.AppSearchBatchResultGeneralKeyParcel;
+import androidx.appsearch.flags.FlaggedApi;
+import androidx.appsearch.flags.Flags;
+import androidx.appsearch.safeparcel.AbstractSafeParcelable;
+import androidx.appsearch.safeparcel.SafeParcelable;
+import androidx.appsearch.safeparcel.stub.StubCreators.OpenBlobForReadResponseCreator;
+import androidx.core.util.Preconditions;
+
+import java.io.Closeable;
+import java.io.IOException;
+
+/**
+ * The response to provide batch operation results of
+ * {@link AppSearchSession#openBlobForReadAsync}.
+ *
+ * <p> This class is used to retrieve the result of a batch read operation on a collection of
+ * blob handles.
+ *
+ * <p class="caution">
+ * The returned {@link android.os.ParcelFileDescriptor} must be closed after use to avoid resource
+ * leaks. Failing to close the descriptor will result in system resource exhaustion, as each open
+ * {@link android.os.ParcelFileDescriptor} occupies a limited file descriptor in the system.
+ * </p>
+ */
+@FlaggedApi(Flags.FLAG_ENABLE_BLOB_STORE)
+@SuppressWarnings("HiddenSuperclass")
[email protected](creator = "OpenBlobForReadResponseCreator")
+@ExperimentalAppSearchApi
+public final class AppSearchOpenBlobForReadResponse extends AbstractSafeParcelable implements
+ Closeable {
+
+ @NonNull
+ public static final Parcelable.Creator<AppSearchOpenBlobForReadResponse> CREATOR =
+ new OpenBlobForReadResponseCreator();
+
+ @Field(id = 1)
+ final AppSearchBatchResultGeneralKeyParcel<AppSearchBlobHandle, ParcelFileDescriptor>
+ mResultParcel;
+
+ /**
+ * Creates a {@link AppSearchOpenBlobForReadResponse} with given {@link AppSearchBatchResult}.
+ */
+ public AppSearchOpenBlobForReadResponse(
+ @NonNull AppSearchBatchResult<AppSearchBlobHandle, ParcelFileDescriptor> result) {
+ this(AppSearchBatchResultGeneralKeyParcel.fromBlobHandleToPfd(result));
+ }
+
+
+ @AbstractSafeParcelable.Constructor
+ AppSearchOpenBlobForReadResponse(
+ @AbstractSafeParcelable.Param(id = 1)
+ @NonNull AppSearchBatchResultGeneralKeyParcel<AppSearchBlobHandle, ParcelFileDescriptor>
+ resultParcel) {
+ mResultParcel = Preconditions.checkNotNull(resultParcel);
+ }
+
+ /**
+ * Returns the {@link AppSearchBatchResult} object containing the results of the read blob for
+ * read operation for each {@link AppSearchBlobHandle}.
+ *
+ * @return A {@link AppSearchBatchResult} maps {@link AppSearchBlobHandle}s which is a unique
+ * identifier for a specific blob being committed to the outcome of that read operation. If the
+ * operation was successful, the result for that handle is {@link ParcelFileDescriptor}; if
+ * there was an error, the result contains an {@link AppSearchResult} with details of the
+ * failure.
+ */
+ @NonNull
+ public AppSearchBatchResult<AppSearchBlobHandle, ParcelFileDescriptor> getResult() {
+ return mResultParcel.getResult();
+ }
+
+ @Override
+ public void close() {
+ AppSearchBatchResult<AppSearchBlobHandle, ParcelFileDescriptor> batchResult =
+ mResultParcel.getResult();
+ for (ParcelFileDescriptor pfd : batchResult.getSuccesses().values()) {
+ try {
+ pfd.close();
+ } catch (IOException ignored) {
+ // The file may be already removed, just ignoring any checked exceptions.
+ }
+ }
+ }
+
+ @Override
+ public void writeToParcel(@NonNull Parcel dest, int flags) {
+ OpenBlobForReadResponseCreator.writeToParcel(this, dest, flags);
+ }
+}
diff --git a/appsearch/appsearch/src/main/java/androidx/appsearch/app/AppSearchOpenBlobForWriteResponse.java b/appsearch/appsearch/src/main/java/androidx/appsearch/app/AppSearchOpenBlobForWriteResponse.java
new file mode 100644
index 0000000..b33ca71
--- /dev/null
+++ b/appsearch/appsearch/src/main/java/androidx/appsearch/app/AppSearchOpenBlobForWriteResponse.java
@@ -0,0 +1,110 @@
+/*
+ * 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
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package androidx.appsearch.app;
+
+import android.os.Parcel;
+import android.os.ParcelFileDescriptor;
+import android.os.Parcelable;
+
+import androidx.annotation.NonNull;
+import androidx.appsearch.app.aidl.AppSearchBatchResultGeneralKeyParcel;
+import androidx.appsearch.flags.FlaggedApi;
+import androidx.appsearch.flags.Flags;
+import androidx.appsearch.safeparcel.AbstractSafeParcelable;
+import androidx.appsearch.safeparcel.SafeParcelable;
+import androidx.appsearch.safeparcel.stub.StubCreators.OpenBlobForWriteResponseCreator;
+import androidx.core.util.Preconditions;
+
+import java.io.Closeable;
+import java.io.IOException;
+
+/**
+ * The response to provide batch operation results of
+ * {@link AppSearchSession#openBlobForWriteAsync}.
+ *
+ * <p> This class is used to retrieve the result of a batch write operation on a collection of
+ * blob handles.
+ *
+ * <p class="caution">
+ * The returned {@link android.os.ParcelFileDescriptor} must be closed after use to avoid resource
+ * leaks. Failing to close the descriptor will result in system resource exhaustion, as each open
+ * {@link android.os.ParcelFileDescriptor} occupies a limited file descriptor in the system.
+ * </p>
+ */
+@FlaggedApi(Flags.FLAG_ENABLE_BLOB_STORE)
+@SuppressWarnings("HiddenSuperclass")
[email protected](creator = "OpenBlobForWriteResponseCreator")
+@ExperimentalAppSearchApi
+public final class AppSearchOpenBlobForWriteResponse extends AbstractSafeParcelable implements
+ Closeable {
+
+ @NonNull
+ public static final Parcelable.Creator<AppSearchOpenBlobForWriteResponse> CREATOR =
+ new OpenBlobForWriteResponseCreator();
+
+ @Field(id = 1)
+ final AppSearchBatchResultGeneralKeyParcel<AppSearchBlobHandle, ParcelFileDescriptor>
+ mResultParcel;
+
+ /**
+ * Creates a {@link AppSearchOpenBlobForWriteResponse} with given {@link AppSearchBatchResult}.
+ */
+ public AppSearchOpenBlobForWriteResponse(
+ @NonNull AppSearchBatchResult<AppSearchBlobHandle, ParcelFileDescriptor> result) {
+ this(AppSearchBatchResultGeneralKeyParcel.fromBlobHandleToPfd(result));
+ }
+
+ @AbstractSafeParcelable.Constructor
+ AppSearchOpenBlobForWriteResponse(
+ @AbstractSafeParcelable.Param(id = 1)
+ @NonNull AppSearchBatchResultGeneralKeyParcel<AppSearchBlobHandle, ParcelFileDescriptor>
+ resultParcel) {
+ mResultParcel = Preconditions.checkNotNull(resultParcel);
+ }
+
+ /**
+ * Returns the {@link AppSearchBatchResult} object containing the results of the write blob for
+ * write operation for each {@link AppSearchBlobHandle}.
+ *
+ * @return A {@link AppSearchBatchResult} maps {@link AppSearchBlobHandle}s which is a unique
+ * identifier for a specific blob being committed to the outcome of that write operation. If the
+ * operation was successful, the result for that handle is {@link ParcelFileDescriptor}; if
+ * there was an error, the result contains an {@link AppSearchResult} with details of the
+ * failure.
+ */
+ @NonNull
+ public AppSearchBatchResult<AppSearchBlobHandle, ParcelFileDescriptor> getResult() {
+ return mResultParcel.getResult();
+ }
+
+ @Override
+ public void close() {
+ AppSearchBatchResult<AppSearchBlobHandle, ParcelFileDescriptor> batchResult =
+ mResultParcel.getResult();
+ for (ParcelFileDescriptor pfd : batchResult.getSuccesses().values()) {
+ try {
+ pfd.close();
+ } catch (IOException ignored) {
+ // The file may be already removed, just ignoring any checked exceptions.
+ }
+ }
+ }
+
+ @Override
+ public void writeToParcel(@NonNull Parcel dest, int flags) {
+ OpenBlobForWriteResponseCreator.writeToParcel(this, dest, flags);
+ }
+}
diff --git a/appsearch/appsearch/src/main/java/androidx/appsearch/app/aidl/AppSearchBatchResultGeneralKeyParcel.java b/appsearch/appsearch/src/main/java/androidx/appsearch/app/aidl/AppSearchBatchResultGeneralKeyParcel.java
new file mode 100644
index 0000000..238f9c7
--- /dev/null
+++ b/appsearch/appsearch/src/main/java/androidx/appsearch/app/aidl/AppSearchBatchResultGeneralKeyParcel.java
@@ -0,0 +1,70 @@
+/*
+ * 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
+ *
+ * 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.
+ */
+// @exportToFramework:skipFile()
+package androidx.appsearch.app.aidl;
+
+import android.os.ParcelFileDescriptor;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.RestrictTo;
+import androidx.appsearch.app.AppSearchBatchResult;
+import androidx.appsearch.app.AppSearchBlobHandle;
+import androidx.appsearch.app.ExperimentalAppSearchApi;
+import androidx.core.util.Preconditions;
+
+/**
+ * A dummy version of AppSearchBatchResultGeneralKeyParcel in jetpack.
+ * @param <KeyType> The type of keys in the batch result, such as {@link AppSearchBlobHandle}.
+ * @param <ValueType> The type of values in the batch result, such as {@link ParcelFileDescriptor}
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+@ExperimentalAppSearchApi
+public final class AppSearchBatchResultGeneralKeyParcel<KeyType, ValueType> {
+ private final AppSearchBatchResult<KeyType, ValueType> mResult;
+
+ private AppSearchBatchResultGeneralKeyParcel(
+ @NonNull AppSearchBatchResult<KeyType, ValueType> result) {
+ mResult = Preconditions.checkNotNull(result);
+ }
+
+ /**
+ * Creates an instance of {@link AppSearchBatchResultGeneralKeyParcel} with key type
+ * {@link AppSearchBlobHandle} and value type {@link ParcelFileDescriptor}.
+ */
+ @NonNull
+ public static AppSearchBatchResultGeneralKeyParcel<AppSearchBlobHandle, ParcelFileDescriptor>
+ fromBlobHandleToPfd(
+ @NonNull AppSearchBatchResult<AppSearchBlobHandle, ParcelFileDescriptor> result) {
+ return new AppSearchBatchResultGeneralKeyParcel<>(result);
+ }
+
+ /**
+ * Creates an instance of {@link AppSearchBatchResultGeneralKeyParcel} with key type
+ * {@link AppSearchBlobHandle} and value type {@link Void}.
+ */
+ @NonNull
+ public static AppSearchBatchResultGeneralKeyParcel<AppSearchBlobHandle, Void>
+ fromBlobHandleToVoid(
+ @NonNull AppSearchBatchResult<AppSearchBlobHandle, Void> result) {
+ return new AppSearchBatchResultGeneralKeyParcel<>(result);
+ }
+
+ /** Returns the wrapped batch result. */
+ @NonNull
+ public AppSearchBatchResult<KeyType, ValueType> getResult() {
+ return mResult;
+ }
+}
diff --git a/appsearch/appsearch/src/main/java/androidx/appsearch/safeparcel/stub/StubCreators.java b/appsearch/appsearch/src/main/java/androidx/appsearch/safeparcel/stub/StubCreators.java
index a9b33eb..8a452f2 100644
--- a/appsearch/appsearch/src/main/java/androidx/appsearch/safeparcel/stub/StubCreators.java
+++ b/appsearch/appsearch/src/main/java/androidx/appsearch/safeparcel/stub/StubCreators.java
@@ -17,6 +17,9 @@
import androidx.annotation.RestrictTo;
import androidx.appsearch.app.AppSearchBlobHandle;
+import androidx.appsearch.app.AppSearchCommitBlobResponse;
+import androidx.appsearch.app.AppSearchOpenBlobForReadResponse;
+import androidx.appsearch.app.AppSearchOpenBlobForWriteResponse;
import androidx.appsearch.app.AppSearchSchema;
import androidx.appsearch.app.EmbeddingVector;
import androidx.appsearch.app.GetByDocumentIdRequest;
@@ -201,4 +204,19 @@
public static class AppSearchBlobHandleCreator extends
AbstractCreator<AppSearchBlobHandle> {
}
+
+ /** Stub creator for {@link AppSearchOpenBlobForWriteResponse}. */
+ public static class OpenBlobForWriteResponseCreator extends
+ AbstractCreator<AppSearchOpenBlobForWriteResponse> {
+ }
+
+ /** Stub creator for {@link AppSearchCommitBlobResponse}. */
+ public static class CommitBlobResponseCreator extends
+ AbstractCreator<AppSearchCommitBlobResponse> {
+ }
+
+ /** Stub creator for {@link AppSearchOpenBlobForReadResponse}. */
+ public static class OpenBlobForReadResponseCreator extends
+ AbstractCreator<AppSearchOpenBlobForReadResponse> {
+ }
}
diff --git a/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/ProfileInstallBroadcastTest.kt b/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/ProfileInstallBroadcastTest.kt
index 722b651..a856ba4 100644
--- a/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/ProfileInstallBroadcastTest.kt
+++ b/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/ProfileInstallBroadcastTest.kt
@@ -96,19 +96,32 @@
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.N)
@Test
- fun saveProfilesForAllProcesses() {
+ fun saveProfilesForAllProcesses_target() {
+ // first command just used to wake target - otherwise we'd get 0
+ ProfileInstallBroadcast.dropShaderCache(Packages.TARGET)
+
assertEquals(
- expected = ProfileInstallBroadcast.SaveProfileResult(1, null),
- actual = ProfileInstallBroadcast.saveProfilesForAllProcesses(Packages.TARGET)
+ ProfileInstallBroadcast.SaveProfileResult(1, null),
+ ProfileInstallBroadcast.saveProfilesForAllProcesses(Packages.TARGET)
)
}
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.N)
@Test
+ fun saveProfilesForAllProcesses_self() {
+ val result = ProfileInstallBroadcast.saveProfilesForAllProcesses(Packages.TEST)
+ // only one process, but we don't care if succeeds
+ // (since this test may not depend on profileinstaller)
+ assertEquals(1, result.processCount)
+ }
+
+ @SdkSuppress(minSdkVersion = Build.VERSION_CODES.N)
+ @Test
fun saveProfilesForAllProcesses_missing() {
- val result = ProfileInstallBroadcast.saveProfilesForAllProcesses(Packages.MISSING)
- assertEquals(0, result.processCount)
- assertNull(result.error)
+ assertEquals(
+ ProfileInstallBroadcast.SaveProfileResult(0, null),
+ ProfileInstallBroadcast.saveProfilesForAllProcesses(Packages.MISSING)
+ )
}
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.N)
diff --git a/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/TraceSectionMetricTest.kt b/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/TraceSectionMetricTest.kt
index 0cb5228..b96ad25 100644
--- a/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/TraceSectionMetricTest.kt
+++ b/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/TraceSectionMetricTest.kt
@@ -37,6 +37,13 @@
createTempFileFromAsset(prefix = "api24_commas_in_slice_names", suffix = ".perfetto-trace")
.absolutePath
+ private val truncatedProcessName =
+ createTempFileFromAsset(
+ prefix = "api29_cold_startup_processname_truncated",
+ suffix = ".perfetto-trace"
+ )
+ .absolutePath
+
@Test
fun activityThreadMain() =
verifyFirstSum(
@@ -125,6 +132,20 @@
targetPackageOnly = false,
)
+ @Test
+ fun truncatedProcessName() =
+ verifyFirstSum(
+ tracePath = truncatedProcessName, // trace with truncated target process name
+ packageName = Packages.TARGET,
+ sectionName = "Choreographer#doFrame",
+ expectedFirstMs = 30.819014,
+ expectedMinMs = 0.122031,
+ expectedMaxMs = 30.81901,
+ expectedSumMs = 31.983701,
+ expectedSumCount = 3,
+ targetPackageOnly = true,
+ )
+
companion object {
private fun verifyMetric(
tracePath: String,
diff --git a/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/perfetto/StartupTimingQueryTest.kt b/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/perfetto/StartupTimingQueryTest.kt
index 1cb8b92..cc3d69f 100644
--- a/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/perfetto/StartupTimingQueryTest.kt
+++ b/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/perfetto/StartupTimingQueryTest.kt
@@ -126,7 +126,7 @@
)
/**
- * Validate that StartupTimingQuery returns null and doesn't crash when process name truncated
+ * Validate that StartupTimingQuery successfully captures metrics when process name truncated
*/
@Test
fun fixedApi29ColdProcessNameTruncated() =
@@ -134,6 +134,11 @@
api = 29,
startupMode = StartupMode.COLD,
tracePrefix = "api29_cold_startup_processname_truncated",
- expectedMetrics = null // process name is truncated, and we currently don't handle this
+ expectedMetrics =
+ StartupTimingQuery.SubMetrics(
+ timeToInitialDisplayNs = 145119546,
+ timeToFullDisplayNs = null,
+ timelineRangeNs = 935014155850..935159275396
+ )
)
}
diff --git a/benchmark/benchmark-macro/src/main/java/androidx/benchmark/perfetto/PerfettoTraceProcessor.kt b/benchmark/benchmark-macro/src/main/java/androidx/benchmark/perfetto/PerfettoTraceProcessor.kt
index 4305566..56e6efc 100644
--- a/benchmark/benchmark-macro/src/main/java/androidx/benchmark/perfetto/PerfettoTraceProcessor.kt
+++ b/benchmark/benchmark-macro/src/main/java/androidx/benchmark/perfetto/PerfettoTraceProcessor.kt
@@ -466,5 +466,14 @@
/** Helper for fuzzy matching process name to package */
internal fun processNameLikePkg(pkg: String): String {
- return """(process.name LIKE "$pkg" OR process.name LIKE "$pkg:%")"""
+ // check for truncated package names, which can sometimes occur if perfetto can't capture full
+ // names, and only has 16 bytes from sched info (which results in 15 chars due to null
+ // termination)
+ val truncated =
+ if (pkg.length > 15) {
+ " OR process.name LIKE \"${pkg.takeLast(15)}\""
+ } else {
+ ""
+ }
+ return """(process.name LIKE "$pkg" OR process.name LIKE "$pkg:%"$truncated)"""
}
diff --git a/buildSrc/private/src/main/kotlin/androidx/build/AndroidXComposeImplPlugin.kt b/buildSrc/private/src/main/kotlin/androidx/build/AndroidXComposeImplPlugin.kt
index 76cb02d..08f6ad5 100644
--- a/buildSrc/private/src/main/kotlin/androidx/build/AndroidXComposeImplPlugin.kt
+++ b/buildSrc/private/src/main/kotlin/androidx/build/AndroidXComposeImplPlugin.kt
@@ -125,7 +125,7 @@
}
// Add Compose compiler plugin to kotlinPlugin configuration, making sure it works
// for Playground builds as well
- val compilerPluginVersion = project.getVersionByName("kotlin")
+ val compilerPluginVersion = project.getVersionByName("composeCompilerPlugin")
project.dependencies.add(
COMPILER_PLUGIN_CONFIGURATION,
"org.jetbrains.kotlin:kotlin-compose-compiler-plugin-embeddable:$compilerPluginVersion"
@@ -185,11 +185,8 @@
compile.pluginClasspath.from(kotlinPluginProvider.get())
- // todo(b/291587160): enable when Compose compiler 2.0.20 is merged
- // compile.enableFeatureFlag(ComposeFeatureFlag.StrongSkipping)
- // compile.enableFeatureFlag(ComposeFeatureFlag.OptimizeNonSkippingGroups)
- compile.addPluginOption(ComposeCompileOptions.StrongSkipping, "true")
- compile.addPluginOption(ComposeCompileOptions.NonSkippingGroupOptimization, "true")
+ compile.enableFeatureFlag(ComposeFeatureFlag.StrongSkipping)
+ compile.enableFeatureFlag(ComposeFeatureFlag.OptimizeNonSkippingGroups)
if (shouldPublish) {
compile.addPluginOption(ComposeCompileOptions.SourceOption, "true")
}
diff --git a/camera/camera-camera2-pipe-integration/src/androidTest/java/androidx/camera/camera2/pipe/integration/UseCaseSurfaceManagerDeviceTest.kt b/camera/camera-camera2-pipe-integration/src/androidTest/java/androidx/camera/camera2/pipe/integration/UseCaseSurfaceManagerDeviceTest.kt
index 5810c6e..bde668a 100644
--- a/camera/camera-camera2-pipe-integration/src/androidTest/java/androidx/camera/camera2/pipe/integration/UseCaseSurfaceManagerDeviceTest.kt
+++ b/camera/camera-camera2-pipe-integration/src/androidTest/java/androidx/camera/camera2/pipe/integration/UseCaseSurfaceManagerDeviceTest.kt
@@ -195,24 +195,13 @@
)
assertThat(surfaceActiveCountDown.await(3, TimeUnit.SECONDS)).isTrue()
val cameraOpenedUsageCount = testSessionParameters.deferrableSurface.useCount
- val cameraDisconnectedUsageCount: Int
// Act. Launch Camera2Activity to open the camera, it disconnects the CameraGraph.
ActivityScenario.launch<Camera2TestActivity>(
Intent(ApplicationProvider.getApplicationContext(), Camera2TestActivity::class.java)
.apply { putExtra(Camera2TestActivity.EXTRA_CAMERA_ID, cameraId) }
)
- .use {
- // TODO(b/268768235): Under some conditions, it is possible that the camera gets
- // disconnected for both the foreground and test activity, before the preview has a
- // chance to be ready. Fix it with follow-up changes to change this test by using a
- // CameraGraphSimulator rather than a real CameraGraph.
- // lateinit var previewReady: IdlingResource
- // it.onActivity { activity -> previewReady = activity.mPreviewReady!! }
- // previewReady.waitForIdle()
-
- cameraDisconnectedUsageCount = testSessionParameters.deferrableSurface.useCount
- }
+ .close()
// Close the CameraGraph to ensure the usage count does go back down.
testUseCaseCamera.useCaseCameraGraphConfig.graph.close()
testUseCaseCamera.useCaseSurfaceManager.stopAsync().awaitWithTimeout()
@@ -221,7 +210,6 @@
// Assert, verify the usage count of the DeferrableSurface
assertThat(cameraOpenedUsageCount).isEqualTo(2)
- assertThat(cameraDisconnectedUsageCount).isEqualTo(2)
assertThat(cameraClosedUsageCount).isEqualTo(1)
}
diff --git a/camera/camera-camera2-pipe-testing/src/main/java/androidx/camera/camera2/pipe/testing/FakeCameraBackend.kt b/camera/camera-camera2-pipe-testing/src/main/java/androidx/camera/camera2/pipe/testing/FakeCameraBackend.kt
index f624b2b..9726735 100644
--- a/camera/camera-camera2-pipe-testing/src/main/java/androidx/camera/camera2/pipe/testing/FakeCameraBackend.kt
+++ b/camera/camera-camera2-pipe-testing/src/main/java/androidx/camera/camera2/pipe/testing/FakeCameraBackend.kt
@@ -25,6 +25,7 @@
import androidx.camera.camera2.pipe.CameraId
import androidx.camera.camera2.pipe.CameraMetadata
import androidx.camera.camera2.pipe.StreamGraph
+import androidx.camera.camera2.pipe.SurfaceTracker
import androidx.camera.camera2.pipe.graph.GraphListener
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Deferred
@@ -63,7 +64,8 @@
graphId: CameraGraphId,
graphConfig: CameraGraph.Config,
graphListener: GraphListener,
- streamGraph: StreamGraph
+ streamGraph: StreamGraph,
+ surfaceTracker: SurfaceTracker,
): CameraController {
val cameraController =
CameraControllerSimulator(cameraContext, graphId, graphConfig, graphListener)
diff --git a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/CameraBackend.kt b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/CameraBackend.kt
index 19859af..80670b0 100644
--- a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/CameraBackend.kt
+++ b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/CameraBackend.kt
@@ -105,7 +105,8 @@
graphId: CameraGraphId,
graphConfig: CameraGraph.Config,
graphListener: GraphListener,
- streamGraph: StreamGraph
+ streamGraph: StreamGraph,
+ surfaceTracker: SurfaceTracker,
): CameraController
/** Connects and starts the underlying camera */
diff --git a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/SurfaceTracker.kt b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/SurfaceTracker.kt
new file mode 100644
index 0000000..8cd26fe
--- /dev/null
+++ b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/SurfaceTracker.kt
@@ -0,0 +1,40 @@
+/*
+ * 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
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.camera2.pipe
+
+import android.view.Surface
+import androidx.annotation.RestrictTo
+
+/**
+ * A SurfaceTracker tracks the current usage of [Surface]s at the CameraGraph level. It keeps track
+ * of the surface usages through the tokens acquired from [CameraSurfaceManager].
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+public interface SurfaceTracker {
+ /**
+ * Disconnect and releases the current SurfaceTokens. Intended to be used when a camera is
+ * closed due to disconnect, error or just simple camera shutdown. Note that after this call, no
+ * tokens are going to be acquired for new Surfaces until [registerAllSurfaces] is called.
+ */
+ public fun unregisterAllSurfaces()
+
+ /**
+ * Reacquires the SurfaceTokens for the currently configured Surfaces. Intended to be used when
+ * the camera is restarted and the Surfaces are used again.
+ */
+ public fun registerAllSurfaces()
+}
diff --git a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/Camera2Backend.kt b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/Camera2Backend.kt
index 79900402..39ed241 100644
--- a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/Camera2Backend.kt
+++ b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/Camera2Backend.kt
@@ -25,6 +25,7 @@
import androidx.camera.camera2.pipe.CameraId
import androidx.camera.camera2.pipe.CameraMetadata
import androidx.camera.camera2.pipe.StreamGraph
+import androidx.camera.camera2.pipe.SurfaceTracker
import androidx.camera.camera2.pipe.config.Camera2ControllerComponent
import androidx.camera.camera2.pipe.config.Camera2ControllerConfig
import androidx.camera.camera2.pipe.graph.GraphListener
@@ -92,7 +93,8 @@
graphId: CameraGraphId,
graphConfig: CameraGraph.Config,
graphListener: GraphListener,
- streamGraph: StreamGraph
+ streamGraph: StreamGraph,
+ surfaceTracker: SurfaceTracker,
): CameraController {
// Use Dagger to create the camera2 controller component, then create the CameraController.
val cameraControllerComponent =
@@ -104,6 +106,7 @@
graphConfig,
graphListener,
streamGraph as StreamGraphImpl,
+ surfaceTracker,
)
)
.build()
diff --git a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/Camera2CameraController.kt b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/Camera2CameraController.kt
index 9268480..203ca4c 100644
--- a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/Camera2CameraController.kt
+++ b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/Camera2CameraController.kt
@@ -30,6 +30,7 @@
import androidx.camera.camera2.pipe.GraphState
import androidx.camera.camera2.pipe.StreamGraph
import androidx.camera.camera2.pipe.StreamId
+import androidx.camera.camera2.pipe.SurfaceTracker
import androidx.camera.camera2.pipe.config.Camera2ControllerScope
import androidx.camera.camera2.pipe.core.DurationNs
import androidx.camera.camera2.pipe.core.Log
@@ -64,6 +65,7 @@
private val threads: Threads,
private val graphConfig: CameraGraph.Config,
private val graphListener: GraphListener,
+ private val surfaceTracker: SurfaceTracker,
private val cameraStatusMonitor: CameraStatusMonitor,
private val captureSessionFactory: CaptureSessionFactory,
private val captureSequenceProcessorFactory: Camera2CaptureSequenceProcessorFactory,
@@ -171,6 +173,7 @@
controllerState != ControllerState.STOPPED
) {
Log.debug { "$this: Restarting Camera2CameraController..." }
+ surfaceTracker.registerAllSurfaces()
stopLocked()
startLocked()
}
@@ -368,6 +371,8 @@
} else {
controllerState = ControllerState.STOPPED
}
+
+ surfaceTracker.unregisterAllSurfaces()
tryRestart()
}
}
diff --git a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/config/Camera2Component.kt b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/config/Camera2Component.kt
index 7684737..a92c954 100644
--- a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/config/Camera2Component.kt
+++ b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/config/Camera2Component.kt
@@ -22,6 +22,7 @@
import androidx.camera.camera2.pipe.CameraGraph
import androidx.camera.camera2.pipe.CameraGraphId
import androidx.camera.camera2.pipe.StreamGraph
+import androidx.camera.camera2.pipe.SurfaceTracker
import androidx.camera.camera2.pipe.compat.AudioRestrictionController
import androidx.camera.camera2.pipe.compat.AudioRestrictionControllerImpl
import androidx.camera.camera2.pipe.compat.Camera2Backend
@@ -123,6 +124,7 @@
private val graphConfig: CameraGraph.Config,
private val graphListener: GraphListener,
private val streamGraph: StreamGraph,
+ private val surfaceTracker: SurfaceTracker,
) {
@Provides fun provideCameraGraphConfig() = graphConfig
@@ -133,6 +135,8 @@
@Provides fun provideStreamGraph() = streamGraph as StreamGraphImpl
@Provides fun provideGraphListener() = graphListener
+
+ @Provides fun provideSurfaceGraph() = surfaceTracker
}
@Module
diff --git a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/config/CameraGraphComponent.kt b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/config/CameraGraphComponent.kt
index e7a6b9c..99b54433c 100644
--- a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/config/CameraGraphComponent.kt
+++ b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/config/CameraGraphComponent.kt
@@ -27,6 +27,7 @@
import androidx.camera.camera2.pipe.CameraSurfaceManager
import androidx.camera.camera2.pipe.Request
import androidx.camera.camera2.pipe.StreamGraph
+import androidx.camera.camera2.pipe.SurfaceTracker
import androidx.camera.camera2.pipe.core.Threads
import androidx.camera.camera2.pipe.graph.CameraGraphImpl
import androidx.camera.camera2.pipe.graph.GraphListener
@@ -43,6 +44,7 @@
import dagger.Module
import dagger.Provides
import dagger.Subcomponent
+import javax.inject.Provider
import javax.inject.Qualifier
import javax.inject.Scope
import kotlinx.coroutines.CoroutineName
@@ -93,6 +95,10 @@
@Binds abstract fun bindStreamGraph(streamGraph: StreamGraphImpl): StreamGraph
+ @CameraGraphScope
+ @Binds
+ abstract fun bindSurfaceTracker(surfaceGraph: SurfaceGraph): SurfaceTracker
+
@Binds
abstract fun bindCameraGraphParameters(
parameters: CameraGraphParametersImpl
@@ -139,7 +145,7 @@
@Provides
fun provideSurfaceGraph(
streamGraphImpl: StreamGraphImpl,
- cameraController: CameraController,
+ cameraController: Provider<CameraController>,
cameraSurfaceManager: CameraSurfaceManager,
imageSourceMap: ImageSourceMap
): SurfaceGraph {
@@ -208,13 +214,15 @@
cameraContext: CameraContext,
graphProcessor: GraphProcessorImpl,
streamGraph: StreamGraph,
+ surfaceTracker: SurfaceTracker,
): CameraController {
return cameraBackend.createCameraController(
cameraContext,
graphId,
graphConfig,
graphProcessor,
- streamGraph
+ streamGraph,
+ surfaceTracker,
)
}
}
diff --git a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/config/ExternalCameraGraphComponent.kt b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/config/ExternalCameraGraphComponent.kt
index 7089a3b..ef4d68a 100644
--- a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/config/ExternalCameraGraphComponent.kt
+++ b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/config/ExternalCameraGraphComponent.kt
@@ -28,6 +28,7 @@
import androidx.camera.camera2.pipe.CameraMetadata
import androidx.camera.camera2.pipe.RequestProcessor
import androidx.camera.camera2.pipe.StreamGraph
+import androidx.camera.camera2.pipe.SurfaceTracker
import androidx.camera.camera2.pipe.compat.ExternalCameraController
import androidx.camera.camera2.pipe.graph.GraphListener
import dagger.Module
@@ -88,7 +89,8 @@
graphId: CameraGraphId,
graphConfig: CameraGraph.Config,
graphListener: GraphListener,
- streamGraph: StreamGraph
+ streamGraph: StreamGraph,
+ surfaceTracker: SurfaceTracker,
): CameraController {
throwUnsupportedOperationException()
}
diff --git a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/graph/SurfaceGraph.kt b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/graph/SurfaceGraph.kt
index 76885a4..b65d285 100644
--- a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/graph/SurfaceGraph.kt
+++ b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/graph/SurfaceGraph.kt
@@ -22,8 +22,10 @@
import androidx.camera.camera2.pipe.CameraGraph
import androidx.camera.camera2.pipe.CameraSurfaceManager
import androidx.camera.camera2.pipe.StreamId
+import androidx.camera.camera2.pipe.SurfaceTracker
import androidx.camera.camera2.pipe.core.Log
import androidx.camera.camera2.pipe.media.ImageSource
+import javax.inject.Provider
/**
* A SurfaceGraph tracks the current stream-to-surface mapping state for a [CameraGraph] instance.
@@ -33,18 +35,28 @@
*/
internal class SurfaceGraph(
private val streamGraphImpl: StreamGraphImpl,
- private val cameraController: CameraController,
+ private val cameraController: Provider<CameraController>,
private val surfaceManager: CameraSurfaceManager,
private val imageSources: Map<StreamId, ImageSource>
-) {
+) : SurfaceTracker, AutoCloseable {
private val lock = Any()
+ /**
+ * A map of [StreamId]s to [Surface]s that stores the mapping of [Surface]s set on the streams
+ * on a [CameraGraph].
+ */
@GuardedBy("lock")
private val surfaceMap = imageSources.mapValuesTo(mutableMapOf()) { it.value.surface }
+ /**
+ * A map of [Surface]s to closeables from [CameraSurfaceManager]. This keeps track of the token
+ * each [Surface] is associated with, as well as the current tokens that remain active.
+ */
@GuardedBy("lock")
private val surfaceUsageMap: MutableMap<Surface, AutoCloseable> = mutableMapOf()
+ @GuardedBy("lock") private var shouldRegisterSurfaces = true
+
@GuardedBy("lock") private var closed: Boolean = false
operator fun set(streamId: StreamId, surface: Surface?) {
@@ -72,14 +84,14 @@
// TODO: Tell the graph processor that it should resubmit the repeating request
// or reconfigure the camera2 captureSession
val oldSurface = surfaceMap.remove(streamId)
- if (oldSurface != null) {
+ if (shouldRegisterSurfaces && oldSurface != null) {
oldSurfaceToken = surfaceUsageMap.remove(oldSurface)
}
} else {
val oldSurface = surfaceMap[streamId]
surfaceMap[streamId] = surface
- if (oldSurface != surface) {
+ if (shouldRegisterSurfaces && oldSurface != surface) {
check(!surfaceUsageMap.containsKey(surface)) {
"Surface ($surface) is already in use!"
}
@@ -95,7 +107,30 @@
closeable?.close()
}
- fun close() {
+ override fun unregisterAllSurfaces() {
+ val closeables =
+ synchronized(lock) {
+ shouldRegisterSurfaces = false
+ surfaceUsageMap.values.toList().also { surfaceUsageMap.clear() }
+ }
+ for (closeable in closeables) {
+ closeable.close()
+ }
+ }
+
+ override fun registerAllSurfaces() {
+ synchronized(lock) {
+ check(!closed)
+ for (surface in surfaceMap.values) {
+ surfaceManager.registerSurface(surface).also { token ->
+ surfaceUsageMap[surface] = token
+ }
+ }
+ shouldRegisterSurfaces = true
+ }
+ }
+
+ override fun close() {
val closeables =
synchronized(lock) {
if (closed) {
@@ -122,7 +157,7 @@
if (surfaces.isEmpty()) {
return
}
- cameraController.updateSurfaceMap(surfaces)
+ cameraController.get().updateSurfaceMap(surfaces)
}
private fun buildSurfaceMap(): Map<StreamId, Surface> =
diff --git a/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/compat/Camera2CameraControllerTest.kt b/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/compat/Camera2CameraControllerTest.kt
index 1d71917..2c1314b 100644
--- a/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/compat/Camera2CameraControllerTest.kt
+++ b/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/compat/Camera2CameraControllerTest.kt
@@ -29,6 +29,7 @@
import androidx.camera.camera2.pipe.CameraSurfaceManager
import androidx.camera.camera2.pipe.StreamFormat
import androidx.camera.camera2.pipe.StreamId
+import androidx.camera.camera2.pipe.SurfaceTracker
import androidx.camera.camera2.pipe.core.TimeSource
import androidx.camera.camera2.pipe.core.TimestampNs
import androidx.camera.camera2.pipe.graph.GraphListener
@@ -66,6 +67,7 @@
private val streamId1 = StreamId(1)
private val fakeGraphConfig = CameraGraph.Config(cameraId, listOf(streamConfig1))
private val fakeGraphListener: GraphListener = mock()
+ private val fakeSurfaceTracker: SurfaceTracker = mock()
// TODO: b/372258646 - Implement a proper fake implementation to simulate status changes.
private val fakeCameraStatusMonitor = FakeCameraStatusMonitor(cameraId)
@@ -86,6 +88,7 @@
fakeThreads,
fakeGraphConfig,
fakeGraphListener,
+ fakeSurfaceTracker,
fakeCameraStatusMonitor,
fakeCaptureSessionFactory,
fakeCaptureSequenceProcessorFactory,
diff --git a/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/graph/CameraGraphImplTest.kt b/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/graph/CameraGraphImplTest.kt
index 5598cda..1a7117a 100644
--- a/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/graph/CameraGraphImplTest.kt
+++ b/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/graph/CameraGraphImplTest.kt
@@ -118,7 +118,7 @@
private val frameDistributor =
FrameDistributor(imageSourceMap.imageSources, frameCaptureQueue) {}
private val surfaceGraph =
- SurfaceGraph(streamGraph, cameraController, cameraSurfaceManager, emptyMap())
+ SurfaceGraph(streamGraph, cameraControllerProvider, cameraSurfaceManager, emptyMap())
private val audioRestriction = FakeAudioRestrictionController()
private val sessionLock = SessionLock()
private val cameraGraph =
diff --git a/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/graph/SurfaceGraphTest.kt b/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/graph/SurfaceGraphTest.kt
index 4f7c6c4..77f018e 100644
--- a/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/graph/SurfaceGraphTest.kt
+++ b/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/graph/SurfaceGraphTest.kt
@@ -19,6 +19,7 @@
import android.graphics.SurfaceTexture
import android.os.Build
import android.view.Surface
+import androidx.camera.camera2.pipe.CameraController
import androidx.camera.camera2.pipe.CameraGraphId
import androidx.camera.camera2.pipe.CameraSurfaceManager
import androidx.camera.camera2.pipe.testing.FakeCameraController
@@ -44,6 +45,7 @@
private val config = FakeGraphConfigs
private val graphId = CameraGraphId.nextId()
private val fakeCameraController = FakeCameraController(graphId)
+ private val fakeCameraControllerProvider: () -> CameraController = { fakeCameraController }
private val streamMap = StreamGraphImpl(config.fakeMetadata, config.graphConfig, mock())
@@ -51,7 +53,7 @@
private val cameraSurfaceManager =
CameraSurfaceManager().also { it.addListener(fakeSurfaceListener) }
private val surfaceGraph =
- SurfaceGraph(streamMap, fakeCameraController, cameraSurfaceManager, emptyMap())
+ SurfaceGraph(streamMap, fakeCameraControllerProvider, cameraSurfaceManager, emptyMap())
private val stream1 = streamMap[config.streamConfig1]!!
private val stream2 = streamMap[config.streamConfig2]!!
@@ -230,7 +232,7 @@
}
@Test
- fun settingSurfaceToNullThenPreviousSurfaceWillReaquireSurfaceToken() {
+ fun settingSurfaceToNullThenPreviousSurfaceWillReacquireSurfaceToken() {
surfaceGraph[stream1.id] = fakeSurface1
surfaceGraph[stream1.id] = null
surfaceGraph[stream1.id] = fakeSurface1
@@ -248,4 +250,52 @@
surfaceGraph[stream1.id] = fakeSurface1
assertThrows<Exception> { surfaceGraph[stream2.id] = fakeSurface1 }
}
+
+ @Test
+ fun disconnectSurfaceGraphReleasesSurfaceTokens() {
+ surfaceGraph[stream1.id] = fakeSurface1
+ surfaceGraph[stream2.id] = fakeSurface2
+ verify(fakeSurfaceListener, times(1)).onSurfaceActive(fakeSurface1)
+ verify(fakeSurfaceListener, times(1)).onSurfaceActive(fakeSurface2)
+
+ surfaceGraph.unregisterAllSurfaces()
+ verify(fakeSurfaceListener, times(1)).onSurfaceInactive(fakeSurface1)
+ verify(fakeSurfaceListener, times(1)).onSurfaceInactive(fakeSurface2)
+ }
+
+ @Test
+ fun reconnectSurfaceGraphThenSurfaceWillReacquireSurfaceTokens() {
+ surfaceGraph[stream1.id] = fakeSurface1
+ surfaceGraph[stream2.id] = fakeSurface2
+ verify(fakeSurfaceListener, times(1)).onSurfaceActive(fakeSurface1)
+ verify(fakeSurfaceListener, times(1)).onSurfaceActive(fakeSurface2)
+
+ surfaceGraph.unregisterAllSurfaces()
+ verify(fakeSurfaceListener, times(1)).onSurfaceInactive(fakeSurface1)
+ verify(fakeSurfaceListener, times(1)).onSurfaceInactive(fakeSurface2)
+
+ surfaceGraph.registerAllSurfaces()
+ verify(fakeSurfaceListener, times(2)).onSurfaceActive(fakeSurface1)
+ verify(fakeSurfaceListener, times(2)).onSurfaceActive(fakeSurface2)
+ }
+
+ @Test
+ fun reconnectSurfaceGraphWhenSurfaceChangedThenOnlyNewSurfaceWillReacquireSurfaceTokens() {
+ surfaceGraph[stream1.id] = fakeSurface1
+ surfaceGraph[stream2.id] = fakeSurface2
+ verify(fakeSurfaceListener, times(1)).onSurfaceActive(fakeSurface1)
+ verify(fakeSurfaceListener, times(1)).onSurfaceActive(fakeSurface2)
+
+ surfaceGraph.unregisterAllSurfaces()
+ verify(fakeSurfaceListener, times(1)).onSurfaceInactive(fakeSurface1)
+ verify(fakeSurfaceListener, times(1)).onSurfaceInactive(fakeSurface2)
+
+ surfaceGraph[stream2.id] = fakeSurface3
+ verify(fakeSurfaceListener, times(1)).onSurfaceInactive(fakeSurface2)
+ verify(fakeSurfaceListener, never()).onSurfaceActive(fakeSurface3)
+
+ surfaceGraph.registerAllSurfaces()
+ verify(fakeSurfaceListener, times(2)).onSurfaceActive(fakeSurface1)
+ verify(fakeSurfaceListener, times(1)).onSurfaceActive(fakeSurface3)
+ }
}
diff --git a/camera/integration-tests/coretestapp/src/androidTest/java/androidx/camera/integration/core/CameraDisconnectTest.kt b/camera/integration-tests/coretestapp/src/androidTest/java/androidx/camera/integration/core/CameraDisconnectTest.kt
index 34f4db5..f9ff936 100644
--- a/camera/integration-tests/coretestapp/src/androidTest/java/androidx/camera/integration/core/CameraDisconnectTest.kt
+++ b/camera/integration-tests/coretestapp/src/androidTest/java/androidx/camera/integration/core/CameraDisconnectTest.kt
@@ -58,7 +58,6 @@
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import org.junit.After
-import org.junit.Assume.assumeFalse
import org.junit.Assume.assumeTrue
import org.junit.Before
import org.junit.Rule
@@ -244,11 +243,6 @@
@Test
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.M) // Known issue, checkout b/147393563.
fun canRecovered_afterReceivingCameraOnDisconnectedEvent() {
- // TODO(b/344749041) The tests can run failed on API 27 devices in camera-pipe config
- assumeFalse(
- Build.VERSION.SDK_INT == Build.VERSION_CODES.O_MR1 &&
- implName == CameraPipeConfig::class.simpleName
- )
// Launch CameraX activity
cameraXActivityScenario = launchCameraXActivity(cameraId)
with(cameraXActivityScenario) {
diff --git a/camera/integration-tests/coretestapp/src/main/java/androidx/camera/integration/core/CameraXActivity.java b/camera/integration-tests/coretestapp/src/main/java/androidx/camera/integration/core/CameraXActivity.java
index 69e5e0e..fe020c4 100644
--- a/camera/integration-tests/coretestapp/src/main/java/androidx/camera/integration/core/CameraXActivity.java
+++ b/camera/integration-tests/coretestapp/src/main/java/androidx/camera/integration/core/CameraXActivity.java
@@ -71,6 +71,7 @@
import android.provider.MediaStore;
import android.util.DisplayMetrics;
import android.util.Log;
+import android.util.Pair;
import android.util.Range;
import android.util.Rational;
import android.view.Display;
@@ -193,6 +194,7 @@
private static final String TAG = "CameraXActivity";
private static final String[] REQUIRED_PERMISSIONS;
private static final List<DynamicRangeUiData> DYNAMIC_RANGE_UI_DATA = new ArrayList<>();
+ private static final List<Pair<Range<Integer>, String>> FPS_OPTIONS = new ArrayList<>();
static {
// From Android T, skips the permission check of WRITE_EXTERNAL_STORAGE since it won't be
@@ -238,6 +240,15 @@
DynamicRange.DOLBY_VISION_10_BIT,
"HDR (Dolby Vision, 10-bit)",
R.string.toggle_video_dyn_rng_hdr_dolby_vision_10));
+
+ // TODO - Indicate whether the FPS ranges are supported with
+ // `CameraInfo.getSupportedFrameRateRanges()`, but we may want to try unsupported cases too
+ // sometimes for testing, so the unsupported ones still should be options (perhaps greyed
+ // out or struck-through).
+ FPS_OPTIONS.add(new Pair<>(new Range<>(0, 0), "Unspecified"));
+ FPS_OPTIONS.add(new Pair<>(new Range<>(15, 15), "15"));
+ FPS_OPTIONS.add(new Pair<>(new Range<>(30, 30), "30"));
+ FPS_OPTIONS.add(new Pair<>(new Range<>(60, 60), "60"));
}
//Use this activity title when Camera Pipe configuration is used by core test app
@@ -355,6 +366,7 @@
private Button mZoomIn2XToggle;
private Button mZoomResetToggle;
private Button mButtonImageOutputFormat;
+ private Button mButtonFps;
private Toast mEvToast = null;
private Toast mPSToast = null;
private ToggleButton mPreviewStabilizationToggle;
@@ -371,6 +383,7 @@
private final Set<DynamicRange> mSelectableDynamicRanges = new HashSet<>();
private int mVideoMirrorMode = MIRROR_MODE_ON_FRONT_ONLY;
private boolean mIsPreviewStabilizationOn = false;
+ private int mFpsMenuId = 0;
SessionMediaUriSet mSessionImagesUriSet = new SessionMediaUriSet();
SessionMediaUriSet mSessionVideosUriSet = new SessionMediaUriSet();
@@ -1277,6 +1290,7 @@
mZoomSeekBar.setVisibility(View.GONE);
mZoomRatioLabel.setVisibility(View.GONE);
mTextView.setVisibility(View.GONE);
+ mButtonFps.setVisibility(View.GONE);
if (testCase.equals(PREVIEW_TEST_CASE) || testCase.equals(SWITCH_TEST_CASE)) {
mTorchButton.setVisibility(View.GONE);
@@ -1389,6 +1403,7 @@
mPlusEV.setEnabled(isExposureCompensationSupported());
mDecEV.setEnabled(isExposureCompensationSupported());
mZoomIn2XToggle.setEnabled(is2XZoomSupported());
+ mButtonFps.setEnabled(mPreviewToggle.isChecked() || mVideoToggle.isChecked());
// this function may make some view visible again, so need to update for E2E tests again
updateAppUIForE2ETest();
@@ -1592,6 +1607,42 @@
findViewById(R.id.video_mute),
(newState) -> updateDynamicRangeUiState()
);
+ mButtonFps = findViewById(R.id.fps);
+ if (mFpsMenuId == 0) {
+ mButtonFps.setText("FPS\nUnsp.");
+ } else {
+ mButtonFps.setText("FPS\n" + FPS_OPTIONS.get(mFpsMenuId).second);
+ }
+ mButtonFps.setOnClickListener(view -> {
+ PopupMenu popup = new PopupMenu(this, view);
+ Menu menu = popup.getMenu();
+
+ for (int i = 0; i < FPS_OPTIONS.size(); i++) {
+ menu.add(0, i, Menu.NONE, FPS_OPTIONS.get(i).second);
+ }
+
+ menu.findItem(mFpsMenuId).setChecked(true);
+
+ // Make menu single checkable
+ menu.setGroupCheckable(0, true, true);
+
+ popup.setOnMenuItemClickListener(item -> {
+ int itemId = item.getItemId();
+ if (itemId != mFpsMenuId) {
+ mFpsMenuId = itemId;
+ if (mFpsMenuId == 0) {
+ mButtonFps.setText("FPS\nUnsp.");
+ } else {
+ mButtonFps.setText("FPS\n" + FPS_OPTIONS.get(mFpsMenuId).second);
+ }
+ // FPS changed, rebind UseCases
+ tryBindUseCases();
+ }
+ return true;
+ });
+
+ popup.show();
+ });
setUpButtonEvents();
setupViewFinderGestureControls();
@@ -1911,6 +1962,7 @@
.setPreviewStabilizationEnabled(mIsPreviewStabilizationOn)
.setDynamicRange(
mVideoToggle.isChecked() ? DynamicRange.UNSPECIFIED : mDynamicRange)
+ .setTargetFrameRate(FPS_OPTIONS.get(mFpsMenuId).first)
.build();
resetViewIdlingResource();
// Use the listener of the future to make sure the Preview setup the new surface.
@@ -1959,6 +2011,7 @@
mVideoCapture = new VideoCapture.Builder<>(mRecorder)
.setMirrorMode(mVideoMirrorMode)
.setDynamicRange(mDynamicRange)
+ .setTargetFrameRate(FPS_OPTIONS.get(mFpsMenuId).first)
.build();
}
useCases.add(mVideoCapture);
diff --git a/camera/integration-tests/coretestapp/src/main/res/layout/activity_camera_xmain.xml b/camera/integration-tests/coretestapp/src/main/res/layout/activity_camera_xmain.xml
index c65182e..bc7e3db 100644
--- a/camera/integration-tests/coretestapp/src/main/res/layout/activity_camera_xmain.xml
+++ b/camera/integration-tests/coretestapp/src/main/res/layout/activity_camera_xmain.xml
@@ -437,6 +437,20 @@
app:layout_constraintStart_toEndOf="@+id/seekBar"
app:layout_constraintTop_toTopOf="@+id/seekBar" />
+ <Button
+ android:id="@+id/fps"
+ android:layout_width="46dp"
+ android:layout_height="wrap_content"
+ android:layout_marginStart="5dp"
+ android:layout_marginTop="1dp"
+ android:background="@android:drawable/btn_default"
+ android:text="FPS"
+ android:textSize="7dp"
+ android:translationZ="1dp"
+ app:layout_constraintTop_toBottomOf="@id/preview_stabilization"
+ app:layout_constraintLeft_toLeftOf="parent"
+ />
+
<androidx.camera.view.ScreenFlashView
android:id="@+id/screen_flash_view"
android:layout_width="match_parent"
diff --git a/compose/foundation/foundation/api/current.ignore b/compose/foundation/foundation/api/current.ignore
index 3694fd4..5333980 100644
--- a/compose/foundation/foundation/api/current.ignore
+++ b/compose/foundation/foundation/api/current.ignore
@@ -1,6 +1,4 @@
// Baseline format: 1.0
-AddedAbstractMethod: androidx.compose.foundation.OverscrollEffect#getNode():
- Added method androidx.compose.foundation.OverscrollEffect.getNode()
AddedAbstractMethod: androidx.compose.foundation.gestures.DraggableAnchors#anchorAt(int):
Added method androidx.compose.foundation.gestures.DraggableAnchors.anchorAt(int)
AddedAbstractMethod: androidx.compose.foundation.gestures.DraggableAnchors#hasPositionFor(T):
@@ -25,9 +23,5 @@
Attempted to change parameter name from value to anchor in method androidx.compose.foundation.gestures.DraggableAnchors.positionOf
-RemovedClass: androidx.compose.foundation.OverscrollConfiguration_androidKt:
- Removed class androidx.compose.foundation.OverscrollConfiguration_androidKt
-
-
RemovedMethod: androidx.compose.foundation.lazy.grid.GridItemSpan#getCurrentLineSpan():
Removed method androidx.compose.foundation.lazy.grid.GridItemSpan.getCurrentLineSpan()
diff --git a/compose/foundation/foundation/api/current.txt b/compose/foundation/foundation/api/current.txt
index b0cbdc8..30beb65 100644
--- a/compose/foundation/foundation/api/current.txt
+++ b/compose/foundation/foundation/api/current.txt
@@ -221,13 +221,29 @@
method public inline boolean tryMutate(kotlin.jvm.functions.Function0<kotlin.Unit> block);
}
+ @Deprecated @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi @androidx.compose.runtime.Stable public final class OverscrollConfiguration {
+ ctor @Deprecated public OverscrollConfiguration();
+ ctor @Deprecated public OverscrollConfiguration(optional long glowColor, optional androidx.compose.foundation.layout.PaddingValues drawPadding);
+ method @Deprecated public androidx.compose.foundation.layout.PaddingValues getDrawPadding();
+ method @Deprecated public long getGlowColor();
+ property @Deprecated public final androidx.compose.foundation.layout.PaddingValues drawPadding;
+ property @Deprecated public final long glowColor;
+ }
+
+ public final class OverscrollConfiguration_androidKt {
+ method @Deprecated @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public static androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.foundation.OverscrollConfiguration?> getLocalOverscrollConfiguration();
+ property @Deprecated @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public static final androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.foundation.OverscrollConfiguration?> LocalOverscrollConfiguration;
+ }
+
@androidx.compose.runtime.Stable public interface OverscrollEffect {
method public suspend Object? applyToFling(long velocity, kotlin.jvm.functions.Function2<? super androidx.compose.ui.unit.Velocity,? super kotlin.coroutines.Continuation<? super androidx.compose.ui.unit.Velocity>,? extends java.lang.Object?> performFling, kotlin.coroutines.Continuation<? super kotlin.Unit>);
method public long applyToScroll(long delta, int source, kotlin.jvm.functions.Function1<? super androidx.compose.ui.geometry.Offset,androidx.compose.ui.geometry.Offset> performScroll);
- method public androidx.compose.ui.node.DelegatableNode getNode();
+ method @Deprecated public default androidx.compose.ui.Modifier getEffectModifier();
+ method public default androidx.compose.ui.node.DelegatableNode getNode();
method public boolean isInProgress();
+ property @Deprecated public default androidx.compose.ui.Modifier effectModifier;
property public abstract boolean isInProgress;
- property public abstract androidx.compose.ui.node.DelegatableNode node;
+ property public default androidx.compose.ui.node.DelegatableNode node;
}
public interface OverscrollFactory {
@@ -594,6 +610,7 @@
public final class ScrollableDefaults {
method @androidx.compose.runtime.Composable public androidx.compose.foundation.gestures.FlingBehavior flingBehavior();
+ method @Deprecated @androidx.compose.runtime.Composable public androidx.compose.foundation.OverscrollEffect overscrollEffect();
method public boolean reverseDirection(androidx.compose.ui.unit.LayoutDirection layoutDirection, androidx.compose.foundation.gestures.Orientation orientation, boolean reverseScrolling);
field public static final androidx.compose.foundation.gestures.ScrollableDefaults INSTANCE;
}
diff --git a/compose/foundation/foundation/api/restricted_current.ignore b/compose/foundation/foundation/api/restricted_current.ignore
index 3694fd4..5333980 100644
--- a/compose/foundation/foundation/api/restricted_current.ignore
+++ b/compose/foundation/foundation/api/restricted_current.ignore
@@ -1,6 +1,4 @@
// Baseline format: 1.0
-AddedAbstractMethod: androidx.compose.foundation.OverscrollEffect#getNode():
- Added method androidx.compose.foundation.OverscrollEffect.getNode()
AddedAbstractMethod: androidx.compose.foundation.gestures.DraggableAnchors#anchorAt(int):
Added method androidx.compose.foundation.gestures.DraggableAnchors.anchorAt(int)
AddedAbstractMethod: androidx.compose.foundation.gestures.DraggableAnchors#hasPositionFor(T):
@@ -25,9 +23,5 @@
Attempted to change parameter name from value to anchor in method androidx.compose.foundation.gestures.DraggableAnchors.positionOf
-RemovedClass: androidx.compose.foundation.OverscrollConfiguration_androidKt:
- Removed class androidx.compose.foundation.OverscrollConfiguration_androidKt
-
-
RemovedMethod: androidx.compose.foundation.lazy.grid.GridItemSpan#getCurrentLineSpan():
Removed method androidx.compose.foundation.lazy.grid.GridItemSpan.getCurrentLineSpan()
diff --git a/compose/foundation/foundation/api/restricted_current.txt b/compose/foundation/foundation/api/restricted_current.txt
index 6923448..4e11e6c9 100644
--- a/compose/foundation/foundation/api/restricted_current.txt
+++ b/compose/foundation/foundation/api/restricted_current.txt
@@ -223,13 +223,29 @@
method @kotlin.PublishedApi internal void unlock();
}
+ @Deprecated @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi @androidx.compose.runtime.Stable public final class OverscrollConfiguration {
+ ctor @Deprecated public OverscrollConfiguration();
+ ctor @Deprecated public OverscrollConfiguration(optional long glowColor, optional androidx.compose.foundation.layout.PaddingValues drawPadding);
+ method @Deprecated public androidx.compose.foundation.layout.PaddingValues getDrawPadding();
+ method @Deprecated public long getGlowColor();
+ property @Deprecated public final androidx.compose.foundation.layout.PaddingValues drawPadding;
+ property @Deprecated public final long glowColor;
+ }
+
+ public final class OverscrollConfiguration_androidKt {
+ method @Deprecated @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public static androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.foundation.OverscrollConfiguration?> getLocalOverscrollConfiguration();
+ property @Deprecated @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public static final androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.foundation.OverscrollConfiguration?> LocalOverscrollConfiguration;
+ }
+
@androidx.compose.runtime.Stable public interface OverscrollEffect {
method public suspend Object? applyToFling(long velocity, kotlin.jvm.functions.Function2<? super androidx.compose.ui.unit.Velocity,? super kotlin.coroutines.Continuation<? super androidx.compose.ui.unit.Velocity>,? extends java.lang.Object?> performFling, kotlin.coroutines.Continuation<? super kotlin.Unit>);
method public long applyToScroll(long delta, int source, kotlin.jvm.functions.Function1<? super androidx.compose.ui.geometry.Offset,androidx.compose.ui.geometry.Offset> performScroll);
- method public androidx.compose.ui.node.DelegatableNode getNode();
+ method @Deprecated public default androidx.compose.ui.Modifier getEffectModifier();
+ method public default androidx.compose.ui.node.DelegatableNode getNode();
method public boolean isInProgress();
+ property @Deprecated public default androidx.compose.ui.Modifier effectModifier;
property public abstract boolean isInProgress;
- property public abstract androidx.compose.ui.node.DelegatableNode node;
+ property public default androidx.compose.ui.node.DelegatableNode node;
}
public interface OverscrollFactory {
@@ -596,6 +612,7 @@
public final class ScrollableDefaults {
method @androidx.compose.runtime.Composable public androidx.compose.foundation.gestures.FlingBehavior flingBehavior();
+ method @Deprecated @androidx.compose.runtime.Composable public androidx.compose.foundation.OverscrollEffect overscrollEffect();
method public boolean reverseDirection(androidx.compose.ui.unit.LayoutDirection layoutDirection, androidx.compose.foundation.gestures.Orientation orientation, boolean reverseScrolling);
field public static final androidx.compose.foundation.gestures.ScrollableDefaults INSTANCE;
}
diff --git a/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/PointerIconDemo.kt b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/PointerIconDemo.kt
index aae1809..6bf9176 100644
--- a/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/PointerIconDemo.kt
+++ b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/PointerIconDemo.kt
@@ -33,6 +33,7 @@
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.input.pointer.PointerIcon
import androidx.compose.ui.input.pointer.pointerHoverIcon
+import androidx.compose.ui.input.pointer.stylusHoverIcon
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -62,6 +63,7 @@
Modifier.fillMaxSize()
.border(BorderStroke(2.dp, SolidColor(Color.Red)))
.pointerHoverIcon(PointerIcon.Crosshair)
+ .stylusHoverIcon(PointerIcon.Crosshair)
) {
Text(text = "expected crosshair")
Box(
@@ -69,6 +71,7 @@
.fillMaxWidth(0.6f)
.border(BorderStroke(2.dp, SolidColor(Color.Black)))
.pointerHoverIcon(PointerIcon.Hand, true)
+ .stylusHoverIcon(PointerIcon.Hand, true)
) {
Text(text = "expected hand")
}
@@ -83,12 +86,14 @@
Modifier.fillMaxSize()
.border(BorderStroke(2.dp, SolidColor(Color.Red)))
.pointerHoverIcon(PointerIcon.Crosshair)
+ .stylusHoverIcon(PointerIcon.Crosshair)
) {
Text(text = "expected crosshair")
Box(
Modifier.fillMaxSize()
.border(BorderStroke(2.dp, SolidColor(Color.Black)))
.pointerHoverIcon(PointerIcon.Hand)
+ .stylusHoverIcon(PointerIcon.Hand)
) {
Text(text = "expected hand")
}
@@ -108,6 +113,7 @@
.requiredSize(50.dp)
.border(BorderStroke(2.dp, SolidColor(Color.Black)))
.pointerHoverIcon(PointerIcon.Hand)
+ .stylusHoverIcon(PointerIcon.Hand)
) {
Text("hand")
}
@@ -116,6 +122,7 @@
.requiredSize(50.dp)
.border(BorderStroke(2.dp, SolidColor(Color.Blue)))
.pointerHoverIcon(PointerIcon.Crosshair)
+ .stylusHoverIcon(PointerIcon.Crosshair)
) {
Text("crosshair")
}
@@ -135,6 +142,7 @@
.requiredSize(120.dp, 60.dp)
.border(BorderStroke(2.dp, SolidColor(Color.Black)))
.pointerHoverIcon(PointerIcon.Hand)
+ .stylusHoverIcon(PointerIcon.Hand)
) {
Text(text = "expected hand")
}
@@ -143,6 +151,7 @@
.requiredSize(120.dp, 20.dp)
.border(BorderStroke(2.dp, SolidColor(Color.Blue)))
.pointerHoverIcon(PointerIcon.Crosshair)
+ .stylusHoverIcon(PointerIcon.Crosshair)
) {
Text(text = "expected crosshair")
}
@@ -157,6 +166,7 @@
Modifier.requiredSize(200.dp)
.border(BorderStroke(2.dp, SolidColor(Color.Red)))
.pointerHoverIcon(PointerIcon.Crosshair)
+ .stylusHoverIcon(PointerIcon.Crosshair)
) {
Text(text = "expected crosshair")
Box(
@@ -164,6 +174,7 @@
.requiredSize(150.dp)
.border(BorderStroke(2.dp, SolidColor(Color.Black)))
.pointerHoverIcon(PointerIcon.Text)
+ .stylusHoverIcon(PointerIcon.Text)
) {
Text(text = "expected text")
Box(
@@ -171,6 +182,7 @@
.requiredSize(100.dp)
.border(BorderStroke(2.dp, SolidColor(Color.Blue)))
.pointerHoverIcon(PointerIcon.Hand)
+ .stylusHoverIcon(PointerIcon.Hand)
) {
Text(text = "expected hand")
}
@@ -189,6 +201,7 @@
.requiredSize(width = 200.dp, height = 150.dp)
.border(BorderStroke(2.dp, SolidColor(Color.Red)))
.pointerHoverIcon(PointerIcon.Crosshair, overrideDescendants = false)
+ .stylusHoverIcon(PointerIcon.Crosshair, overrideDescendants = false)
) {
Text(text = "expected crosshair")
Box(
@@ -196,6 +209,7 @@
.requiredSize(width = 150.dp, height = 125.dp)
.border(BorderStroke(2.dp, SolidColor(Color.Black)))
.pointerHoverIcon(PointerIcon.Text, overrideDescendants = false)
+ .stylusHoverIcon(PointerIcon.Text, overrideDescendants = false)
) {
Text(text = "expected text")
Box(
@@ -204,6 +218,7 @@
.offset(x = 100.dp)
.border(BorderStroke(2.dp, SolidColor(Color.Blue)))
.pointerHoverIcon(PointerIcon.Hand, overrideDescendants = false)
+ .stylusHoverIcon(PointerIcon.Hand, overrideDescendants = false)
) {
Text(text = "expected hand")
}
diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/ScrollTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/ScrollTest.kt
index bcc76c8..728d34b 100644
--- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/ScrollTest.kt
+++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/ScrollTest.kt
@@ -663,33 +663,21 @@
fun testInspectorValue_withoutOverscrollParameter() {
val state = ScrollState(initial = 0)
rule.setContent {
- val modifiers =
+ val modifier =
when (config.orientation) {
Vertical -> Modifier.verticalScroll(state)
Horizontal -> Modifier.horizontalScroll(state)
- }.toList()
-
- val scrollableContainer = modifiers[0] as InspectableValue
- val scroll = modifiers[1] as InspectableValue
- assertThat(scrollableContainer.nameFallback).isEqualTo("scrollingContainer")
- assertThat(scrollableContainer.valueOverride).isNull()
- assertThat(scrollableContainer.inspectableElements.map { it.name }.asIterable())
- .containsExactly(
- "state",
- "orientation",
- "enabled",
- "reverseScrolling",
- "flingBehavior",
- "interactionSource",
- "bringIntoViewSpec",
- "useLocalOverscrollFactory",
- "overscrollEffect"
- )
-
- assertThat(scroll.nameFallback).isEqualTo("scroll")
- assertThat(scroll.valueOverride).isNull()
- assertThat(scroll.inspectableElements.map { it.name }.asIterable())
- .containsExactly("state", "reverseScrolling", "isVertical")
+ }
+ as InspectableValue
+ val expectedName =
+ when (config.orientation) {
+ Vertical -> "verticalScroll"
+ Horizontal -> "horizontalScroll"
+ }
+ assertThat(modifier.nameFallback).isEqualTo(expectedName)
+ assertThat(modifier.valueOverride).isNull()
+ assertThat(modifier.inspectableElements.map { it.name }.asIterable())
+ .containsExactly("state", "enabled", "flingBehavior", "reverseScrolling")
}
}
@@ -717,7 +705,6 @@
"flingBehavior",
"interactionSource",
"bringIntoViewSpec",
- "useLocalOverscrollFactory",
"overscrollEffect"
)
diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/ScrollingContainerTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/ScrollingContainerTest.kt
index b5abe10..1b359e5 100644
--- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/ScrollingContainerTest.kt
+++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/ScrollingContainerTest.kt
@@ -81,7 +81,6 @@
reverseScrolling = false,
flingBehavior = null,
interactionSource = null,
- useLocalOverscrollFactory = false,
overscrollEffect = null,
bringIntoViewSpec = null
) as InspectableValue
@@ -95,7 +94,6 @@
"reverseScrolling",
"flingBehavior",
"interactionSource",
- "useLocalOverscrollFactory",
"overscrollEffect",
"bringIntoViewSpec"
)
@@ -119,7 +117,6 @@
reverseScrolling = false,
flingBehavior = null,
interactionSource = null,
- useLocalOverscrollFactory = false,
overscrollEffect = null
)
) {
@@ -177,7 +174,6 @@
reverseScrolling = false,
flingBehavior = null,
interactionSource = null,
- useLocalOverscrollFactory = false,
overscrollEffect = null
)
)
@@ -211,7 +207,6 @@
reverseScrolling = false,
flingBehavior = null,
interactionSource = null,
- useLocalOverscrollFactory = false,
overscrollEffect = overscrollEffect,
bringIntoViewSpec = null
)
@@ -236,7 +231,6 @@
reverseScrolling = false,
flingBehavior = null,
interactionSource = null,
- useLocalOverscrollFactory = false,
overscrollEffect = effect,
bringIntoViewSpec = null
)
@@ -253,6 +247,7 @@
rule.runOnIdle {
assertThat(overscrollEffect1.node.node.isAttached).isFalse()
assertThat(overscrollEffect2.node.node.isAttached).isTrue()
+ effect = overscrollEffect2
}
}
@@ -289,7 +284,6 @@
reverseScrolling = false,
flingBehavior = null,
interactionSource = null,
- useLocalOverscrollFactory = false,
overscrollEffect = overscrollEffect,
bringIntoViewSpec = null
)
@@ -307,207 +301,6 @@
rule.waitForIdle()
}
- @Test
- fun attachesLocalOverscrollFactoryOverscrollEffectNode() {
- val overscrollEffect = TestOverscrollEffect()
- val factory =
- object : OverscrollFactory {
- override fun createOverscrollEffect(): OverscrollEffect = overscrollEffect
-
- override fun equals(other: Any?): Boolean = other === this
-
- override fun hashCode(): Int = -1
- }
-
- rule.setContent {
- CompositionLocalProvider(LocalOverscrollFactory provides factory) {
- Box(
- Modifier.scrollingContainer(
- rememberScrollState(),
- orientation = Horizontal,
- enabled = true,
- reverseScrolling = false,
- flingBehavior = null,
- interactionSource = null,
- useLocalOverscrollFactory = true,
- overscrollEffect = null,
- bringIntoViewSpec = null
- )
- )
- }
- }
-
- rule.runOnIdle { assertThat(overscrollEffect.node.node.isAttached).isTrue() }
- }
-
- @Test
- fun updatesToNewLocalOverscrollFactory() {
- val overscrollEffect1 = TestOverscrollEffect()
- val overscrollEffect2 = TestOverscrollEffect()
-
- val factory1 =
- object : OverscrollFactory {
- override fun createOverscrollEffect(): OverscrollEffect = overscrollEffect1
-
- override fun equals(other: Any?): Boolean = other === this
-
- override fun hashCode(): Int = -1
- }
-
- val factory2 =
- object : OverscrollFactory {
- override fun createOverscrollEffect(): OverscrollEffect = overscrollEffect2
-
- override fun equals(other: Any?): Boolean = other === this
-
- override fun hashCode(): Int = -2
- }
-
- var factory by mutableStateOf<OverscrollFactory>(factory1)
-
- rule.setContent {
- CompositionLocalProvider(LocalOverscrollFactory provides factory) {
- Box(
- Modifier.scrollingContainer(
- rememberScrollState(),
- orientation = Horizontal,
- enabled = true,
- reverseScrolling = false,
- flingBehavior = null,
- interactionSource = null,
- useLocalOverscrollFactory = true,
- overscrollEffect = null,
- bringIntoViewSpec = null
- )
- )
- }
- }
-
- rule.runOnIdle {
- assertThat(overscrollEffect1.node.node.isAttached).isTrue()
- assertThat(overscrollEffect2.node.node.isAttached).isFalse()
- factory = factory2
- }
-
- // The old node should be detached, and the new one should be attached
- rule.runOnIdle {
- assertThat(overscrollEffect1.node.node.isAttached).isFalse()
- assertThat(overscrollEffect2.node.node.isAttached).isTrue()
- }
- }
-
- @Test
- fun updatesBetweenProvidedOverscrollEffectAndLocalOverscrollFactory() {
- val overscrollEffect1 = TestOverscrollEffect()
- val overscrollEffect2 = TestOverscrollEffect()
-
- val factory =
- object : OverscrollFactory {
- override fun createOverscrollEffect(): OverscrollEffect = overscrollEffect1
-
- override fun equals(other: Any?): Boolean = other === this
-
- override fun hashCode(): Int = -1
- }
-
- var useLocalOverscrollFactory by mutableStateOf(true)
-
- rule.setContent {
- CompositionLocalProvider(LocalOverscrollFactory provides factory) {
- Box(
- Modifier.scrollingContainer(
- rememberScrollState(),
- orientation = Horizontal,
- enabled = true,
- reverseScrolling = false,
- flingBehavior = null,
- interactionSource = null,
- useLocalOverscrollFactory = useLocalOverscrollFactory,
- overscrollEffect = overscrollEffect2,
- bringIntoViewSpec = null
- )
- )
- }
- }
-
- // useLocalOverscrollFactory = true, so it will override the overscrollEffect2 we set
- // on the modifier
- rule.runOnIdle {
- assertThat(overscrollEffect1.node.node.isAttached).isTrue()
- assertThat(overscrollEffect2.node.node.isAttached).isFalse()
- useLocalOverscrollFactory = false
- }
-
- // The factory-provided node should be detached, and the explicit node should be attached
- rule.runOnIdle {
- assertThat(overscrollEffect1.node.node.isAttached).isFalse()
- assertThat(overscrollEffect2.node.node.isAttached).isTrue()
- // Use the factory again
- useLocalOverscrollFactory = true
- }
-
- // useLocalOverscrollFactory = true, so it should be used again
- rule.runOnIdle {
- assertThat(overscrollEffect1.node.node.isAttached).isTrue()
- assertThat(overscrollEffect2.node.node.isAttached).isFalse()
- }
- }
-
- @Test
- fun changesToProvidedOverscrollEffectIgnoredIfUseLocalOverscrollFactoryTrue() {
- val overscrollEffect1 = TestOverscrollEffect()
- val overscrollEffect2 = TestOverscrollEffect()
- var creationCalls = 0
-
- val factory =
- object : OverscrollFactory {
- override fun createOverscrollEffect(): OverscrollEffect {
- creationCalls++
- return overscrollEffect1
- }
-
- override fun equals(other: Any?): Boolean = other === this
-
- override fun hashCode(): Int = -1
- }
-
- var overscrollEffect by mutableStateOf<OverscrollEffect?>(null)
-
- rule.setContent {
- CompositionLocalProvider(LocalOverscrollFactory provides factory) {
- Box(
- Modifier.scrollingContainer(
- rememberScrollState(),
- orientation = Horizontal,
- enabled = true,
- reverseScrolling = false,
- flingBehavior = null,
- interactionSource = null,
- useLocalOverscrollFactory = true,
- overscrollEffect = overscrollEffect,
- bringIntoViewSpec = null
- )
- )
- }
- }
-
- rule.runOnIdle {
- assertThat(creationCalls).isEqualTo(1)
- assertThat(overscrollEffect1.node.node.isAttached).isTrue()
- assertThat(overscrollEffect2.node.node.isAttached).isFalse()
- // Change the provided overscrollEffect - this should no-op as useLocalOverscrollFactory
- // is true
- overscrollEffect = overscrollEffect2
- }
-
- rule.runOnIdle {
- // create should not be called again on the factory
- assertThat(creationCalls).isEqualTo(1)
- assertThat(overscrollEffect1.node.node.isAttached).isTrue()
- assertThat(overscrollEffect2.node.node.isAttached).isFalse()
- }
- }
-
private fun Modifier.drawOutsideOfBounds() = drawBehind {
val inflate = 20.dp.roundToPx().toFloat()
drawRect(
diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/contextmenu/ContextMenuCommon.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/contextmenu/ContextMenuCommon.kt
index 52d4514..5719d943 100644
--- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/contextmenu/ContextMenuCommon.kt
+++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/contextmenu/ContextMenuCommon.kt
@@ -161,6 +161,7 @@
internal const val COPY = "Copy"
internal const val PASTE = "Paste"
internal const val SELECT_ALL = "Select all"
+ internal const val AUTOFILL = "Autofill"
}
internal fun ComposeTestRule.contextMenuItemInteraction(
@@ -183,6 +184,7 @@
copyState: ContextMenuItemState,
pasteState: ContextMenuItemState,
selectAllState: ContextMenuItemState,
+ autofillState: ContextMenuItemState,
) {
val contextMenuInteraction = onNode(isPopup())
contextMenuInteraction.assertExists("Context Menu should exist.")
@@ -191,6 +193,7 @@
assertContextMenuItem(label = ContextMenuItemLabels.COPY, state = copyState)
assertContextMenuItem(label = ContextMenuItemLabels.PASTE, state = pasteState)
assertContextMenuItem(label = ContextMenuItemLabels.SELECT_ALL, state = selectAllState)
+ assertContextMenuItem(label = ContextMenuItemLabels.AUTOFILL, state = autofillState)
}
private fun ComposeTestRule.assertContextMenuItem(label: String, state: ContextMenuItemState) {
diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/selection/SelectionContainerContextMenuTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/selection/SelectionContainerContextMenuTest.kt
index 3280db6..6ebdd3f 100644
--- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/selection/SelectionContainerContextMenuTest.kt
+++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/selection/SelectionContainerContextMenuTest.kt
@@ -182,6 +182,7 @@
copyState = ContextMenuItemState.DOES_NOT_EXIST,
pasteState = ContextMenuItemState.DOES_NOT_EXIST,
selectAllState = ContextMenuItemState.ENABLED,
+ autofillState = ContextMenuItemState.DOES_NOT_EXIST,
)
}
@@ -197,6 +198,7 @@
copyState = ContextMenuItemState.ENABLED,
pasteState = ContextMenuItemState.DOES_NOT_EXIST,
selectAllState = ContextMenuItemState.ENABLED,
+ autofillState = ContextMenuItemState.DOES_NOT_EXIST,
)
}
@@ -212,6 +214,7 @@
copyState = ContextMenuItemState.ENABLED,
pasteState = ContextMenuItemState.DOES_NOT_EXIST,
selectAllState = ContextMenuItemState.DOES_NOT_EXIST,
+ autofillState = ContextMenuItemState.DOES_NOT_EXIST,
)
}
diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/textfield/TextFieldContextMenuTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/textfield/TextFieldContextMenuTest.kt
index a4c56a3..f79f7644 100644
--- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/textfield/TextFieldContextMenuTest.kt
+++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/textfield/TextFieldContextMenuTest.kt
@@ -50,6 +50,7 @@
import androidx.compose.ui.unit.lerp
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
+import androidx.test.filters.SdkSuppress
import com.google.common.truth.Truth.assertThat
import org.junit.Rule
import org.junit.Test
@@ -230,7 +231,8 @@
// region BTF1 Context Menu Correct Item Tests
@Test
- fun btf1_contextMenu_emptyClipboard_noSelection_itemsMatch() =
+ @SdkSuppress(maxSdkVersion = 25)
+ fun btf1_contextMenu_emptyClipboard_noSelection_itemsMatch_beforeApi26() =
runBtf1CorrectItemsTest(
isEmptyClipboard = true,
selectionAmount = SelectionAmount.NONE,
@@ -240,6 +242,23 @@
copyState = ContextMenuItemState.DOES_NOT_EXIST,
pasteState = ContextMenuItemState.DOES_NOT_EXIST,
selectAllState = ContextMenuItemState.ENABLED,
+ autofillState = ContextMenuItemState.DOES_NOT_EXIST,
+ )
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 26)
+ fun btf1_contextMenu_emptyClipboard_noSelection_itemsMatch_afterApi26() =
+ runBtf1CorrectItemsTest(
+ isEmptyClipboard = true,
+ selectionAmount = SelectionAmount.NONE,
+ ) {
+ rule.assertContextMenuItems(
+ cutState = ContextMenuItemState.DOES_NOT_EXIST,
+ copyState = ContextMenuItemState.DOES_NOT_EXIST,
+ pasteState = ContextMenuItemState.DOES_NOT_EXIST,
+ selectAllState = ContextMenuItemState.ENABLED,
+ autofillState = ContextMenuItemState.ENABLED,
)
}
@@ -254,6 +273,7 @@
copyState = ContextMenuItemState.ENABLED,
pasteState = ContextMenuItemState.DOES_NOT_EXIST,
selectAllState = ContextMenuItemState.ENABLED,
+ autofillState = ContextMenuItemState.DOES_NOT_EXIST,
)
}
@@ -268,11 +288,13 @@
copyState = ContextMenuItemState.ENABLED,
pasteState = ContextMenuItemState.DOES_NOT_EXIST,
selectAllState = ContextMenuItemState.DOES_NOT_EXIST,
+ autofillState = ContextMenuItemState.DOES_NOT_EXIST,
)
}
@Test
- fun btf1_contextMenu_nonEmptyClipboard_noSelection_itemsMatch() =
+ @SdkSuppress(maxSdkVersion = 25)
+ fun btf1_contextMenu_nonEmptyClipboard_noSelection_itemsMatch_beforeApi26() =
runBtf1CorrectItemsTest(
isEmptyClipboard = false,
selectionAmount = SelectionAmount.NONE,
@@ -282,6 +304,23 @@
copyState = ContextMenuItemState.DOES_NOT_EXIST,
pasteState = ContextMenuItemState.ENABLED,
selectAllState = ContextMenuItemState.ENABLED,
+ autofillState = ContextMenuItemState.DOES_NOT_EXIST,
+ )
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 26)
+ fun btf1_contextMenu_nonEmptyClipboard_noSelection_itemsMatch_afterApi26() =
+ runBtf1CorrectItemsTest(
+ isEmptyClipboard = false,
+ selectionAmount = SelectionAmount.NONE,
+ ) {
+ rule.assertContextMenuItems(
+ cutState = ContextMenuItemState.DOES_NOT_EXIST,
+ copyState = ContextMenuItemState.DOES_NOT_EXIST,
+ pasteState = ContextMenuItemState.ENABLED,
+ selectAllState = ContextMenuItemState.ENABLED,
+ autofillState = ContextMenuItemState.ENABLED,
)
}
@@ -298,6 +337,7 @@
copyState = ContextMenuItemState.ENABLED,
pasteState = ContextMenuItemState.ENABLED,
selectAllState = ContextMenuItemState.ENABLED,
+ autofillState = ContextMenuItemState.DOES_NOT_EXIST,
)
}
@@ -312,11 +352,13 @@
copyState = ContextMenuItemState.ENABLED,
pasteState = ContextMenuItemState.ENABLED,
selectAllState = ContextMenuItemState.DOES_NOT_EXIST,
+ autofillState = ContextMenuItemState.DOES_NOT_EXIST,
)
}
@Test
- fun btf1_contextMenu_password_noSelection_itemsMatch() =
+ @SdkSuppress(maxSdkVersion = 25)
+ fun btf1_contextMenu_password_noSelection_itemsMatch_beforeApi26() =
runBtf1CorrectItemsTest(
isPassword = true,
selectionAmount = SelectionAmount.NONE,
@@ -326,6 +368,23 @@
copyState = ContextMenuItemState.DOES_NOT_EXIST,
pasteState = ContextMenuItemState.ENABLED,
selectAllState = ContextMenuItemState.ENABLED,
+ autofillState = ContextMenuItemState.DOES_NOT_EXIST,
+ )
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 26)
+ fun btf1_contextMenu_password_noSelection_itemsMatch_afterApi26() =
+ runBtf1CorrectItemsTest(
+ isPassword = true,
+ selectionAmount = SelectionAmount.NONE,
+ ) {
+ rule.assertContextMenuItems(
+ cutState = ContextMenuItemState.DOES_NOT_EXIST,
+ copyState = ContextMenuItemState.DOES_NOT_EXIST,
+ pasteState = ContextMenuItemState.ENABLED,
+ selectAllState = ContextMenuItemState.ENABLED,
+ autofillState = ContextMenuItemState.ENABLED,
)
}
@@ -340,6 +399,7 @@
copyState = ContextMenuItemState.DOES_NOT_EXIST,
pasteState = ContextMenuItemState.ENABLED,
selectAllState = ContextMenuItemState.ENABLED,
+ autofillState = ContextMenuItemState.DOES_NOT_EXIST,
)
}
@@ -354,6 +414,7 @@
copyState = ContextMenuItemState.DOES_NOT_EXIST,
pasteState = ContextMenuItemState.ENABLED,
selectAllState = ContextMenuItemState.DOES_NOT_EXIST,
+ autofillState = ContextMenuItemState.DOES_NOT_EXIST,
)
}
@@ -369,6 +430,7 @@
copyState = ContextMenuItemState.DOES_NOT_EXIST,
pasteState = ContextMenuItemState.DOES_NOT_EXIST,
selectAllState = ContextMenuItemState.ENABLED,
+ autofillState = ContextMenuItemState.DOES_NOT_EXIST,
)
}
@@ -384,6 +446,7 @@
copyState = ContextMenuItemState.ENABLED,
pasteState = ContextMenuItemState.DOES_NOT_EXIST,
selectAllState = ContextMenuItemState.ENABLED,
+ autofillState = ContextMenuItemState.DOES_NOT_EXIST,
)
}
@@ -399,6 +462,7 @@
copyState = ContextMenuItemState.ENABLED,
pasteState = ContextMenuItemState.DOES_NOT_EXIST,
selectAllState = ContextMenuItemState.DOES_NOT_EXIST,
+ autofillState = ContextMenuItemState.DOES_NOT_EXIST,
)
}
@@ -597,7 +661,8 @@
// region BTF2 Context Menu Correct Item Tests
@Test
- fun btf2_contextMenu_emptyClipboard_noSelection_itemsMatch() =
+ @SdkSuppress(maxSdkVersion = 25)
+ fun btf2_contextMenu_emptyClipboard_noSelection_itemsMatch_beforeApi26() =
runBtf2CorrectItemsTest(
isEmptyClipboard = true,
selectionAmount = SelectionAmount.NONE,
@@ -607,6 +672,23 @@
copyState = ContextMenuItemState.DOES_NOT_EXIST,
pasteState = ContextMenuItemState.DOES_NOT_EXIST,
selectAllState = ContextMenuItemState.ENABLED,
+ autofillState = ContextMenuItemState.DOES_NOT_EXIST,
+ )
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 26)
+ fun btf2_contextMenu_emptyClipboard_noSelection_itemsMatch_afterApi26() =
+ runBtf2CorrectItemsTest(
+ isEmptyClipboard = true,
+ selectionAmount = SelectionAmount.NONE,
+ ) {
+ rule.assertContextMenuItems(
+ cutState = ContextMenuItemState.DOES_NOT_EXIST,
+ copyState = ContextMenuItemState.DOES_NOT_EXIST,
+ pasteState = ContextMenuItemState.DOES_NOT_EXIST,
+ selectAllState = ContextMenuItemState.ENABLED,
+ autofillState = ContextMenuItemState.ENABLED,
)
}
@@ -621,6 +703,7 @@
copyState = ContextMenuItemState.ENABLED,
pasteState = ContextMenuItemState.DOES_NOT_EXIST,
selectAllState = ContextMenuItemState.ENABLED,
+ autofillState = ContextMenuItemState.DOES_NOT_EXIST,
)
}
@@ -635,11 +718,13 @@
copyState = ContextMenuItemState.ENABLED,
pasteState = ContextMenuItemState.DOES_NOT_EXIST,
selectAllState = ContextMenuItemState.DOES_NOT_EXIST,
+ autofillState = ContextMenuItemState.DOES_NOT_EXIST,
)
}
@Test
- fun btf2_contextMenu_nonEmptyClipboard_noSelection_itemsMatch() =
+ @SdkSuppress(maxSdkVersion = 25)
+ fun btf2_contextMenu_nonEmptyClipboard_noSelection_itemsMatch_beforeApi26() =
runBtf2CorrectItemsTest(
isEmptyClipboard = false,
selectionAmount = SelectionAmount.NONE,
@@ -649,6 +734,23 @@
copyState = ContextMenuItemState.DOES_NOT_EXIST,
pasteState = ContextMenuItemState.ENABLED,
selectAllState = ContextMenuItemState.ENABLED,
+ autofillState = ContextMenuItemState.DOES_NOT_EXIST,
+ )
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 26)
+ fun btf2_contextMenu_nonEmptyClipboard_noSelection_itemsMatch_afterApi26() =
+ runBtf2CorrectItemsTest(
+ isEmptyClipboard = false,
+ selectionAmount = SelectionAmount.NONE,
+ ) {
+ rule.assertContextMenuItems(
+ cutState = ContextMenuItemState.DOES_NOT_EXIST,
+ copyState = ContextMenuItemState.DOES_NOT_EXIST,
+ pasteState = ContextMenuItemState.ENABLED,
+ selectAllState = ContextMenuItemState.ENABLED,
+ autofillState = ContextMenuItemState.ENABLED,
)
}
@@ -663,6 +765,7 @@
copyState = ContextMenuItemState.ENABLED,
pasteState = ContextMenuItemState.ENABLED,
selectAllState = ContextMenuItemState.ENABLED,
+ autofillState = ContextMenuItemState.DOES_NOT_EXIST,
)
}
@@ -677,11 +780,13 @@
copyState = ContextMenuItemState.ENABLED,
pasteState = ContextMenuItemState.ENABLED,
selectAllState = ContextMenuItemState.DOES_NOT_EXIST,
+ autofillState = ContextMenuItemState.DOES_NOT_EXIST,
)
}
@Test
- fun btf2_contextMenu_password_noSelection_itemsMatch() =
+ @SdkSuppress(maxSdkVersion = 25)
+ fun btf2_contextMenu_password_noSelection_itemsMatch_beforeApi26() =
runBtf2CorrectItemsTest(
isPassword = true,
selectionAmount = SelectionAmount.NONE,
@@ -691,6 +796,23 @@
copyState = ContextMenuItemState.DOES_NOT_EXIST,
pasteState = ContextMenuItemState.ENABLED,
selectAllState = ContextMenuItemState.ENABLED,
+ autofillState = ContextMenuItemState.DOES_NOT_EXIST,
+ )
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 26)
+ fun btf2_contextMenu_password_noSelection_itemsMatch_afterApi26() =
+ runBtf2CorrectItemsTest(
+ isPassword = true,
+ selectionAmount = SelectionAmount.NONE,
+ ) {
+ rule.assertContextMenuItems(
+ cutState = ContextMenuItemState.DOES_NOT_EXIST,
+ copyState = ContextMenuItemState.DOES_NOT_EXIST,
+ pasteState = ContextMenuItemState.ENABLED,
+ selectAllState = ContextMenuItemState.ENABLED,
+ autofillState = ContextMenuItemState.ENABLED,
)
}
@@ -705,6 +827,7 @@
copyState = ContextMenuItemState.DOES_NOT_EXIST,
pasteState = ContextMenuItemState.ENABLED,
selectAllState = ContextMenuItemState.ENABLED,
+ autofillState = ContextMenuItemState.DOES_NOT_EXIST,
)
}
@@ -719,6 +842,7 @@
copyState = ContextMenuItemState.DOES_NOT_EXIST,
pasteState = ContextMenuItemState.ENABLED,
selectAllState = ContextMenuItemState.DOES_NOT_EXIST,
+ autofillState = ContextMenuItemState.DOES_NOT_EXIST,
)
}
@@ -734,6 +858,7 @@
copyState = ContextMenuItemState.DOES_NOT_EXIST,
pasteState = ContextMenuItemState.DOES_NOT_EXIST,
selectAllState = ContextMenuItemState.ENABLED,
+ autofillState = ContextMenuItemState.DOES_NOT_EXIST,
)
}
@@ -749,6 +874,7 @@
copyState = ContextMenuItemState.ENABLED,
pasteState = ContextMenuItemState.DOES_NOT_EXIST,
selectAllState = ContextMenuItemState.ENABLED,
+ autofillState = ContextMenuItemState.DOES_NOT_EXIST,
)
}
@@ -764,6 +890,7 @@
copyState = ContextMenuItemState.ENABLED,
pasteState = ContextMenuItemState.DOES_NOT_EXIST,
selectAllState = ContextMenuItemState.DOES_NOT_EXIST,
+ autofillState = ContextMenuItemState.DOES_NOT_EXIST,
)
}
diff --git a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/AndroidOverscroll.android.kt b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/AndroidOverscroll.android.kt
index a47732e..410f064 100644
--- a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/AndroidOverscroll.android.kt
+++ b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/AndroidOverscroll.android.kt
@@ -36,6 +36,7 @@
import androidx.compose.runtime.CompositionLocalAccessorScope
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.neverEqualPolicy
+import androidx.compose.runtime.remember
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.geometry.center
@@ -88,10 +89,38 @@
return AndroidEdgeEffectOverscrollFactory(context, density, glowColor, glowDrawPadding)
}
+@OptIn(ExperimentalFoundationApi::class)
+@Suppress("DEPRECATION")
internal actual fun CompositionLocalAccessorScope.defaultOverscrollFactory(): OverscrollFactory? {
val context = LocalContext.currentValue
val density = LocalDensity.currentValue
- return AndroidEdgeEffectOverscrollFactory(context, density)
+ val config = LocalOverscrollConfiguration.currentValue
+ return if (config == null) {
+ null
+ } else {
+ AndroidEdgeEffectOverscrollFactory(context, density, config.glowColor, config.drawPadding)
+ }
+}
+
+@OptIn(ExperimentalFoundationApi::class)
+@Suppress("DEPRECATION")
+@Composable
+internal actual fun rememberPlatformOverscrollEffect(): OverscrollEffect? {
+ val context = LocalContext.current
+ val density = LocalDensity.current
+ val config = LocalOverscrollConfiguration.current
+ return if (config == null) {
+ null
+ } else {
+ remember(context, density, config) {
+ AndroidEdgeEffectOverscrollEffect(
+ context,
+ density,
+ config.glowColor,
+ config.drawPadding
+ )
+ }
+ }
}
private class AndroidEdgeEffectOverscrollFactory(
diff --git a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/OverscrollConfiguration.android.kt b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/OverscrollConfiguration.android.kt
new file mode 100644
index 0000000..a99fecf
--- /dev/null
+++ b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/OverscrollConfiguration.android.kt
@@ -0,0 +1,79 @@
+/*
+ * 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
+ *
+ * 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:Suppress("DEPRECATION")
+
+package androidx.compose.foundation
+
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.compositionLocalOf
+import androidx.compose.ui.graphics.Color
+
+/**
+ * Metadata for overscroll effects for android platform.
+ *
+ * @param glowColor color for the glow effect, if the platform effect is a glow effect, otherwise
+ * ignored.
+ * @param drawPadding the amount of padding to apply from scrollable container bounds to the effect
+ * before drawing it, if the platform effect is a glow effect, otherwise ignored.
+ */
+@Deprecated(
+ "Providing `OverscrollConfiguration` through `LocalOverscrollConfiguration` to disable / configure overscroll has been replaced with `LocalOverscrollFactory` and `rememberPlatformOverscrollFactory`. To disable overscroll, instead of `LocalOverscrollConfiguration provides null`, use `LocalOverscrollFactory provides null`. To change the glow color / padding, instead of `LocalOverscrollConfiguration provides OverscrollConfiguration(myColor, myPadding)`, use `LocalOverscrollFactory provides rememberPlatformOverscrollFactory(myColor, myPadding)`"
+)
+@ExperimentalFoundationApi
+@Stable
+class OverscrollConfiguration(
+ val glowColor: Color = Color(0xff666666), // taken from EdgeEffect.java defaults
+ val drawPadding: PaddingValues = PaddingValues()
+) {
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (javaClass != other?.javaClass) return false
+
+ other as OverscrollConfiguration
+
+ if (glowColor != other.glowColor) return false
+ if (drawPadding != other.drawPadding) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = glowColor.hashCode()
+ result = 31 * result + drawPadding.hashCode()
+ return result
+ }
+
+ override fun toString(): String {
+ return "OverscrollConfiguration(glowColor=$glowColor, drawPadding=$drawPadding)"
+ }
+}
+
+/**
+ * Composition local to provide configuration for scrolling containers down the hierarchy. `null`
+ * means there will be no overscroll at all.
+ */
+@Deprecated(
+ "Providing `OverscrollConfiguration` through `LocalOverscrollConfiguration` to disable / configure overscroll has been replaced with `LocalOverscrollFactory` and `rememberPlatformOverscrollFactory`. To disable overscroll, instead of `LocalOverscrollConfiguration provides null`, use `LocalOverscrollFactory provides null`. To change the glow color / padding, instead of `LocalOverscrollConfiguration provides OverscrollConfiguration(myColor, myPadding)`, use `LocalOverscrollFactory provides rememberPlatformOverscrollFactory(myColor, myPadding)`",
+ replaceWith =
+ ReplaceWith("LocalOverscrollFactory", "androidx.compose.foundation.LocalOverscrollFactory")
+)
+@Suppress("OPT_IN_MARKER_ON_WRONG_TARGET")
+@ExperimentalFoundationApi
+@get:ExperimentalFoundationApi
+val LocalOverscrollConfiguration =
+ compositionLocalOf<OverscrollConfiguration?> { OverscrollConfiguration() }
diff --git a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/ContextMenu.android.kt b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/ContextMenu.android.kt
index afda250..5eb18f5 100644
--- a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/ContextMenu.android.kt
+++ b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/ContextMenu.android.kt
@@ -16,6 +16,8 @@
package androidx.compose.foundation.text
+import android.os.Build
+import androidx.compose.foundation.R
import androidx.compose.foundation.contextmenu.ContextMenuScope
import androidx.compose.foundation.contextmenu.ContextMenuState
import androidx.compose.foundation.contextmenu.close
@@ -80,7 +82,14 @@
Cut(android.R.string.cut),
Copy(android.R.string.copy),
Paste(android.R.string.paste),
- SelectAll(android.R.string.selectAll);
+ SelectAll(android.R.string.selectAll),
+ Autofill(
+ if (Build.VERSION.SDK_INT <= 26) {
+ R.string.autofill
+ } else {
+ android.R.string.autofill
+ }
+ );
@ReadOnlyComposable @Composable fun resolvedString(): String = stringResource(stringId)
}
diff --git a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldSelectionState.android.kt b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldSelectionState.android.kt
index e770393..d1445cd 100644
--- a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldSelectionState.android.kt
+++ b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldSelectionState.android.kt
@@ -16,6 +16,7 @@
package androidx.compose.foundation.text.input.internal.selection
+import android.os.Build
import androidx.compose.foundation.contextmenu.ContextMenuScope
import androidx.compose.foundation.contextmenu.ContextMenuState
import androidx.compose.foundation.text.TextContextMenuItems
@@ -30,4 +31,7 @@
}
TextItem(state, TextContextMenuItems.Paste, enabled = canPaste()) { paste() }
TextItem(state, TextContextMenuItems.SelectAll, enabled = canSelectAll()) { selectAll() }
+ if (Build.VERSION.SDK_INT >= 26) {
+ TextItem(state, TextContextMenuItems.Autofill, enabled = canAutofill()) { autofill() }
+ }
}
diff --git a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/selection/TextFieldSelectionManager.android.kt b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/selection/TextFieldSelectionManager.android.kt
index d04bdd6..6e08546 100644
--- a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/selection/TextFieldSelectionManager.android.kt
+++ b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/selection/TextFieldSelectionManager.android.kt
@@ -16,6 +16,7 @@
package androidx.compose.foundation.text.selection
+import android.os.Build
import androidx.compose.foundation.PlatformMagnifierFactory
import androidx.compose.foundation.contextmenu.ContextMenuScope
import androidx.compose.foundation.contextmenu.ContextMenuState
@@ -100,4 +101,13 @@
) {
selectAll()
}
+ if (Build.VERSION.SDK_INT >= 26) {
+ TextItem(
+ state = contextMenuState,
+ label = TextContextMenuItems.Autofill,
+ enabled = editable && value.selection.collapsed
+ ) {
+ autofill()
+ }
+ }
}
diff --git a/compose/foundation/foundation/src/androidMain/res/values/strings.xml b/compose/foundation/foundation/src/androidMain/res/values/strings.xml
index cb6255c..f1a2c47 100644
--- a/compose/foundation/foundation/src/androidMain/res/values/strings.xml
+++ b/compose/foundation/foundation/src/androidMain/res/values/strings.xml
@@ -17,4 +17,6 @@
<resources>
<string name="tooltip_description">tooltip</string>
<string name="tooltip_label">show tooltip</string>
+ <!-- Item on context menu. This action is used to request autofill. -->
+ <string name="autofill">Autofill</string>
</resources>
\ No newline at end of file
diff --git a/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/selection/TextFieldSelectionManagerTest.kt b/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/selection/TextFieldSelectionManagerTest.kt
index af1ba42..4de2745 100644
--- a/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/selection/TextFieldSelectionManagerTest.kt
+++ b/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/selection/TextFieldSelectionManagerTest.kt
@@ -16,10 +16,13 @@
package androidx.compose.foundation.text.selection
+import android.os.Build
+import androidx.annotation.RequiresApi
import androidx.compose.foundation.text.HandleState
import androidx.compose.foundation.text.LegacyTextFieldState
import androidx.compose.foundation.text.TextDelegate
import androidx.compose.foundation.text.TextLayoutResultProxy
+import androidx.compose.ui.autofill.AutofillManager
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
@@ -90,6 +93,7 @@
private val hapticFeedback = mock<HapticFeedback>()
private val focusRequester = mock<FocusRequester>()
private val multiParagraph = mock<MultiParagraph>()
+ private val autofillManager = mock<AutofillManager>()
@Before
fun setup() {
@@ -101,6 +105,7 @@
manager.textToolbar = textToolbar
manager.hapticFeedBack = hapticFeedback
manager.focusRequester = focusRequester
+ manager.autofillManager = autofillManager
whenever(layoutResult.layoutInput)
.thenReturn(
@@ -355,6 +360,17 @@
assertThat(state.handleState).isEqualTo(HandleState.None)
}
+ @RequiresApi(Build.VERSION_CODES.O)
+ @Test
+ fun autofill_selection_collapse() {
+ manager.value = TextFieldValue(text = text, selection = TextRange(4, 4))
+
+ manager.autofill()
+
+ verify(autofillManager, times(1)).requestAutofillForActiveElement()
+ assertThat(state.handleState).isEqualTo(HandleState.None)
+ }
+
@Test
fun copy_selection_collapse() {
manager.value = TextFieldValue(text = text, selection = TextRange(4, 4))
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/Overscroll.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/Overscroll.kt
index 12c6ba4..03760b9 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/Overscroll.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/Overscroll.kt
@@ -37,6 +37,9 @@
* container have been reached with a scroll or fling. To create an instance of the default /
* currently provided [OverscrollFactory], use [rememberOverscrollEffect].
*
+ * To implement, make sure to override [node] - this has a default implementation for compatibility
+ * reasons, but is required for an OverscrollEffect to render.
+ *
* OverscrollEffect conceptually 'decorates' scroll / fling events: consuming some of the delta or
* velocity before and/or after the event is consumed by the scrolling container. [applyToScroll]
* applies overscroll to a scroll event, and [applyToFling] applies overscroll to a fling.
@@ -121,6 +124,21 @@
val isInProgress: Boolean
/**
+ * A [Modifier] that will draw this OverscrollEffect
+ *
+ * This API is deprecated- implementers should instead override [node]. Callers should use
+ * [Modifier.overscroll].
+ */
+ @Deprecated(
+ "This has been replaced with `node`. If you are calling this property to render overscroll, use Modifier.overscroll() instead. If you are implementing OverscrollEffect, override `node` instead to render your overscroll.",
+ level = DeprecationLevel.ERROR,
+ replaceWith =
+ ReplaceWith("Modifier.overscroll(this)", "androidx.compose.foundation.overscroll")
+ )
+ val effectModifier: Modifier
+ get() = Modifier
+
+ /**
* The [DelegatableNode] that will render this OverscrollEffect and provide any required size or
* other information to this effect.
*
@@ -132,6 +150,7 @@
* [DelegatableNode]s.
*/
val node: DelegatableNode
+ get() = object : Modifier.Node() {}
}
/**
@@ -250,8 +269,14 @@
* @sample androidx.compose.foundation.samples.OverscrollSample
* @param overscrollEffect the [OverscrollEffect] to render
*/
-fun Modifier.overscroll(overscrollEffect: OverscrollEffect?): Modifier =
- this.then(OverscrollModifierElement(overscrollEffect))
+@Suppress("DEPRECATION_ERROR")
+fun Modifier.overscroll(overscrollEffect: OverscrollEffect?): Modifier {
+ val effectModifier = overscrollEffect?.effectModifier ?: Modifier
+ val modifier =
+ if (effectModifier !== Modifier) effectModifier
+ else OverscrollModifierElement(overscrollEffect)
+ return this.then(modifier)
+}
private class OverscrollModifierElement(
private val overscrollEffect: OverscrollEffect?,
@@ -316,6 +341,14 @@
}
/**
+ * Needed for behavioral backwards compatibility for
+ * [androidx.compose.foundation.gestures.ScrollableDefaults.overscrollEffect]. New code should use
+ * [rememberOverscrollEffect] instead, which takes into account theme provided overscroll, rather
+ * than always using the platform default, without any customizations.
+ */
+@Composable internal expect fun rememberPlatformOverscrollEffect(): OverscrollEffect?
+
+/**
* A factory for creating [OverscrollEffect]s. You can provide a factory instance to
* [LocalOverscrollFactory] to globally change the factory, and hence effect, used by components
* within the hierarchy.
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/Scroll.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/Scroll.kt
index 7a034224..0e256334 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/Scroll.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/Scroll.kt
@@ -38,6 +38,7 @@
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.Snapshot
import androidx.compose.ui.Modifier
+import androidx.compose.ui.composed
import androidx.compose.ui.layout.IntrinsicMeasurable
import androidx.compose.ui.layout.IntrinsicMeasureScope
import androidx.compose.ui.layout.Measurable
@@ -47,6 +48,7 @@
import androidx.compose.ui.node.ModifierNodeElement
import androidx.compose.ui.node.SemanticsModifierNode
import androidx.compose.ui.platform.InspectorInfo
+import androidx.compose.ui.platform.debugInspectorInfo
import androidx.compose.ui.semantics.ScrollAxisRange
import androidx.compose.ui.semantics.SemanticsPropertyReceiver
import androidx.compose.ui.semantics.horizontalScrollAxisRange
@@ -215,13 +217,24 @@
flingBehavior: FlingBehavior? = null,
reverseScrolling: Boolean = false
) =
- scroll(
- state = state,
- isScrollable = enabled,
- reverseScrolling = reverseScrolling,
- flingBehavior = flingBehavior,
- isVertical = true,
- useLocalOverscrollFactory = true
+ composed(
+ factory = {
+ verticalScroll(
+ state = state,
+ enabled = enabled,
+ flingBehavior = flingBehavior,
+ reverseScrolling = reverseScrolling,
+ overscrollEffect = rememberOverscrollEffect(),
+ )
+ },
+ inspectorInfo =
+ debugInspectorInfo {
+ name = "verticalScroll"
+ properties["state"] = state
+ properties["enabled"] = enabled
+ properties["flingBehavior"] = flingBehavior
+ properties["reverseScrolling"] = reverseScrolling
+ }
)
/**
@@ -256,7 +269,6 @@
reverseScrolling = reverseScrolling,
flingBehavior = flingBehavior,
isVertical = true,
- useLocalOverscrollFactory = false,
overscrollEffect = overscrollEffect
)
@@ -284,13 +296,24 @@
flingBehavior: FlingBehavior? = null,
reverseScrolling: Boolean = false
) =
- scroll(
- state = state,
- isScrollable = enabled,
- reverseScrolling = reverseScrolling,
- flingBehavior = flingBehavior,
- isVertical = false,
- useLocalOverscrollFactory = true
+ composed(
+ factory = {
+ horizontalScroll(
+ state = state,
+ enabled = enabled,
+ flingBehavior = flingBehavior,
+ reverseScrolling = reverseScrolling,
+ overscrollEffect = rememberOverscrollEffect(),
+ )
+ },
+ inspectorInfo =
+ debugInspectorInfo {
+ name = "horizontalScroll"
+ properties["state"] = state
+ properties["enabled"] = enabled
+ properties["flingBehavior"] = flingBehavior
+ properties["reverseScrolling"] = reverseScrolling
+ }
)
/**
@@ -325,7 +348,6 @@
reverseScrolling = reverseScrolling,
flingBehavior = flingBehavior,
isVertical = false,
- useLocalOverscrollFactory = false,
overscrollEffect = overscrollEffect
)
@@ -335,8 +357,7 @@
flingBehavior: FlingBehavior?,
isScrollable: Boolean,
isVertical: Boolean,
- useLocalOverscrollFactory: Boolean,
- overscrollEffect: OverscrollEffect? = null
+ overscrollEffect: OverscrollEffect?
): Modifier {
val orientation = if (isVertical) Orientation.Vertical else Orientation.Horizontal
return scrollingContainer(
@@ -346,7 +367,6 @@
reverseScrolling = reverseScrolling,
flingBehavior = flingBehavior,
interactionSource = state.internalInteractionSource,
- useLocalOverscrollFactory = useLocalOverscrollFactory,
overscrollEffect = overscrollEffect
)
.then(ScrollingLayoutElement(state, reverseScrolling, isVertical))
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/ScrollingContainer.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/ScrollingContainer.kt
index ec1a0c6..33a3730 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/ScrollingContainer.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/ScrollingContainer.kt
@@ -28,27 +28,18 @@
import androidx.compose.ui.layout.Measurable
import androidx.compose.ui.layout.MeasureResult
import androidx.compose.ui.layout.MeasureScope
-import androidx.compose.ui.node.CompositionLocalConsumerModifierNode
import androidx.compose.ui.node.DelegatableNode
import androidx.compose.ui.node.DelegatingNode
import androidx.compose.ui.node.LayoutModifierNode
import androidx.compose.ui.node.ModifierNodeElement
-import androidx.compose.ui.node.ObserverModifierNode
-import androidx.compose.ui.node.currentValueOf
import androidx.compose.ui.node.invalidatePlacement
-import androidx.compose.ui.node.observeReads
import androidx.compose.ui.node.requireLayoutDirection
import androidx.compose.ui.platform.InspectorInfo
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.LayoutDirection
// TODO b/316559454 to make it public
-/**
- * Scrolling related information to transform a layout into a "Scrollable Container" If
- * [useLocalOverscrollFactory] is false, [overscrollEffect] will be used. If
- * [useLocalOverscrollFactory] is true, [overscrollEffect] will be ignored and
- * [LocalOverscrollFactory] will be used instead internally.
- */
+/** Scrolling related information to transform a layout into a "Scrollable Container" */
internal fun Modifier.scrollingContainer(
state: ScrollableState,
orientation: Orientation,
@@ -56,7 +47,6 @@
reverseScrolling: Boolean,
flingBehavior: FlingBehavior?,
interactionSource: MutableInteractionSource?,
- useLocalOverscrollFactory: Boolean,
overscrollEffect: OverscrollEffect?,
bringIntoViewSpec: BringIntoViewSpec? = null
): Modifier {
@@ -69,7 +59,6 @@
flingBehavior = flingBehavior,
interactionSource = interactionSource,
bringIntoViewSpec = bringIntoViewSpec,
- useLocalOverscrollFactory = useLocalOverscrollFactory,
overscrollEffect = overscrollEffect
)
)
@@ -88,7 +77,6 @@
private val flingBehavior: FlingBehavior?,
private val interactionSource: MutableInteractionSource?,
private val bringIntoViewSpec: BringIntoViewSpec?,
- private val useLocalOverscrollFactory: Boolean,
private val overscrollEffect: OverscrollEffect?
) : ModifierNodeElement<ScrollingContainerNode>() {
override fun create(): ScrollingContainerNode {
@@ -100,7 +88,6 @@
flingBehavior = flingBehavior,
interactionSource = interactionSource,
bringIntoViewSpec = bringIntoViewSpec,
- useLocalOverscrollFactory = useLocalOverscrollFactory,
overscrollEffect = overscrollEffect
)
}
@@ -109,7 +96,6 @@
node.update(
state = state,
orientation = orientation,
- useLocalOverscrollFactory = useLocalOverscrollFactory,
overscrollEffect = overscrollEffect,
enabled = enabled,
reverseScrolling = reverseScrolling,
@@ -128,7 +114,6 @@
properties["flingBehavior"] = flingBehavior
properties["interactionSource"] = interactionSource
properties["bringIntoViewSpec"] = bringIntoViewSpec
- properties["useLocalOverscrollFactory"] = useLocalOverscrollFactory
properties["overscrollEffect"] = overscrollEffect
}
@@ -145,7 +130,6 @@
if (flingBehavior != other.flingBehavior) return false
if (interactionSource != other.interactionSource) return false
if (bringIntoViewSpec != other.bringIntoViewSpec) return false
- if (useLocalOverscrollFactory != other.useLocalOverscrollFactory) return false
if (overscrollEffect != other.overscrollEffect) return false
return true
@@ -159,7 +143,6 @@
result = 31 * result + (flingBehavior?.hashCode() ?: 0)
result = 31 * result + (interactionSource?.hashCode() ?: 0)
result = 31 * result + (bringIntoViewSpec?.hashCode() ?: 0)
- result = 31 * result + useLocalOverscrollFactory.hashCode()
result = 31 * result + (overscrollEffect?.hashCode() ?: 0)
return result
}
@@ -173,17 +156,11 @@
private var flingBehavior: FlingBehavior?,
private var interactionSource: MutableInteractionSource?,
private var bringIntoViewSpec: BringIntoViewSpec?,
- private var useLocalOverscrollFactory: Boolean,
private var overscrollEffect: OverscrollEffect?
-) :
- DelegatingNode(),
- LayoutModifierNode,
- CompositionLocalConsumerModifierNode,
- ObserverModifierNode {
+) : DelegatingNode(), LayoutModifierNode {
override val shouldAutoInvalidate = false
private var scrollableNode: ScrollableNode? = null
private var overscrollNode: DelegatableNode? = null
- private var localOverscrollFactory: OverscrollFactory? = null
private var shouldReverseDirection = false
// Needs to be mutated to properly update the underlying layer, which relies on instance
@@ -233,7 +210,6 @@
update(
state,
orientation,
- useLocalOverscrollFactory,
overscrollEffect,
enabled,
reverseScrolling,
@@ -247,7 +223,6 @@
fun update(
state: ScrollableState,
orientation: Orientation,
- useLocalOverscrollFactory: Boolean,
overscrollEffect: OverscrollEffect?,
enabled: Boolean,
reverseScrolling: Boolean,
@@ -266,22 +241,8 @@
}
invalidatePlacement()
}
- var useLocalOverscrollFactoryChanged = false
- if (this.useLocalOverscrollFactory != useLocalOverscrollFactory) {
- useLocalOverscrollFactoryChanged = true
- this.useLocalOverscrollFactory = useLocalOverscrollFactory
- }
- var overscrollEffectChanged = false
if (this.overscrollEffect != overscrollEffect) {
- overscrollEffectChanged = true
this.overscrollEffect = overscrollEffect
- }
- if (
- useLocalOverscrollFactoryChanged ||
- // If the overscroll effect changed but we are still using the local factory, this
- // should no-op
- overscrollEffectChanged && !useLocalOverscrollFactory
- ) {
overscrollNode?.let { undelegate(it) }
overscrollNode = null
attachOverscrollNodeIfNeeded()
@@ -314,31 +275,11 @@
}
private fun attachOverscrollNodeIfNeeded() {
- if (overscrollNode == null) {
- var effect: OverscrollEffect? = overscrollEffect
- // Overrides overscrollEffect if set
- if (useLocalOverscrollFactory) {
- observeReads {
- localOverscrollFactory = currentValueOf(LocalOverscrollFactory)
- effect = localOverscrollFactory?.createOverscrollEffect()
- }
+ if (overscrollNode == null && overscrollEffect != null) {
+ val node = overscrollEffect!!.node
+ if (!node.node.isAttached) {
+ overscrollNode = delegate(node)
}
- if (effect != null) {
- val node = effect!!.node
- if (!node.node.isAttached) {
- overscrollNode = delegate(node)
- }
- }
- }
- }
-
- override fun onObservedReadsChanged() {
- val factory = currentValueOf(LocalOverscrollFactory)
- if (factory != localOverscrollFactory) {
- localOverscrollFactory = factory
- overscrollNode?.let { undelegate(it) }
- overscrollNode = null
- attachOverscrollNodeIfNeeded()
}
}
}
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/Scrollable.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/Scrollable.kt
index f8388e5..93725b7 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/Scrollable.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/Scrollable.kt
@@ -25,12 +25,15 @@
import androidx.compose.foundation.ComposeFoundationFlags.NewNestedFlingPropagationEnabled
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.FocusedBoundsObserverNode
+import androidx.compose.foundation.LocalOverscrollFactory
import androidx.compose.foundation.MutatePriority
import androidx.compose.foundation.OverscrollEffect
import androidx.compose.foundation.gestures.Orientation.Horizontal
import androidx.compose.foundation.gestures.Orientation.Vertical
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.relocation.BringIntoViewResponderNode
+import androidx.compose.foundation.rememberOverscrollEffect
+import androidx.compose.foundation.rememberPlatformOverscrollEffect
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.remember
@@ -58,6 +61,7 @@
import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.input.pointer.PointerType
import androidx.compose.ui.node.CompositionLocalConsumerModifierNode
+import androidx.compose.ui.node.DelegatableNode
import androidx.compose.ui.node.ModifierNodeElement
import androidx.compose.ui.node.SemanticsModifierNode
import androidx.compose.ui.node.TraversableNode
@@ -541,6 +545,46 @@
}
/**
+ * Returns a remembered [OverscrollEffect] created from the current value of
+ * [LocalOverscrollFactory].
+ *
+ * This API has been deprpecated, and replaced with [rememberOverscrollEffect]
+ */
+ @Deprecated(
+ "This API has been replaced with rememberOverscrollEffect, which queries theme provided OverscrollFactory values instead of the 'platform default' without customization.",
+ replaceWith =
+ ReplaceWith(
+ "rememberOverscrollEffect()",
+ "androidx.compose.foundation.rememberOverscrollEffect"
+ )
+ )
+ @Composable
+ fun overscrollEffect(): OverscrollEffect {
+ return rememberPlatformOverscrollEffect() ?: NoOpOverscrollEffect
+ }
+
+ private object NoOpOverscrollEffect : OverscrollEffect {
+ override fun applyToScroll(
+ delta: Offset,
+ source: NestedScrollSource,
+ performScroll: (Offset) -> Offset
+ ): Offset = performScroll(delta)
+
+ override suspend fun applyToFling(
+ velocity: Velocity,
+ performFling: suspend (Velocity) -> Velocity
+ ) {
+ performFling(velocity)
+ }
+
+ override val isInProgress: Boolean
+ get() = false
+
+ override val node: DelegatableNode
+ get() = object : Modifier.Node() {}
+ }
+
+ /**
* Used to determine the value of `reverseDirection` parameter of [Modifier.scrollable] in
* scrollable layouts.
*
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyList.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyList.kt
index ea7e5c0..bc592cc 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyList.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyList.kt
@@ -146,7 +146,6 @@
reverseScrolling = reverseLayout,
flingBehavior = flingBehavior,
interactionSource = state.internalInteractionSource,
- useLocalOverscrollFactory = false,
overscrollEffect = overscrollEffect
),
prefetchState = state.prefetchState,
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGrid.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGrid.kt
index 37a7183..27c5b21 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGrid.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGrid.kt
@@ -139,7 +139,6 @@
reverseScrolling = reverseLayout,
flingBehavior = flingBehavior,
interactionSource = state.internalInteractionSource,
- useLocalOverscrollFactory = false,
overscrollEffect = overscrollEffect
),
prefetchState = state.prefetchState,
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGrid.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGrid.kt
index ff86e6da..9a6acc9 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGrid.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGrid.kt
@@ -112,7 +112,6 @@
reverseScrolling = reverseLayout,
flingBehavior = flingBehavior,
interactionSource = state.mutableInteractionSource,
- useLocalOverscrollFactory = false,
overscrollEffect = overscrollEffect
),
prefetchState = state.prefetchState,
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/LazyLayoutPager.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/LazyLayoutPager.kt
index 5c971cc..0b5d584 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/LazyLayoutPager.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/LazyLayoutPager.kt
@@ -185,7 +185,6 @@
flingBehavior = resolvedFlingBehavior,
interactionSource = state.internalInteractionSource,
overscrollEffect = overscrollEffect,
- useLocalOverscrollFactory = false,
bringIntoViewSpec = pagerBringIntoViewSpec
)
.dragDirectionDetector(state)
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/CoreTextField.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/CoreTextField.kt
index 3123ffa..d00eb59 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/CoreTextField.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/CoreTextField.kt
@@ -82,6 +82,7 @@
import androidx.compose.ui.layout.MeasureResult
import androidx.compose.ui.layout.MeasureScope
import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.platform.LocalAutofillManager
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
@@ -311,6 +312,7 @@
manager.clipboardManager = LocalClipboardManager.current
manager.textToolbar = LocalTextToolbar.current
manager.hapticFeedBack = LocalHapticFeedback.current
+ manager.autofillManager = LocalAutofillManager.current
manager.focusRequester = focusRequester
manager.editable = !readOnly
manager.enabled = enabled
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/modifiers/TextStringSimpleNode.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/modifiers/TextStringSimpleNode.kt
index d22b7c5..0801ba7 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/modifiers/TextStringSimpleNode.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/modifiers/TextStringSimpleNode.kt
@@ -58,8 +58,8 @@
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Constraints.Companion.fitPrioritizingWidth
-import androidx.compose.ui.unit.Density
import androidx.compose.ui.util.fastRoundToInt
+import kotlin.jvm.JvmName
/**
* Node that implements Text for [String].
@@ -105,20 +105,36 @@
}
/**
- * Get the layout cache for the current state of the node.
+ * Get the layout cache for the current state of the node during layout.
+ *
+ * If text substitution is active, this will return the layout cache for the substitution.
+ * Otherwise, it will return the layout cache for the original text.
+ *
+ * @receiver Current measure scope that requests the layout cache. This scope is used to update
+ * the density value of the returned cache.
+ */
+ private fun IntrinsicMeasureScope.getLayoutCacheForMeasure(): ParagraphLayoutCache {
+ val activeCache = getLayoutCache()
+ activeCache.density = this@getLayoutCacheForMeasure
+ return activeCache
+ }
+
+ /**
+ * Get the layout cache for the current state of the node without updating the density.
+ *
+ * Warning; DO NOT USE this function from a MeasureScope. Instead please use
+ * [getLayoutCacheForMeasure].
+ *
+ * The reason this function does not update the density value is because the density should not
+ * change between layout and draw phases. This is a micro optimization to skip the unnecessary
+ * density comparison.
*
* If text substitution is active, this will return the layout cache for the substitution.
* Otherwise, it will return the layout cache for the original text.
*/
- private fun getLayoutCache(density: Density): ParagraphLayoutCache {
- textSubstitution?.let { textSubstitutionValue ->
- if (textSubstitutionValue.isShowingSubstitution) {
- textSubstitutionValue.layoutCache?.let { cache ->
- return cache.also { it.density = density }
- }
- }
- }
- return layoutCache.also { it.density = density }
+ @JvmName("getLayoutCacheOrSubstitute")
+ private fun getLayoutCache(): ParagraphLayoutCache {
+ return textSubstitution?.takeIf { it.isShowingSubstitution }?.layoutCache ?: layoutCache
}
fun updateDraw(color: ColorProducer?, style: TextStyle): Boolean {
@@ -343,7 +359,7 @@
measurable: Measurable,
constraints: Constraints
): MeasureResult {
- val layoutCache = getLayoutCache(this)
+ val layoutCache = getLayoutCacheForMeasure()
val didChangeLayout = layoutCache.layoutWithConstraints(constraints, layoutDirection)
// ensure measure restarts when hasStaleResolvedFonts by reading in measure
@@ -383,23 +399,23 @@
measurable: IntrinsicMeasurable,
height: Int
): Int {
- return getLayoutCache(this).minIntrinsicWidth(layoutDirection)
+ return getLayoutCacheForMeasure().minIntrinsicWidth(layoutDirection)
}
override fun IntrinsicMeasureScope.minIntrinsicHeight(
measurable: IntrinsicMeasurable,
width: Int
- ): Int = getLayoutCache(this).intrinsicHeight(width, layoutDirection)
+ ): Int = getLayoutCacheForMeasure().intrinsicHeight(width, layoutDirection)
override fun IntrinsicMeasureScope.maxIntrinsicWidth(
measurable: IntrinsicMeasurable,
height: Int
- ): Int = getLayoutCache(this).maxIntrinsicWidth(layoutDirection)
+ ): Int = getLayoutCacheForMeasure().maxIntrinsicWidth(layoutDirection)
override fun IntrinsicMeasureScope.maxIntrinsicHeight(
measurable: IntrinsicMeasurable,
width: Int
- ): Int = getLayoutCache(this).intrinsicHeight(width, layoutDirection)
+ ): Int = getLayoutCacheForMeasure().intrinsicHeight(width, layoutDirection)
/** Optimized Text draw. */
override fun ContentDrawScope.draw() {
@@ -408,7 +424,7 @@
return
}
- val layoutCache = getLayoutCache(this)
+ val layoutCache = getLayoutCache()
val localParagraph =
requirePreconditionNotNull(layoutCache.paragraph) {
"no paragraph (layoutCache=$_layoutCache, textSubstitution=$textSubstitution)"
diff --git a/compose/foundation/foundation/src/commonStubsMain/kotlin/androidx/compose/foundation/DesktopOverscroll.commonStubs.kt b/compose/foundation/foundation/src/commonStubsMain/kotlin/androidx/compose/foundation/DesktopOverscroll.commonStubs.kt
index fd46e2b..d65c067 100644
--- a/compose/foundation/foundation/src/commonStubsMain/kotlin/androidx/compose/foundation/DesktopOverscroll.commonStubs.kt
+++ b/compose/foundation/foundation/src/commonStubsMain/kotlin/androidx/compose/foundation/DesktopOverscroll.commonStubs.kt
@@ -16,7 +16,12 @@
package androidx.compose.foundation
+import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalAccessorScope
internal actual fun CompositionLocalAccessorScope.defaultOverscrollFactory(): OverscrollFactory? =
implementedInJetBrainsFork()
+
+@Composable
+internal actual fun rememberPlatformOverscrollEffect(): OverscrollEffect? =
+ implementedInJetBrainsFork()
diff --git a/compose/material3/material3/api/current.txt b/compose/material3/material3/api/current.txt
index c8872fd..78e3cbc 100644
--- a/compose/material3/material3/api/current.txt
+++ b/compose/material3/material3/api/current.txt
@@ -1076,22 +1076,26 @@
method @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @androidx.compose.runtime.Composable public androidx.compose.ui.graphics.Shape getXSmallSquareShape();
method @androidx.compose.runtime.Composable public androidx.compose.material3.IconButtonColors iconButtonColors();
method @androidx.compose.runtime.Composable public androidx.compose.material3.IconButtonColors iconButtonColors(optional long containerColor, optional long contentColor, optional long disabledContainerColor, optional long disabledContentColor);
- method @androidx.compose.runtime.Composable public androidx.compose.material3.IconButtonColors iconButtonLocalContentColors();
+ method @androidx.compose.runtime.Composable public androidx.compose.material3.IconButtonColors iconButtonVibrantColors();
+ method @androidx.compose.runtime.Composable public androidx.compose.material3.IconButtonColors iconButtonVibrantColors(optional long containerColor, optional long contentColor, optional long disabledContainerColor, optional long disabledContentColor);
method @androidx.compose.runtime.Composable public androidx.compose.material3.IconToggleButtonColors iconToggleButtonColors();
method @androidx.compose.runtime.Composable public androidx.compose.material3.IconToggleButtonColors iconToggleButtonColors(optional long containerColor, optional long contentColor, optional long disabledContainerColor, optional long disabledContentColor, optional long checkedContainerColor, optional long checkedContentColor);
- method @androidx.compose.runtime.Composable public androidx.compose.material3.IconToggleButtonColors iconToggleButtonLocalContentColors();
+ method @androidx.compose.runtime.Composable public androidx.compose.material3.IconToggleButtonColors iconToggleButtonVibrantColors();
+ method @androidx.compose.runtime.Composable public androidx.compose.material3.IconToggleButtonColors iconToggleButtonVibrantColors(optional long containerColor, optional long contentColor, optional long disabledContainerColor, optional long disabledContentColor, optional long checkedContainerColor, optional long checkedContentColor);
method @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi public long largeContainerSize(optional int widthOption);
method @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi public long mediumContainerSize(optional int widthOption);
method @androidx.compose.runtime.Composable public androidx.compose.foundation.BorderStroke outlinedIconButtonBorder(boolean enabled);
method @androidx.compose.runtime.Composable public androidx.compose.material3.IconButtonColors outlinedIconButtonColors();
method @androidx.compose.runtime.Composable public androidx.compose.material3.IconButtonColors outlinedIconButtonColors(optional long containerColor, optional long contentColor, optional long disabledContainerColor, optional long disabledContentColor);
- method @androidx.compose.runtime.Composable public androidx.compose.foundation.BorderStroke? outlinedIconButtonLocalContentColorBorder(boolean enabled);
- method @androidx.compose.runtime.Composable public androidx.compose.material3.IconButtonColors outlinedIconButtonLocalContentColors();
+ method @androidx.compose.runtime.Composable public androidx.compose.foundation.BorderStroke outlinedIconButtonVibrantBorder(boolean enabled);
+ method @androidx.compose.runtime.Composable public androidx.compose.material3.IconButtonColors outlinedIconButtonVibrantColors();
+ method @androidx.compose.runtime.Composable public androidx.compose.material3.IconButtonColors outlinedIconButtonVibrantColors(optional long containerColor, optional long contentColor, optional long disabledContainerColor, optional long disabledContentColor);
method @androidx.compose.runtime.Composable public androidx.compose.foundation.BorderStroke? outlinedIconToggleButtonBorder(boolean enabled, boolean checked);
method @androidx.compose.runtime.Composable public androidx.compose.material3.IconToggleButtonColors outlinedIconToggleButtonColors();
method @androidx.compose.runtime.Composable public androidx.compose.material3.IconToggleButtonColors outlinedIconToggleButtonColors(optional long containerColor, optional long contentColor, optional long disabledContainerColor, optional long disabledContentColor, optional long checkedContainerColor, optional long checkedContentColor);
- method @androidx.compose.runtime.Composable public androidx.compose.foundation.BorderStroke? outlinedIconToggleButtonLocalContentColorBorder(boolean enabled, boolean checked);
- method @androidx.compose.runtime.Composable public androidx.compose.material3.IconToggleButtonColors outlinedIconToggleButtonLocalContentColors();
+ method @androidx.compose.runtime.Composable public androidx.compose.foundation.BorderStroke? outlinedIconToggleButtonVibrantBorder(boolean enabled, boolean checked);
+ method @androidx.compose.runtime.Composable public androidx.compose.material3.IconToggleButtonColors outlinedIconToggleButtonVibrantColors();
+ method @androidx.compose.runtime.Composable public androidx.compose.material3.IconToggleButtonColors outlinedIconToggleButtonVibrantColors(optional long containerColor, optional long contentColor, optional long disabledContainerColor, optional long disabledContentColor, optional long checkedContainerColor, optional long checkedContentColor);
method @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @androidx.compose.runtime.Composable public androidx.compose.material3.IconButtonShapes shapes(androidx.compose.ui.graphics.Shape shape, androidx.compose.ui.graphics.Shape pressedShape, androidx.compose.ui.graphics.Shape checkedShape);
method @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi public long smallContainerSize(optional int widthOption);
method @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi public long xLargeContainerSize(optional int widthOption);
diff --git a/compose/material3/material3/api/restricted_current.txt b/compose/material3/material3/api/restricted_current.txt
index c8872fd..78e3cbc 100644
--- a/compose/material3/material3/api/restricted_current.txt
+++ b/compose/material3/material3/api/restricted_current.txt
@@ -1076,22 +1076,26 @@
method @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @androidx.compose.runtime.Composable public androidx.compose.ui.graphics.Shape getXSmallSquareShape();
method @androidx.compose.runtime.Composable public androidx.compose.material3.IconButtonColors iconButtonColors();
method @androidx.compose.runtime.Composable public androidx.compose.material3.IconButtonColors iconButtonColors(optional long containerColor, optional long contentColor, optional long disabledContainerColor, optional long disabledContentColor);
- method @androidx.compose.runtime.Composable public androidx.compose.material3.IconButtonColors iconButtonLocalContentColors();
+ method @androidx.compose.runtime.Composable public androidx.compose.material3.IconButtonColors iconButtonVibrantColors();
+ method @androidx.compose.runtime.Composable public androidx.compose.material3.IconButtonColors iconButtonVibrantColors(optional long containerColor, optional long contentColor, optional long disabledContainerColor, optional long disabledContentColor);
method @androidx.compose.runtime.Composable public androidx.compose.material3.IconToggleButtonColors iconToggleButtonColors();
method @androidx.compose.runtime.Composable public androidx.compose.material3.IconToggleButtonColors iconToggleButtonColors(optional long containerColor, optional long contentColor, optional long disabledContainerColor, optional long disabledContentColor, optional long checkedContainerColor, optional long checkedContentColor);
- method @androidx.compose.runtime.Composable public androidx.compose.material3.IconToggleButtonColors iconToggleButtonLocalContentColors();
+ method @androidx.compose.runtime.Composable public androidx.compose.material3.IconToggleButtonColors iconToggleButtonVibrantColors();
+ method @androidx.compose.runtime.Composable public androidx.compose.material3.IconToggleButtonColors iconToggleButtonVibrantColors(optional long containerColor, optional long contentColor, optional long disabledContainerColor, optional long disabledContentColor, optional long checkedContainerColor, optional long checkedContentColor);
method @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi public long largeContainerSize(optional int widthOption);
method @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi public long mediumContainerSize(optional int widthOption);
method @androidx.compose.runtime.Composable public androidx.compose.foundation.BorderStroke outlinedIconButtonBorder(boolean enabled);
method @androidx.compose.runtime.Composable public androidx.compose.material3.IconButtonColors outlinedIconButtonColors();
method @androidx.compose.runtime.Composable public androidx.compose.material3.IconButtonColors outlinedIconButtonColors(optional long containerColor, optional long contentColor, optional long disabledContainerColor, optional long disabledContentColor);
- method @androidx.compose.runtime.Composable public androidx.compose.foundation.BorderStroke? outlinedIconButtonLocalContentColorBorder(boolean enabled);
- method @androidx.compose.runtime.Composable public androidx.compose.material3.IconButtonColors outlinedIconButtonLocalContentColors();
+ method @androidx.compose.runtime.Composable public androidx.compose.foundation.BorderStroke outlinedIconButtonVibrantBorder(boolean enabled);
+ method @androidx.compose.runtime.Composable public androidx.compose.material3.IconButtonColors outlinedIconButtonVibrantColors();
+ method @androidx.compose.runtime.Composable public androidx.compose.material3.IconButtonColors outlinedIconButtonVibrantColors(optional long containerColor, optional long contentColor, optional long disabledContainerColor, optional long disabledContentColor);
method @androidx.compose.runtime.Composable public androidx.compose.foundation.BorderStroke? outlinedIconToggleButtonBorder(boolean enabled, boolean checked);
method @androidx.compose.runtime.Composable public androidx.compose.material3.IconToggleButtonColors outlinedIconToggleButtonColors();
method @androidx.compose.runtime.Composable public androidx.compose.material3.IconToggleButtonColors outlinedIconToggleButtonColors(optional long containerColor, optional long contentColor, optional long disabledContainerColor, optional long disabledContentColor, optional long checkedContainerColor, optional long checkedContentColor);
- method @androidx.compose.runtime.Composable public androidx.compose.foundation.BorderStroke? outlinedIconToggleButtonLocalContentColorBorder(boolean enabled, boolean checked);
- method @androidx.compose.runtime.Composable public androidx.compose.material3.IconToggleButtonColors outlinedIconToggleButtonLocalContentColors();
+ method @androidx.compose.runtime.Composable public androidx.compose.foundation.BorderStroke? outlinedIconToggleButtonVibrantBorder(boolean enabled, boolean checked);
+ method @androidx.compose.runtime.Composable public androidx.compose.material3.IconToggleButtonColors outlinedIconToggleButtonVibrantColors();
+ method @androidx.compose.runtime.Composable public androidx.compose.material3.IconToggleButtonColors outlinedIconToggleButtonVibrantColors(optional long containerColor, optional long contentColor, optional long disabledContainerColor, optional long disabledContentColor, optional long checkedContainerColor, optional long checkedContentColor);
method @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @androidx.compose.runtime.Composable public androidx.compose.material3.IconButtonShapes shapes(androidx.compose.ui.graphics.Shape shape, androidx.compose.ui.graphics.Shape pressedShape, androidx.compose.ui.graphics.Shape checkedShape);
method @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi public long smallContainerSize(optional int widthOption);
method @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi public long xLargeContainerSize(optional int widthOption);
diff --git a/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/IconButtonScreenshotTest.kt b/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/IconButtonScreenshotTest.kt
index da0d64f..896ab0a 100644
--- a/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/IconButtonScreenshotTest.kt
+++ b/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/IconButtonScreenshotTest.kt
@@ -58,7 +58,6 @@
@MediumTest
@RunWith(AndroidJUnit4::class)
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
-@OptIn(ExperimentalMaterial3Api::class)
class IconButtonScreenshotTest {
@get:Rule val rule = createComposeRule()
diff --git a/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/IconButtonTest.kt b/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/IconButtonTest.kt
index f2e370a..dbb49b3 100644
--- a/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/IconButtonTest.kt
+++ b/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/IconButtonTest.kt
@@ -16,6 +16,7 @@
package androidx.compose.material3
import android.os.Build
+import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.requiredSize
@@ -27,6 +28,7 @@
import androidx.compose.material3.tokens.FilledIconButtonTokens
import androidx.compose.material3.tokens.FilledTonalIconButtonTokens
import androidx.compose.material3.tokens.OutlinedIconButtonTokens
+import androidx.compose.material3.tokens.SmallIconButtonTokens
import androidx.compose.material3.tokens.StandardIconButtonTokens
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
@@ -309,7 +311,7 @@
fun iconButton_defaultLocalContentColors() {
rule.setMaterialContent(lightColorScheme()) {
CompositionLocalProvider(LocalContentColor provides Color.Blue) {
- Truth.assertThat(IconButtonDefaults.iconButtonLocalContentColors())
+ Truth.assertThat(IconButtonDefaults.iconButtonColors())
.isEqualTo(
IconButtonColors(
containerColor = Color.Transparent,
@@ -324,9 +326,9 @@
}
@Test
- fun iconButton_defaultColors() {
+ fun iconButton_defaultVibrantColors() {
rule.setMaterialContent(lightColorScheme()) {
- Truth.assertThat(IconButtonDefaults.iconButtonColors())
+ Truth.assertThat(IconButtonDefaults.iconButtonVibrantColors())
.isEqualTo(
IconButtonColors(
containerColor = Color.Transparent,
@@ -342,38 +344,32 @@
}
@Test
- fun iconButtonColors_localContentColor() {
+ fun iconButtonColors_useLocalContentColor() {
rule.setMaterialContent(lightColorScheme()) {
CompositionLocalProvider(LocalContentColor provides Color.Blue) {
- val colors = IconButtonDefaults.iconButtonLocalContentColors()
+ val colors = IconButtonDefaults.iconButtonColors()
assert(colors.contentColor == Color.Blue)
}
CompositionLocalProvider(LocalContentColor provides Color.Red) {
val colors =
- IconButtonDefaults.iconButtonLocalContentColors()
- .copy(containerColor = Color.Green)
+ IconButtonDefaults.iconButtonColors().copy(containerColor = Color.Green)
assert(colors.containerColor == Color.Green)
assert(colors.contentColor == Color.Red)
+ assert(
+ colors.disabledContentColor ==
+ Color.Red.copy(StandardIconButtonTokens.DisabledOpacity)
+ )
}
}
}
@Test
- fun iconButtonColors_customValues_useLocalContentColor() {
+ fun iconButtonVibrantColors_ignoreLocalContentColor() {
rule.setMaterialContent(lightColorScheme()) {
- CompositionLocalProvider(LocalContentColor provides Color.Blue) {
- val colors = IconButtonDefaults.iconButtonLocalContentColors()
- assert(colors.contentColor == Color.Blue)
- assert(
- colors.disabledContentColor ==
- Color.Blue.copy(StandardIconButtonTokens.DisabledOpacity)
- )
- }
-
CompositionLocalProvider(LocalContentColor provides Color.Red) {
val colors =
- IconButtonDefaults.iconButtonColors(
+ IconButtonDefaults.iconButtonVibrantColors(
containerColor = Color.Blue,
contentColor = Color.Green
)
@@ -392,7 +388,7 @@
rule.setMaterialContent(lightColorScheme()) {
CompositionLocalProvider(LocalContentColor provides Color.Red) {
val colors =
- IconButtonDefaults.iconButtonColors(
+ IconButtonDefaults.iconButtonVibrantColors(
containerColor = Color.Blue,
contentColor = Color.Green
)
@@ -409,8 +405,8 @@
@Test
fun iconButtonColors_copy() {
rule.setMaterialContent(lightColorScheme()) {
- val colors = IconButtonDefaults.iconButtonColors().copy()
- assert(colors == IconButtonDefaults.iconButtonColors())
+ val colors = IconButtonDefaults.iconButtonVibrantColors().copy()
+ assert(colors == IconButtonDefaults.iconButtonVibrantColors())
}
}
@@ -543,7 +539,7 @@
fun iconToggleButton_defaultLocalContentColors() {
rule.setMaterialContent(lightColorScheme()) {
val localContentColor = LocalContentColor.current
- Truth.assertThat(IconButtonDefaults.iconToggleButtonLocalContentColors())
+ Truth.assertThat(IconButtonDefaults.iconToggleButtonColors())
.isEqualTo(
IconToggleButtonColors(
containerColor = Color.Transparent,
@@ -561,9 +557,9 @@
}
@Test
- fun iconToggleButton_defaultColors() {
+ fun iconToggleButton_defaultVibrantColors() {
rule.setMaterialContent(lightColorScheme()) {
- Truth.assertThat(IconButtonDefaults.iconToggleButtonColors())
+ Truth.assertThat(IconButtonDefaults.iconToggleButtonVibrantColors())
.isEqualTo(
IconToggleButtonColors(
containerColor = Color.Transparent,
@@ -741,7 +737,9 @@
.size(IconButtonDefaults.mediumContainerSize()),
shape = shape,
colors =
- IconButtonDefaults.iconButtonColors(containerColor = iconButtonColor)
+ IconButtonDefaults.iconButtonVibrantColors(
+ containerColor = iconButtonColor
+ )
) {}
}
}
@@ -1051,7 +1049,7 @@
.testTag(IconTestTag)
.size(IconButtonDefaults.mediumContainerSize()),
colors =
- IconButtonDefaults.iconToggleButtonColors(
+ IconButtonDefaults.iconToggleButtonVibrantColors(
checkedContainerColor = iconButtonColor
)
) {}
@@ -1305,7 +1303,7 @@
fun outlinedIconButton_defaultLocalContentColors() {
rule.setMaterialContent(lightColorScheme()) {
val localContentColor = LocalContentColor.current
- Truth.assertThat(IconButtonDefaults.outlinedIconButtonLocalContentColors())
+ Truth.assertThat(IconButtonDefaults.outlinedIconButtonColors())
.isEqualTo(
IconButtonColors(
containerColor = Color.Transparent,
@@ -1319,9 +1317,9 @@
}
@Test
- fun outlinedIconButton_defaultColors() {
+ fun outlinedIconButton_defaultVibrantColors() {
rule.setMaterialContent(lightColorScheme()) {
- Truth.assertThat(IconButtonDefaults.outlinedIconButtonColors())
+ Truth.assertThat(IconButtonDefaults.outlinedIconButtonVibrantColors())
.isEqualTo(
IconButtonColors(
containerColor = Color.Transparent,
@@ -1337,9 +1335,30 @@
}
@Test
- fun outlinedIconToggleButton_defaultColors() {
+ fun outlinedIconToggleButton_useLocalContentColors() {
rule.setMaterialContent(lightColorScheme()) {
- Truth.assertThat(IconButtonDefaults.outlinedIconToggleButtonColors())
+ CompositionLocalProvider(LocalContentColor provides Color.Blue) {
+ val colors = IconButtonDefaults.outlinedIconButtonColors()
+ assert(colors.contentColor == Color.Blue)
+ }
+
+ CompositionLocalProvider(LocalContentColor provides Color.Red) {
+ val colors =
+ IconButtonDefaults.outlinedIconButtonColors().copy(containerColor = Color.Green)
+ assert(colors.containerColor == Color.Green)
+ assert(colors.contentColor == Color.Red)
+ assert(
+ colors.disabledContentColor ==
+ Color.Red.copy(OutlinedIconButtonTokens.DisabledOpacity)
+ )
+ }
+ }
+ }
+
+ @Test
+ fun outlinedIconToggleButton_defaultVibrantColors() {
+ rule.setMaterialContent(lightColorScheme()) {
+ Truth.assertThat(IconButtonDefaults.outlinedIconToggleButtonVibrantColors())
.isEqualTo(
IconToggleButtonColors(
containerColor = Color.Transparent,
@@ -1358,55 +1377,29 @@
}
@Test
- fun outlinedIconToggleButton_defaultLocalContentColors_reuse() {
+ fun outlinedIconButton_borderStroke_defaultLocalContentColor() {
rule.setMaterialContent(lightColorScheme()) {
- val localContentColor = LocalContentColor.current
- Truth.assertThat(MaterialTheme.colorScheme.defaultOutlinedIconToggleButtonColorsCached)
- .isNull()
- val colors = IconButtonDefaults.outlinedIconToggleButtonLocalContentColors()
- Truth.assertThat(MaterialTheme.colorScheme.defaultOutlinedIconToggleButtonColorsCached)
- .isNotNull()
- Truth.assertThat(colors)
- .isEqualTo(
- IconToggleButtonColors(
- containerColor = Color.Transparent,
- contentColor = localContentColor,
- disabledContainerColor = Color.Transparent,
- disabledContentColor =
- localContentColor.copy(
- alpha = OutlinedIconButtonTokens.DisabledOpacity
- ),
- checkedContainerColor =
- OutlinedIconButtonTokens.SelectedContainerColor.value,
- checkedContentColor =
- contentColorFor(OutlinedIconButtonTokens.SelectedContainerColor.value)
- )
- )
+ CompositionLocalProvider(LocalContentColor provides Color.Blue) {
+ Truth.assertThat(IconButtonDefaults.outlinedIconButtonBorder(enabled = true))
+ .isEqualTo(BorderStroke(SmallIconButtonTokens.OutlinedOutlineWidth, Color.Blue))
+ }
}
}
@Test
- fun outlinedIconToggleButton_defaultColors_reuse() {
+ fun outlinedIconToggleButton_borderStroke_defaultVibrantColor() {
rule.setMaterialContent(lightColorScheme()) {
- val localContentColor = LocalContentColor.current
- Truth.assertThat(MaterialTheme.colorScheme.defaultOutlinedIconToggleButtonColorsCached)
- .isNull()
- val colors = IconButtonDefaults.outlinedIconToggleButtonColors()
- Truth.assertThat(MaterialTheme.colorScheme.defaultOutlinedIconToggleButtonColorsCached)
- .isNotNull()
- Truth.assertThat(colors)
+ val outlineColor = OutlinedIconButtonTokens.OutlineColor.value
+ Truth.assertThat(
+ IconButtonDefaults.outlinedIconToggleButtonVibrantBorder(
+ enabled = false,
+ checked = false
+ )
+ )
.isEqualTo(
- IconToggleButtonColors(
- containerColor = Color.Transparent,
- contentColor = OutlinedIconButtonTokens.UnselectedColor.value,
- disabledContainerColor = Color.Transparent,
- disabledContentColor =
- localContentColor.copy(
- alpha = OutlinedIconButtonTokens.DisabledOpacity
- ),
- checkedContainerColor =
- OutlinedIconButtonTokens.SelectedContainerColor.value,
- checkedContentColor = OutlinedIconButtonTokens.SelectedColor.value
+ BorderStroke(
+ SmallIconButtonTokens.OutlinedOutlineWidth,
+ outlineColor.copy(alpha = OutlinedIconButtonTokens.DisabledContainerOpacity)
)
)
}
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/ColorScheme.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/ColorScheme.kt
index bd16b81..4b952d8 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/ColorScheme.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/ColorScheme.kt
@@ -492,13 +492,17 @@
internal var defaultDatePickerColorsCached: DatePickerColors? = null
internal var defaultIconButtonColorsCached: IconButtonColors? = null
+ internal var defaultIconButtonVibrantColorsCached: IconButtonColors? = null
internal var defaultIconToggleButtonColorsCached: IconToggleButtonColors? = null
+ internal var defaultIconToggleButtonVibrantColorsCached: IconToggleButtonColors? = null
internal var defaultFilledIconButtonColorsCached: IconButtonColors? = null
internal var defaultFilledIconToggleButtonColorsCached: IconToggleButtonColors? = null
internal var defaultFilledTonalIconButtonColorsCached: IconButtonColors? = null
internal var defaultFilledTonalIconToggleButtonColorsCached: IconToggleButtonColors? = null
internal var defaultOutlinedIconButtonColorsCached: IconButtonColors? = null
+ internal var defaultOutlinedIconButtonVibrantColorsCached: IconButtonColors? = null
internal var defaultOutlinedIconToggleButtonColorsCached: IconToggleButtonColors? = null
+ internal var defaultOutlinedIconToggleButtonVibrantColorsCached: IconToggleButtonColors? = null
internal var defaultToggleButtonColorsCached: ToggleButtonColors? = null
internal var defaultElevatedToggleButtonColorsCached: ToggleButtonColors? = null
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/IconButton.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/IconButton.kt
index 6337974..35ba030 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/IconButton.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/IconButton.kt
@@ -90,7 +90,7 @@
* not respond to user input, and it will appear visually disabled and disabled to accessibility
* services.
* @param colors [IconButtonColors] that will be used to resolve the colors used for this icon
- * button in different states. See [IconButtonDefaults.iconButtonColors].
+ * button in different states. See [IconButtonDefaults.iconButtonVibrantColors].
* @param interactionSource an optional hoisted [MutableInteractionSource] for observing and
* emitting [Interaction]s for this icon button. You can use this to change the icon button's
* appearance or preview the icon button in different states. Note that if `null` is provided,
@@ -110,7 +110,7 @@
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
- colors: IconButtonColors = IconButtonDefaults.iconButtonLocalContentColors(),
+ colors: IconButtonColors = IconButtonDefaults.iconButtonColors(),
interactionSource: MutableInteractionSource? = null,
content: @Composable () -> Unit
) {
@@ -160,8 +160,8 @@
* not respond to user input, and it will appear visually disabled and disabled to accessibility
* services.
* @param colors [IconButtonColors] that will be used to resolve the colors used for this icon
- * button in different states. See [IconButtonDefaults.iconButtonColors] and
- * [IconButtonDefaults.iconButtonLocalContentColors] .
+ * button in different states. See [IconButtonDefaults.iconButtonVibrantColors] and
+ * [IconButtonDefaults.iconButtonColors] .
* @param interactionSource an optional hoisted [MutableInteractionSource] for observing and
* emitting [Interaction]s for this icon button. You can use this to change the icon button's
* appearance or preview the icon button in different states. Note that if `null` is provided,
@@ -175,7 +175,7 @@
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
- colors: IconButtonColors = IconButtonDefaults.iconButtonLocalContentColors(),
+ colors: IconButtonColors = IconButtonDefaults.iconButtonColors(),
interactionSource: MutableInteractionSource? = null,
shape: Shape = IconButtonDefaults.standardShape,
content: @Composable () -> Unit
@@ -227,7 +227,7 @@
* not respond to user input, and it will appear visually disabled and disabled to accessibility
* services.
* @param colors [IconToggleButtonColors] that will be used to resolve the colors used for this icon
- * button in different states. See [IconButtonDefaults.iconToggleButtonColors].
+ * button in different states. See [IconButtonDefaults.iconToggleButtonVibrantColors].
* @param interactionSource an optional hoisted [MutableInteractionSource] for observing and
* emitting [Interaction]s for this icon button. You can use this to change the icon button's
* appearance or preview the icon button in different states. Note that if `null` is provided,
@@ -249,7 +249,7 @@
onCheckedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
- colors: IconToggleButtonColors = IconButtonDefaults.iconToggleButtonLocalContentColors(),
+ colors: IconToggleButtonColors = IconButtonDefaults.iconToggleButtonColors(),
interactionSource: MutableInteractionSource? = null,
content: @Composable () -> Unit
) {
@@ -287,8 +287,8 @@
* not respond to user input, and it will appear visually disabled and disabled to accessibility
* services.
* @param colors [IconToggleButtonColors] that will be used to resolve the colors used for this icon
- * button in different states. See [IconButtonDefaults.iconToggleButtonColors] and
- * [IconButtonDefaults.iconToggleButtonLocalContentColors].
+ * button in different states. See [IconButtonDefaults.iconToggleButtonVibrantColors] and
+ * [IconButtonDefaults.iconToggleButtonColors].
* @param interactionSource an optional hoisted [MutableInteractionSource] for observing and
* emitting [Interaction]s for this icon button. You can use this to change the icon button's
* appearance or preview the icon button in different states. Note that if `null` is provided,
@@ -303,7 +303,7 @@
onCheckedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
- colors: IconToggleButtonColors = IconButtonDefaults.iconToggleButtonLocalContentColors(),
+ colors: IconToggleButtonColors = IconButtonDefaults.iconToggleButtonColors(),
interactionSource: MutableInteractionSource? = null,
shape: Shape = IconButtonDefaults.standardShape,
content: @Composable () -> Unit
@@ -343,7 +343,7 @@
* not respond to user input, and it will appear visually disabled and disabled to accessibility
* services.
* @param colors [IconToggleButtonColors] that will be used to resolve the colors used for this icon
- * button in different states. See [IconButtonDefaults.iconToggleButtonColors].
+ * button in different states. See [IconButtonDefaults.iconToggleButtonVibrantColors].
* @param interactionSource an optional hoisted [MutableInteractionSource] for observing and
* emitting [Interaction]s for this icon button. You can use this to change the icon button's
* appearance or preview the icon button in different states. Note that if `null` is provided,
@@ -358,7 +358,7 @@
shapes: IconButtonShapes,
modifier: Modifier = Modifier,
enabled: Boolean = true,
- colors: IconToggleButtonColors = IconButtonDefaults.iconToggleButtonColors(),
+ colors: IconToggleButtonColors = IconButtonDefaults.iconToggleButtonVibrantColors(),
interactionSource: MutableInteractionSource? = null,
content: @Composable () -> Unit
) {
@@ -383,7 +383,7 @@
onCheckedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
- colors: IconToggleButtonColors = IconButtonDefaults.iconToggleButtonColors(),
+ colors: IconToggleButtonColors = IconButtonDefaults.iconToggleButtonVibrantColors(),
interactionSource: MutableInteractionSource? = null,
shape: Shape = IconButtonDefaults.standardShape,
content: @Composable () -> Unit
@@ -788,11 +788,11 @@
* @param shape defines the shape of this icon button's container and border (when [border] is not
* null)
* @param colors [IconButtonColors] that will be used to resolve the colors used for this icon
- * button in different states. See [IconButtonDefaults.outlinedIconButtonColors] and
- * [IconButtonDefaults.outlinedIconButtonLocalContentColors].
+ * button in different states. See [IconButtonDefaults.outlinedIconButtonVibrantColors] and
+ * [IconButtonDefaults.outlinedIconButtonColors].
* @param border the border to draw around the container of this icon button. Pass `null` for no
* border. See [IconButtonDefaults.outlinedIconButtonBorder] and
- * [IconButtonDefaults.outlinedIconButtonLocalContentColorBorder].
+ * [IconButtonDefaults.outlinedIconButtonBorder].
* @param interactionSource an optional hoisted [MutableInteractionSource] for observing and
* emitting [Interaction]s for this icon button. You can use this to change the icon button's
* appearance or preview the icon button in different states. Note that if `null` is provided,
@@ -805,8 +805,8 @@
modifier: Modifier = Modifier,
enabled: Boolean = true,
shape: Shape = IconButtonDefaults.outlinedShape,
- colors: IconButtonColors = IconButtonDefaults.outlinedIconButtonLocalContentColors(),
- border: BorderStroke? = IconButtonDefaults.outlinedIconButtonLocalContentColorBorder(enabled),
+ colors: IconButtonColors = IconButtonDefaults.outlinedIconButtonColors(),
+ border: BorderStroke? = IconButtonDefaults.outlinedIconButtonBorder(enabled),
interactionSource: MutableInteractionSource? = null,
content: @Composable () -> Unit
) =
@@ -845,11 +845,11 @@
* @param shape defines the shape of this icon button's container and border (when [border] is not
* null)
* @param colors [IconToggleButtonColors] that will be used to resolve the colors used for this icon
- * button in different states. See [IconButtonDefaults.outlinedIconToggleButtonColors] and
- * [IconButtonDefaults.outlinedIconToggleButtonLocalContentColors].
+ * button in different states. See [IconButtonDefaults.outlinedIconToggleButtonVibrantColors] and
+ * [IconButtonDefaults.outlinedIconToggleButtonColors].
* @param border the border to draw around the container of this icon button. Pass `null` for no
- * border. See [IconButtonDefaults.outlinedIconToggleButtonBorder] and
- * [IconButtonDefaults.outlinedIconToggleButtonLocalContentColorBorder].
+ * border. See [IconButtonDefaults.outlinedIconToggleButtonVibrantBorder] and
+ * [IconButtonDefaults.outlinedIconToggleButtonBorder].
* @param interactionSource an optional hoisted [MutableInteractionSource] for observing and
* emitting [Interaction]s for this icon button. You can use this to change the icon button's
* appearance or preview the icon button in different states. Note that if `null` is provided,
@@ -863,10 +863,8 @@
modifier: Modifier = Modifier,
enabled: Boolean = true,
shape: Shape = IconButtonDefaults.outlinedShape,
- colors: IconToggleButtonColors =
- IconButtonDefaults.outlinedIconToggleButtonLocalContentColors(),
- border: BorderStroke? =
- IconButtonDefaults.outlinedIconToggleButtonLocalContentColorBorder(enabled, checked),
+ colors: IconToggleButtonColors = IconButtonDefaults.outlinedIconToggleButtonColors(),
+ border: BorderStroke? = IconButtonDefaults.outlinedIconToggleButtonBorder(enabled, checked),
interactionSource: MutableInteractionSource? = null,
content: @Composable () -> Unit
) =
@@ -906,9 +904,9 @@
* not respond to user input, and it will appear visually disabled and disabled to accessibility
* services.
* @param colors [IconToggleButtonColors] that will be used to resolve the colors used for this icon
- * button in different states. See [IconButtonDefaults.outlinedIconToggleButtonColors].
+ * button in different states. See [IconButtonDefaults.outlinedIconToggleButtonVibrantColors].
* @param border the border to draw around the container of this icon button. Pass `null` for no
- * border. See [IconButtonDefaults.outlinedIconToggleButtonBorder].
+ * border. See [IconButtonDefaults.outlinedIconToggleButtonVibrantBorder].
* @param interactionSource an optional hoisted [MutableInteractionSource] for observing and
* emitting [Interaction]s for this icon button. You can use this to change the icon button's
* appearance or preview the icon button in different states. Note that if `null` is provided,
@@ -923,8 +921,9 @@
shapes: IconButtonShapes,
modifier: Modifier = Modifier,
enabled: Boolean = true,
- colors: IconToggleButtonColors = IconButtonDefaults.outlinedIconToggleButtonColors(),
- border: BorderStroke? = IconButtonDefaults.outlinedIconToggleButtonBorder(enabled, checked),
+ colors: IconToggleButtonColors = IconButtonDefaults.outlinedIconToggleButtonVibrantColors(),
+ border: BorderStroke? =
+ IconButtonDefaults.outlinedIconToggleButtonVibrantBorder(enabled, checked),
interactionSource: MutableInteractionSource? = null,
content: @Composable () -> Unit
) =
@@ -1076,9 +1075,12 @@
/**
* Contains the default values used by [IconButton]. [LocalContentColor] will be applied to the
* icon and down the UI tree.
+ *
+ * See [iconButtonVibrantColors] for default values that applies the recommended high contrast
+ * colors.
*/
@Composable
- fun iconButtonLocalContentColors(): IconButtonColors {
+ fun iconButtonColors(): IconButtonColors {
val contentColor = LocalContentColor.current
val colors = MaterialTheme.colorScheme.defaultIconButtonColors(contentColor)
return if (colors.contentColor == contentColor) {
@@ -1093,31 +1095,29 @@
}
/**
- * Creates a [IconButtonColors] that represents the default colors used in a [IconButton]. See
- * [iconButtonLocalContentColors] for default values that applies [LocalContentColor] to the
- * icon and down the UI tree.
- */
- @Composable
- fun iconButtonColors(): IconButtonColors = MaterialTheme.colorScheme.defaultIconButtonColors()
-
- /**
* Creates a [IconButtonColors] that represents the default colors used in a [IconButton].
+ * [LocalContentColor] will be applied to the icon and down the UI tree unless a custom
+ * [contentColor] is provided.
+ *
+ * See [iconButtonVibrantColors] for default values that applies the recommended high contrast
+ * colors.
*
* @param containerColor the container color of this icon button when enabled.
- * @param contentColor the content color of this icon button when enabled.
+ * @param contentColor the content color of this icon button when enabled. By default, this will
+ * use the current LocalContentColor value.
* @param disabledContainerColor the container color of this icon button when not enabled.
* @param disabledContentColor the content color of this icon button when not enabled.
*/
@Composable
fun iconButtonColors(
containerColor: Color = Color.Unspecified,
- contentColor: Color = Color.Unspecified,
+ contentColor: Color = LocalContentColor.current,
disabledContainerColor: Color = Color.Unspecified,
disabledContentColor: Color =
contentColor.copy(alpha = StandardIconButtonTokens.DisabledOpacity)
): IconButtonColors =
MaterialTheme.colorScheme
- .defaultIconButtonColors()
+ .defaultIconButtonColors(LocalContentColor.current)
.copy(
containerColor = containerColor,
contentColor = contentColor,
@@ -1125,33 +1125,84 @@
disabledContentColor = disabledContentColor,
)
- internal fun ColorScheme.defaultIconButtonColors(
- localContentColor: Color? = null,
- ): IconButtonColors {
+ internal fun ColorScheme.defaultIconButtonColors(localContentColor: Color): IconButtonColors {
return defaultIconButtonColorsCached
?: run {
IconButtonColors(
containerColor = Color.Transparent,
- contentColor =
- localContentColor ?: fromToken(StandardIconButtonTokens.Color),
+ contentColor = localContentColor,
disabledContainerColor = Color.Transparent,
disabledContentColor =
- localContentColor?.copy(
- alpha = StandardIconButtonTokens.DisabledOpacity
- )
- ?: fromToken(StandardIconButtonTokens.DisabledColor)
- .copy(alpha = StandardIconButtonTokens.DisabledOpacity)
+ localContentColor.copy(alpha = StandardIconButtonTokens.DisabledOpacity)
)
.also { defaultIconButtonColorsCached = it }
}
}
/**
- * Creates a [IconToggleButtonColors] that represents the default colors used in a
- * [IconToggleButton]. [LocalContentColor] will be applied to the icon and down the UI tree.
+ * Creates a [IconButtonColors] that represents the recommended high contrast colors used in an
+ * [IconButton].
+ *
+ * See [iconButtonColors] for default values that applies [LocalContentColor] to the icon and
+ * down the UI tree.
*/
@Composable
- fun iconToggleButtonLocalContentColors(): IconToggleButtonColors {
+ fun iconButtonVibrantColors(): IconButtonColors =
+ MaterialTheme.colorScheme.defaultIconButtonVibrantColors()
+
+ /**
+ * Creates a [IconButtonColors] that represents the recommended high contrast colors used in an
+ * [IconButton].
+ *
+ * See [iconButtonColors] for default values that applies [LocalContentColor] to the icon and
+ * down the UI tree.
+ *
+ * @param containerColor the container color of this icon button when enabled.
+ * @param contentColor the content color of this icon button when enabled.
+ * @param disabledContainerColor the container color of this icon button when not enabled.
+ * @param disabledContentColor the content color of this icon button when not enabled.
+ */
+ @Composable
+ fun iconButtonVibrantColors(
+ containerColor: Color = Color.Unspecified,
+ contentColor: Color = Color.Unspecified,
+ disabledContainerColor: Color = Color.Unspecified,
+ disabledContentColor: Color =
+ contentColor.copy(alpha = StandardIconButtonTokens.DisabledOpacity)
+ ): IconButtonColors =
+ MaterialTheme.colorScheme
+ .defaultIconButtonVibrantColors()
+ .copy(
+ containerColor = containerColor,
+ contentColor = contentColor,
+ disabledContainerColor = disabledContainerColor,
+ disabledContentColor = disabledContentColor,
+ )
+
+ internal fun ColorScheme.defaultIconButtonVibrantColors(): IconButtonColors {
+ return defaultIconButtonVibrantColorsCached
+ ?: run {
+ IconButtonColors(
+ containerColor = Color.Transparent,
+ contentColor = fromToken(StandardIconButtonTokens.Color),
+ disabledContainerColor = Color.Transparent,
+ disabledContentColor =
+ fromToken(StandardIconButtonTokens.DisabledColor)
+ .copy(alpha = StandardIconButtonTokens.DisabledOpacity)
+ )
+ .also { defaultIconButtonVibrantColorsCached = it }
+ }
+ }
+
+ /**
+ * Creates a [IconToggleButtonColors] that represents the default colors used in a
+ * [IconToggleButton]. [LocalContentColor] will be applied to the icon and down the UI tree.
+ *
+ * See [iconToggleButtonVibrantColors] for default values that applies the recommended high
+ * contrast colors.
+ */
+ @Composable
+ fun iconToggleButtonColors(): IconToggleButtonColors {
val contentColor = LocalContentColor.current
val colors = MaterialTheme.colorScheme.defaultIconToggleButtonColors(contentColor)
if (colors.contentColor == contentColor) {
@@ -1167,16 +1218,11 @@
/**
* Creates a [IconToggleButtonColors] that represents the default colors used in a
- * [IconToggleButton]. See [iconToggleButtonLocalContentColors] for default values that applies
- * [LocalContentColor] to the icon and down the UI tree.
- */
- @Composable
- fun iconToggleButtonColors(): IconToggleButtonColors =
- MaterialTheme.colorScheme.defaultIconToggleButtonColors()
-
- /**
- * Creates a [IconToggleButtonColors] that represents the default colors used in a
- * [IconToggleButton].
+ * [IconToggleButton]. [LocalContentColor] will be applied to the icon and down the UI tree
+ * unless a custom [contentColor] is provided.
+ *
+ * See [iconToggleButtonVibrantColors] for default values that applies the recommended high
+ * contrast colors.
*
* @param containerColor the container color of this icon button when enabled.
* @param contentColor the content color of this icon button when enabled.
@@ -1188,7 +1234,7 @@
@Composable
fun iconToggleButtonColors(
containerColor: Color = Color.Unspecified,
- contentColor: Color = Color.Unspecified,
+ contentColor: Color = LocalContentColor.current,
disabledContainerColor: Color = Color.Unspecified,
disabledContentColor: Color =
contentColor.copy(alpha = StandardIconButtonTokens.DisabledOpacity),
@@ -1196,7 +1242,7 @@
checkedContentColor: Color = Color.Unspecified
): IconToggleButtonColors =
MaterialTheme.colorScheme
- .defaultIconToggleButtonColors()
+ .defaultIconToggleButtonColors(LocalContentColor.current)
.copy(
containerColor = containerColor,
contentColor = contentColor,
@@ -1207,22 +1253,18 @@
)
internal fun ColorScheme.defaultIconToggleButtonColors(
- localContentColor: Color? = null,
+ localContentColor: Color
): IconToggleButtonColors {
return defaultIconToggleButtonColorsCached
?: run {
IconToggleButtonColors(
containerColor = Color.Transparent,
- contentColor =
- localContentColor
- ?: fromToken(StandardIconButtonTokens.UnselectedColor),
+ contentColor = localContentColor,
disabledContainerColor = Color.Transparent,
disabledContentColor =
- localContentColor?.copy(
+ localContentColor.copy(
alpha = StandardIconButtonTokens.DisabledOpacity
- )
- ?: fromToken(StandardIconButtonTokens.DisabledColor)
- .copy(alpha = StandardIconButtonTokens.DisabledOpacity),
+ ),
checkedContainerColor = Color.Transparent,
checkedContentColor = fromToken(StandardIconButtonTokens.SelectedColor)
)
@@ -1231,6 +1273,67 @@
}
/**
+ * Creates a [IconToggleButtonColors] that represents the recommended high contrast colors used
+ * in a [IconToggleButton]. See [iconToggleButtonColors] for default values that applies
+ * [LocalContentColor] to the icon and down the UI tree.
+ */
+ @Composable
+ fun iconToggleButtonVibrantColors(): IconToggleButtonColors =
+ MaterialTheme.colorScheme.defaultIconToggleButtonVibrantColors()
+
+ /**
+ * Creates a [IconToggleButtonColors] that represents the recommended high contrast colors used
+ * in a [IconToggleButton].
+ *
+ * See [iconToggleButtonColors] for default values that applies [LocalContentColor] to the icon
+ * and down the UI tree.
+ *
+ * @param containerColor the container color of this icon button when enabled.
+ * @param contentColor the content color of this icon button when enabled.
+ * @param disabledContainerColor the container color of this icon button when not enabled.
+ * @param disabledContentColor the content color of this icon button when not enabled.
+ * @param checkedContainerColor the container color of this icon button when checked.
+ * @param checkedContentColor the content color of this icon button when checked.
+ */
+ @Composable
+ fun iconToggleButtonVibrantColors(
+ containerColor: Color = Color.Unspecified,
+ contentColor: Color = Color.Unspecified,
+ disabledContainerColor: Color = Color.Unspecified,
+ disabledContentColor: Color =
+ contentColor.copy(alpha = StandardIconButtonTokens.DisabledOpacity),
+ checkedContainerColor: Color = Color.Unspecified,
+ checkedContentColor: Color = Color.Unspecified
+ ): IconToggleButtonColors =
+ MaterialTheme.colorScheme
+ .defaultIconToggleButtonVibrantColors()
+ .copy(
+ containerColor = containerColor,
+ contentColor = contentColor,
+ disabledContainerColor = disabledContainerColor,
+ disabledContentColor = disabledContentColor,
+ checkedContainerColor = checkedContainerColor,
+ checkedContentColor = checkedContentColor,
+ )
+
+ internal fun ColorScheme.defaultIconToggleButtonVibrantColors(): IconToggleButtonColors {
+ return defaultIconToggleButtonVibrantColorsCached
+ ?: run {
+ IconToggleButtonColors(
+ containerColor = Color.Transparent,
+ contentColor = fromToken(StandardIconButtonTokens.UnselectedColor),
+ disabledContainerColor = Color.Transparent,
+ disabledContentColor =
+ fromToken(StandardIconButtonTokens.DisabledColor)
+ .copy(alpha = StandardIconButtonTokens.DisabledOpacity),
+ checkedContainerColor = Color.Transparent,
+ checkedContentColor = fromToken(StandardIconButtonTokens.SelectedColor)
+ )
+ .also { defaultIconToggleButtonVibrantColorsCached = it }
+ }
+ }
+
+ /**
* Creates a [IconButtonColors] that represents the default colors used in a [FilledIconButton].
*/
@Composable
@@ -1443,9 +1546,12 @@
/**
* Creates a [IconButtonColors] that represents the default colors used in a
* [OutlinedIconButton]. [LocalContentColor] will be applied to the icon and down the UI tree.
+ *
+ * See [outlinedIconButtonVibrantColors] for default values that applies the recommended high
+ * contrast colors.
*/
@Composable
- fun outlinedIconButtonLocalContentColors(): IconButtonColors {
+ fun outlinedIconButtonColors(): IconButtonColors {
val contentColor = LocalContentColor.current
val colors = MaterialTheme.colorScheme.defaultOutlinedIconButtonColors(contentColor)
if (colors.contentColor == contentColor) {
@@ -1463,16 +1569,8 @@
* Creates a [IconButtonColors] that represents the default colors used in a
* [OutlinedIconButton].
*
- * See [outlinedIconButtonLocalContentColors] for default values that applies
- * [LocalContentColor] to the icon and down the UI tree.
- */
- @Composable
- fun outlinedIconButtonColors(): IconButtonColors =
- MaterialTheme.colorScheme.defaultOutlinedIconButtonColors()
-
- /**
- * Creates a [IconButtonColors] that represents the default colors used in a
- * [OutlinedIconButton].
+ * See [outlinedIconButtonVibrantColors] for default values that applies the recommended high
+ * contrast colors.
*
* @param containerColor the container color of this icon button when enabled.
* @param contentColor the content color of this icon button when enabled.
@@ -1482,13 +1580,13 @@
@Composable
fun outlinedIconButtonColors(
containerColor: Color = Color.Unspecified,
- contentColor: Color = Color.Unspecified,
+ contentColor: Color = LocalContentColor.current,
disabledContainerColor: Color = Color.Unspecified,
disabledContentColor: Color =
contentColor.copy(alpha = OutlinedIconButtonTokens.DisabledOpacity)
): IconButtonColors =
MaterialTheme.colorScheme
- .defaultOutlinedIconButtonColors()
+ .defaultOutlinedIconButtonColors(LocalContentColor.current)
.copy(
containerColor = containerColor,
contentColor = contentColor,
@@ -1497,33 +1595,86 @@
)
internal fun ColorScheme.defaultOutlinedIconButtonColors(
- localContentColor: Color? = null
+ localContentColor: Color
): IconButtonColors {
return defaultOutlinedIconButtonColorsCached
?: run {
IconButtonColors(
containerColor = Color.Transparent,
- contentColor =
- localContentColor ?: fromToken(OutlinedIconButtonTokens.Color),
+ contentColor = localContentColor,
disabledContainerColor = Color.Transparent,
disabledContentColor =
- localContentColor?.copy(
- alpha = OutlinedIconButtonTokens.DisabledOpacity
- )
- ?: fromToken(OutlinedIconButtonTokens.DisabledColor)
- .copy(alpha = OutlinedIconButtonTokens.DisabledOpacity)
+ localContentColor.copy(alpha = OutlinedIconButtonTokens.DisabledOpacity)
)
.also { defaultOutlinedIconButtonColorsCached = it }
}
}
/**
+ * Creates a [IconButtonColors] that represents the default colors used in a
+ * [OutlinedIconButton].
+ *
+ * See [outlinedIconButtonColors] for default values that applies [LocalContentColor] to the
+ * icon and down the UI tree.
+ */
+ @Composable
+ fun outlinedIconButtonVibrantColors(): IconButtonColors =
+ MaterialTheme.colorScheme.defaultOutlinedIconButtonVibrantColors()
+
+ /**
+ * Creates a [IconButtonColors] that represents the default colors used in a
+ * [OutlinedIconButton].
+ *
+ * See [outlinedIconButtonColors] for default values that applies [LocalContentColor] to the
+ * icon and down the UI tree.
+ *
+ * @param containerColor the container color of this icon button when enabled.
+ * @param contentColor the content color of this icon button when enabled.
+ * @param disabledContainerColor the container color of this icon button when not enabled.
+ * @param disabledContentColor the content color of this icon button when not enabled.
+ */
+ @Composable
+ fun outlinedIconButtonVibrantColors(
+ containerColor: Color = Color.Unspecified,
+ contentColor: Color = Color.Unspecified,
+ disabledContainerColor: Color = Color.Unspecified,
+ disabledContentColor: Color =
+ contentColor.copy(alpha = OutlinedIconButtonTokens.DisabledOpacity)
+ ): IconButtonColors =
+ MaterialTheme.colorScheme
+ .defaultOutlinedIconButtonVibrantColors()
+ .copy(
+ containerColor = containerColor,
+ contentColor = contentColor,
+ disabledContainerColor = disabledContainerColor,
+ disabledContentColor = disabledContentColor,
+ )
+
+ internal fun ColorScheme.defaultOutlinedIconButtonVibrantColors(): IconButtonColors {
+ return defaultOutlinedIconButtonVibrantColorsCached
+ ?: run {
+ IconButtonColors(
+ containerColor = Color.Transparent,
+ contentColor = fromToken(OutlinedIconButtonTokens.Color),
+ disabledContainerColor = Color.Transparent,
+ disabledContentColor =
+ fromToken(OutlinedIconButtonTokens.DisabledColor)
+ .copy(alpha = OutlinedIconButtonTokens.DisabledOpacity)
+ )
+ .also { defaultOutlinedIconButtonVibrantColorsCached = it }
+ }
+ }
+
+ /**
* Creates a [IconToggleButtonColors] that represents the default colors used in a
* [OutlinedIconToggleButton]. [LocalContentColor] will be applied to the icon and down the UI
* tree.
+ *
+ * See [outlinedIconButtonVibrantColors] for default values that applies the recommended high
+ * contrast colors.
*/
@Composable
- fun outlinedIconToggleButtonLocalContentColors(): IconToggleButtonColors {
+ fun outlinedIconToggleButtonColors(): IconToggleButtonColors {
val contentColor = LocalContentColor.current
val colors = MaterialTheme.colorScheme.defaultOutlinedIconToggleButtonColors(contentColor)
if (colors.contentColor == contentColor) {
@@ -1539,18 +1690,11 @@
/**
* Creates a [IconToggleButtonColors] that represents the default colors used in a
- * [OutlinedIconToggleButton].
+ * [OutlinedIconToggleButton]. [LocalContentColor] will be applied to the icon and down the UI
+ * tree.
*
- * See [outlinedIconToggleButtonLocalContentColors] for default values that applies
- * [LocalContentColor] to the icon and down the UI tree.
- */
- @Composable
- fun outlinedIconToggleButtonColors(): IconToggleButtonColors =
- MaterialTheme.colorScheme.defaultOutlinedIconToggleButtonColors()
-
- /**
- * Creates a [IconToggleButtonColors] that represents the default colors used in a
- * [OutlinedIconToggleButton].
+ * See [outlinedIconButtonVibrantColors] for default values that applies the recommended high
+ * contrast colors.
*
* @param containerColor the container color of this icon button when enabled.
* @param contentColor the content color of this icon button when enabled.
@@ -1562,7 +1706,7 @@
@Composable
fun outlinedIconToggleButtonColors(
containerColor: Color = Color.Unspecified,
- contentColor: Color = Color.Unspecified,
+ contentColor: Color = LocalContentColor.current,
disabledContainerColor: Color = Color.Unspecified,
disabledContentColor: Color =
contentColor.copy(alpha = OutlinedIconButtonTokens.DisabledOpacity),
@@ -1570,7 +1714,7 @@
checkedContentColor: Color = contentColorFor(checkedContainerColor)
): IconToggleButtonColors =
MaterialTheme.colorScheme
- .defaultOutlinedIconToggleButtonColors()
+ .defaultOutlinedIconToggleButtonColors(LocalContentColor.current)
.copy(
containerColor = containerColor,
contentColor = contentColor,
@@ -1581,22 +1725,86 @@
)
internal fun ColorScheme.defaultOutlinedIconToggleButtonColors(
- localContentColor: Color? = null
+ localContentColor: Color
): IconToggleButtonColors {
- return defaultOutlinedIconToggleButtonColorsCached
+ return defaultIconToggleButtonColorsCached
?: run {
IconToggleButtonColors(
containerColor = Color.Transparent,
- contentColor =
- localContentColor
- ?: fromToken(OutlinedIconButtonTokens.UnselectedColor),
+ contentColor = localContentColor,
disabledContainerColor = Color.Transparent,
disabledContentColor =
- localContentColor?.copy(
+ localContentColor.copy(
alpha = OutlinedIconButtonTokens.DisabledOpacity
+ ),
+ checkedContainerColor =
+ fromToken(OutlinedIconButtonTokens.SelectedContainerColor),
+ checkedContentColor =
+ contentColorFor(
+ fromToken(OutlinedIconButtonTokens.SelectedContainerColor)
)
- ?: fromToken(OutlinedIconButtonTokens.DisabledColor)
- .copy(alpha = OutlinedIconButtonTokens.DisabledOpacity),
+ )
+ .also { defaultOutlinedIconToggleButtonColorsCached = it }
+ }
+ }
+
+ /**
+ * Creates a [IconToggleButtonColors] that represents the default colors used in a
+ * [OutlinedIconToggleButton].
+ *
+ * See [outlinedIconToggleButtonColors] for default values that applies [LocalContentColor] to
+ * the icon and down the UI tree.
+ */
+ @Composable
+ fun outlinedIconToggleButtonVibrantColors(): IconToggleButtonColors =
+ MaterialTheme.colorScheme.defaultOutlinedIconToggleButtonVibrantColors()
+
+ /**
+ * Creates a [IconToggleButtonColors] that represents the default colors used in a
+ * [OutlinedIconToggleButton].
+ *
+ * See [outlinedIconToggleButtonColors] for default values that applies [LocalContentColor] to
+ * the icon and down the UI tree.
+ *
+ * @param containerColor the container color of this icon button when enabled.
+ * @param contentColor the content color of this icon button when enabled.
+ * @param disabledContainerColor the container color of this icon button when not enabled.
+ * @param disabledContentColor the content color of this icon button when not enabled.
+ * @param checkedContainerColor the container color of this icon button when checked.
+ * @param checkedContentColor the content color of this icon button when checked.
+ */
+ @Composable
+ fun outlinedIconToggleButtonVibrantColors(
+ containerColor: Color = Color.Unspecified,
+ contentColor: Color = Color.Unspecified,
+ disabledContainerColor: Color = Color.Unspecified,
+ disabledContentColor: Color =
+ contentColor.copy(alpha = OutlinedIconButtonTokens.DisabledOpacity),
+ checkedContainerColor: Color = Color.Unspecified,
+ checkedContentColor: Color = contentColorFor(checkedContainerColor)
+ ): IconToggleButtonColors =
+ MaterialTheme.colorScheme
+ .defaultOutlinedIconToggleButtonVibrantColors()
+ .copy(
+ containerColor = containerColor,
+ contentColor = contentColor,
+ disabledContainerColor = disabledContainerColor,
+ disabledContentColor = disabledContentColor,
+ checkedContainerColor = checkedContainerColor,
+ checkedContentColor = checkedContentColor,
+ )
+
+ internal fun ColorScheme.defaultOutlinedIconToggleButtonVibrantColors():
+ IconToggleButtonColors {
+ return defaultOutlinedIconToggleButtonVibrantColorsCached
+ ?: run {
+ IconToggleButtonColors(
+ containerColor = Color.Transparent,
+ contentColor = fromToken(OutlinedIconButtonTokens.UnselectedColor),
+ disabledContainerColor = Color.Transparent,
+ disabledContentColor =
+ fromToken(OutlinedIconButtonTokens.DisabledColor)
+ .copy(alpha = OutlinedIconButtonTokens.DisabledOpacity),
checkedContainerColor =
fromToken(OutlinedIconButtonTokens.SelectedContainerColor),
checkedContentColor = fromToken(OutlinedIconButtonTokens.SelectedColor)
@@ -1607,25 +1815,10 @@
/**
* Represents the [BorderStroke] for an [OutlinedIconButton], depending on its [enabled] and
- * [checked] state.
+ * [checked] state. [LocalContentColor] will be used as the border color.
*
- * @param enabled whether the icon button is enabled
- * @param checked whether the icon button is checked
- */
- @Composable
- fun outlinedIconToggleButtonLocalContentColorBorder(
- enabled: Boolean,
- checked: Boolean
- ): BorderStroke? {
- if (checked) {
- return null
- }
- return outlinedIconButtonLocalContentColorBorder(enabled)
- }
-
- /**
- * Represents the [BorderStroke] for an [OutlinedIconButton], depending on its [enabled] and
- * [checked] state.
+ * See [outlinedIconToggleButtonVibrantBorder] for a [BorderStroke] that uses the spec
+ * recommended color as the border color.
*
* @param enabled whether the icon button is enabled
* @param checked whether the icon button is checked
@@ -1638,8 +1831,32 @@
return outlinedIconButtonBorder(enabled)
}
+ /**
+ * Represents the [BorderStroke] for an [OutlinedIconButton], depending on its [enabled] and
+ * [checked] state. The spec recommended color will be used as the border color.
+ *
+ * @param enabled whether the icon button is enabled
+ * @param checked whether the icon button is checked
+ */
@Composable
- fun outlinedIconButtonLocalContentColorBorder(enabled: Boolean): BorderStroke? {
+ fun outlinedIconToggleButtonVibrantBorder(enabled: Boolean, checked: Boolean): BorderStroke? {
+ if (checked) {
+ return null
+ }
+ return outlinedIconButtonVibrantBorder(enabled)
+ }
+
+ /**
+ * Represents the [BorderStroke] for an [OutlinedIconButton], depending on its [enabled] state.
+ * [LocalContentColor] will be used as the border color.
+ *
+ * See [outlinedIconToggleButtonVibrantBorder] for a [BorderStroke] that uses the spec
+ * recommended color as the border color.
+ *
+ * @param enabled whether the icon button is enabled
+ */
+ @Composable
+ fun outlinedIconButtonBorder(enabled: Boolean): BorderStroke {
val outlineColor = LocalContentColor.current
val color: Color =
if (enabled) {
@@ -1652,11 +1869,12 @@
/**
* Represents the [BorderStroke] for an [OutlinedIconButton], depending on its [enabled] state.
+ * The spec recommended color will be used as the border color.
*
* @param enabled whether the icon button is enabled
*/
@Composable
- fun outlinedIconButtonBorder(enabled: Boolean): BorderStroke {
+ fun outlinedIconButtonVibrantBorder(enabled: Boolean): BorderStroke {
val outlineColor = OutlinedIconButtonTokens.OutlineColor.value
val color: Color =
if (enabled) {
@@ -2010,7 +2228,7 @@
* - See [IconButtonDefaults.filledIconButtonColors] and
* [IconButtonDefaults.filledTonalIconButtonColors] for the default colors used in a
* [FilledIconButton].
- * - See [IconButtonDefaults.outlinedIconButtonColors] for the default colors used in an
+ * - See [IconButtonDefaults.outlinedIconButtonVibrantColors] for the default colors used in an
* [OutlinedIconButton].
*/
@Immutable
@@ -2091,7 +2309,7 @@
* - See [IconButtonDefaults.filledIconToggleButtonColors] and
* [IconButtonDefaults.filledTonalIconToggleButtonColors] for the default colors used in a
* [FilledIconButton].
- * - See [IconButtonDefaults.outlinedIconToggleButtonColors] for the default colors used in a
+ * - See [IconButtonDefaults.outlinedIconToggleButtonVibrantColors] for the default colors used in a
* toggleable [OutlinedIconButton].
*/
@Immutable
diff --git a/compose/runtime/runtime/api/current.ignore b/compose/runtime/runtime/api/current.ignore
index 7b50ec7..75715a1 100644
--- a/compose/runtime/runtime/api/current.ignore
+++ b/compose/runtime/runtime/api/current.ignore
@@ -1,6 +1,6 @@
// Baseline format: 1.0
-AddedAbstractMethod: androidx.compose.runtime.ControlledComposition#setShouldPauseCallback(kotlin.jvm.functions.Function0<java.lang.Boolean>):
- Added method androidx.compose.runtime.ControlledComposition.setShouldPauseCallback(kotlin.jvm.functions.Function0<java.lang.Boolean>)
+AddedAbstractMethod: androidx.compose.runtime.ControlledComposition#getAndSetShouldPauseCallback(androidx.compose.runtime.ShouldPauseCallback):
+ Added method androidx.compose.runtime.ControlledComposition.getAndSetShouldPauseCallback(androidx.compose.runtime.ShouldPauseCallback)
BecameUnchecked: androidx.compose.runtime.Composer#compoundKeyHash:
diff --git a/compose/runtime/runtime/api/current.txt b/compose/runtime/runtime/api/current.txt
index 11a7666..e5eee1c 100644
--- a/compose/runtime/runtime/api/current.txt
+++ b/compose/runtime/runtime/api/current.txt
@@ -279,6 +279,7 @@
method public void composeContent(kotlin.jvm.functions.Function0<kotlin.Unit> content);
method public <R> R delegateInvalidations(androidx.compose.runtime.ControlledComposition? to, int groupIndex, kotlin.jvm.functions.Function0<? extends R> block);
method @SuppressCompatibility @androidx.compose.runtime.InternalComposeApi public void disposeUnusedMovableContent(androidx.compose.runtime.MovableContentState state);
+ method public androidx.compose.runtime.ShouldPauseCallback? getAndSetShouldPauseCallback(androidx.compose.runtime.ShouldPauseCallback? shouldPause);
method public boolean getHasPendingChanges();
method @SuppressCompatibility @androidx.compose.runtime.InternalComposeApi public void insertMovableContent(java.util.List<kotlin.Pair<androidx.compose.runtime.MovableContentStateReference,androidx.compose.runtime.MovableContentStateReference?>> references);
method public void invalidateAll();
@@ -289,7 +290,6 @@
method public void recordModificationsOf(java.util.Set<?> values);
method public void recordReadOf(Object value);
method public void recordWriteOf(Object value);
- method public kotlin.jvm.functions.Function0<java.lang.Boolean>? setShouldPauseCallback(kotlin.jvm.functions.Function0<java.lang.Boolean>? shouldPause);
method @SuppressCompatibility @androidx.compose.runtime.InternalComposeApi public void verifyConsistent();
property public abstract boolean hasPendingChanges;
property public abstract boolean isComposing;
@@ -463,7 +463,7 @@
@kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.SOURCE) @kotlin.annotation.Target(allowedTargets={kotlin.annotation.AnnotationTarget.FUNCTION, kotlin.annotation.AnnotationTarget.PROPERTY_GETTER}) public @interface NonSkippableComposable {
}
- public interface PausableComposition extends androidx.compose.runtime.ReusableComposition {
+ public sealed interface PausableComposition extends androidx.compose.runtime.ReusableComposition {
method public androidx.compose.runtime.PausedComposition setPausableContent(kotlin.jvm.functions.Function0<kotlin.Unit> content);
method public androidx.compose.runtime.PausedComposition setPausableContentWithReuse(kotlin.jvm.functions.Function0<kotlin.Unit> content);
}
@@ -481,11 +481,11 @@
property public final boolean isPaused;
}
- public interface PausedComposition {
+ public sealed interface PausedComposition {
method public void apply();
method public void cancel();
method public boolean isComplete();
- method public boolean resume(kotlin.jvm.functions.Function0<java.lang.Boolean> shouldPause);
+ method public boolean resume(androidx.compose.runtime.ShouldPauseCallback shouldPause);
property public abstract boolean isComplete;
}
@@ -587,6 +587,10 @@
method public void updateScope(kotlin.jvm.functions.Function2<? super androidx.compose.runtime.Composer,? super java.lang.Integer,kotlin.Unit> block);
}
+ public fun interface ShouldPauseCallback {
+ method public boolean shouldPause();
+ }
+
@kotlin.jvm.JvmInline public final value class SkippableUpdater<T> {
ctor public SkippableUpdater(@kotlin.PublishedApi androidx.compose.runtime.Composer composer);
method public inline void update(kotlin.jvm.functions.Function1<? super androidx.compose.runtime.Updater<T>,kotlin.Unit> block);
diff --git a/compose/runtime/runtime/api/restricted_current.ignore b/compose/runtime/runtime/api/restricted_current.ignore
index 7b50ec7..75715a1 100644
--- a/compose/runtime/runtime/api/restricted_current.ignore
+++ b/compose/runtime/runtime/api/restricted_current.ignore
@@ -1,6 +1,6 @@
// Baseline format: 1.0
-AddedAbstractMethod: androidx.compose.runtime.ControlledComposition#setShouldPauseCallback(kotlin.jvm.functions.Function0<java.lang.Boolean>):
- Added method androidx.compose.runtime.ControlledComposition.setShouldPauseCallback(kotlin.jvm.functions.Function0<java.lang.Boolean>)
+AddedAbstractMethod: androidx.compose.runtime.ControlledComposition#getAndSetShouldPauseCallback(androidx.compose.runtime.ShouldPauseCallback):
+ Added method androidx.compose.runtime.ControlledComposition.getAndSetShouldPauseCallback(androidx.compose.runtime.ShouldPauseCallback)
BecameUnchecked: androidx.compose.runtime.Composer#compoundKeyHash:
diff --git a/compose/runtime/runtime/api/restricted_current.txt b/compose/runtime/runtime/api/restricted_current.txt
index 15f6290..2ebdc05 100644
--- a/compose/runtime/runtime/api/restricted_current.txt
+++ b/compose/runtime/runtime/api/restricted_current.txt
@@ -319,6 +319,7 @@
method public void composeContent(kotlin.jvm.functions.Function0<kotlin.Unit> content);
method public <R> R delegateInvalidations(androidx.compose.runtime.ControlledComposition? to, int groupIndex, kotlin.jvm.functions.Function0<? extends R> block);
method @SuppressCompatibility @androidx.compose.runtime.InternalComposeApi public void disposeUnusedMovableContent(androidx.compose.runtime.MovableContentState state);
+ method public androidx.compose.runtime.ShouldPauseCallback? getAndSetShouldPauseCallback(androidx.compose.runtime.ShouldPauseCallback? shouldPause);
method public boolean getHasPendingChanges();
method @SuppressCompatibility @androidx.compose.runtime.InternalComposeApi public void insertMovableContent(java.util.List<kotlin.Pair<androidx.compose.runtime.MovableContentStateReference,androidx.compose.runtime.MovableContentStateReference?>> references);
method public void invalidateAll();
@@ -329,7 +330,6 @@
method public void recordModificationsOf(java.util.Set<?> values);
method public void recordReadOf(Object value);
method public void recordWriteOf(Object value);
- method public kotlin.jvm.functions.Function0<java.lang.Boolean>? setShouldPauseCallback(kotlin.jvm.functions.Function0<java.lang.Boolean>? shouldPause);
method @SuppressCompatibility @androidx.compose.runtime.InternalComposeApi public void verifyConsistent();
property public abstract boolean hasPendingChanges;
property public abstract boolean isComposing;
@@ -504,7 +504,7 @@
@kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.SOURCE) @kotlin.annotation.Target(allowedTargets={kotlin.annotation.AnnotationTarget.FUNCTION, kotlin.annotation.AnnotationTarget.PROPERTY_GETTER}) public @interface NonSkippableComposable {
}
- public interface PausableComposition extends androidx.compose.runtime.ReusableComposition {
+ public sealed interface PausableComposition extends androidx.compose.runtime.ReusableComposition {
method public androidx.compose.runtime.PausedComposition setPausableContent(kotlin.jvm.functions.Function0<kotlin.Unit> content);
method public androidx.compose.runtime.PausedComposition setPausableContentWithReuse(kotlin.jvm.functions.Function0<kotlin.Unit> content);
}
@@ -522,11 +522,11 @@
property public final boolean isPaused;
}
- public interface PausedComposition {
+ public sealed interface PausedComposition {
method public void apply();
method public void cancel();
method public boolean isComplete();
- method public boolean resume(kotlin.jvm.functions.Function0<java.lang.Boolean> shouldPause);
+ method public boolean resume(androidx.compose.runtime.ShouldPauseCallback shouldPause);
property public abstract boolean isComplete;
}
@@ -632,6 +632,10 @@
method public void updateScope(kotlin.jvm.functions.Function2<? super androidx.compose.runtime.Composer,? super java.lang.Integer,kotlin.Unit> block);
}
+ public fun interface ShouldPauseCallback {
+ method public boolean shouldPause();
+ }
+
@kotlin.jvm.JvmInline public final value class SkippableUpdater<T> {
ctor public SkippableUpdater(@kotlin.PublishedApi androidx.compose.runtime.Composer composer);
method public inline void update(kotlin.jvm.functions.Function1<? super androidx.compose.runtime.Updater<T>,kotlin.Unit> block);
diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Composer.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Composer.kt
index 27bad04..5216ba1 100644
--- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Composer.kt
+++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Composer.kt
@@ -993,6 +993,18 @@
* not execute if parameter has not changed and the nothing else is forcing the function to
* execute (such as its scope was invalidated or a static composition local it was changed) or
* the composition is pausable and the composition is pausing.
+ *
+ * @param parametersChanged `true` if the parameters to the composable function have changed.
+ * This is also `true` if the composition is [inserting] or if content is being reused.
+ * @param flags The `$changed` parameter that contains the forced recompose bit to allow the
+ * composer to disambiguate when the parameters changed due the execution being forced or if
+ * the parameters actually changed. This is only ambiguous in a [PausableComposition] and is
+ * necessary to determine if the function can be paused. The bits, other than 0, are reserved
+ * for future use (which would required the bit 31, which is unused in `$changed` values, to
+ * be set to indicate that the flags carry additional information). Passing the `$changed`
+ * flags directly, instead of masking the 0 bit, is more efficient as it allows less code to
+ * be generated per call to `shouldExecute` which is every called in every restartable
+ * function, as well as allowing for the API to be extended without a breaking changed.
*/
@InternalComposeApi fun shouldExecute(parametersChanged: Boolean, flags: Int): Boolean
@@ -1366,7 +1378,7 @@
private var insertFixups = FixupList()
private var pausable: Boolean = false
- private var shouldPauseCallback: (() -> Boolean)? = null
+ private var shouldPauseCallback: ShouldPauseCallback? = null
override val applyCoroutineContext: CoroutineContext
@TestOnly get() = parentContext.effectCoroutineContext
@@ -3082,7 +3094,7 @@
if (((flags and 1) == 0) && (inserting || reusing)) {
val callback = shouldPauseCallback ?: return true
val scope = currentRecomposeScope ?: return true
- val pausing = callback()
+ val pausing = callback.shouldPause()
if (pausing) {
scope.used = true
// Force the composer back into the reusing state when this scope restarts.
@@ -3514,7 +3526,7 @@
internal fun composeContent(
invalidationsRequested: ScopeMap<RecomposeScopeImpl, Any>,
content: @Composable () -> Unit,
- shouldPause: (() -> Boolean)?
+ shouldPause: ShouldPauseCallback?
) {
runtimeCheck(changes.isEmpty()) { "Expected applyChanges() to have been called" }
this.shouldPauseCallback = shouldPause
@@ -3541,7 +3553,7 @@
*/
internal fun recompose(
invalidationsRequested: ScopeMap<RecomposeScopeImpl, Any>,
- shouldPause: (() -> Boolean)?
+ shouldPause: ShouldPauseCallback?
): Boolean {
runtimeCheck(changes.isEmpty()) { "Expected applyChanges() to have been called" }
// even if invalidationsRequested is empty we still need to recompose if the Composer has
@@ -3905,14 +3917,14 @@
override fun composeInitialPaused(
composition: ControlledComposition,
- shouldPause: () -> Boolean,
+ shouldPause: ShouldPauseCallback,
content: @Composable () -> Unit
): ScatterSet<RecomposeScopeImpl> =
parentContext.composeInitialPaused(composition, shouldPause, content)
override fun recomposePaused(
composition: ControlledComposition,
- shouldPause: () -> Boolean,
+ shouldPause: ShouldPauseCallback,
invalidScopes: ScatterSet<RecomposeScopeImpl>
): ScatterSet<RecomposeScopeImpl> =
parentContext.recomposePaused(composition, shouldPause, invalidScopes)
diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Composition.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Composition.kt
index c764476..5938406 100644
--- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Composition.kt
+++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Composition.kt
@@ -297,19 +297,20 @@
* longer needed.
* @see PausableComposition
*/
- fun setShouldPauseCallback(shouldPause: (() -> Boolean)?): (() -> Boolean)?
+ @Suppress("ExecutorRegistration")
+ fun getAndSetShouldPauseCallback(shouldPause: ShouldPauseCallback?): ShouldPauseCallback?
}
/** Utility function to set and restore a should pause callback. */
internal inline fun <R> ControlledComposition.pausable(
- noinline shouldPause: () -> Boolean,
+ shouldPause: ShouldPauseCallback,
block: () -> R
): R {
- val previous = setShouldPauseCallback(shouldPause)
+ val previous = getAndSetShouldPauseCallback(shouldPause)
return try {
block()
} finally {
- setShouldPauseCallback(previous)
+ getAndSetShouldPauseCallback(previous)
}
}
@@ -551,7 +552,7 @@
* If the [shouldPause] callback is set the composition is pausable and should pause whenever
* the [shouldPause] callback returns `true`.
*/
- private var shouldPause: (() -> Boolean)? = null
+ private var shouldPause: ShouldPauseCallback? = null
private var pendingPausedComposition: PausedCompositionImpl? = null
@@ -1152,7 +1153,9 @@
} else block()
}
- override fun setShouldPauseCallback(shouldPause: (() -> Boolean)?): (() -> Boolean)? {
+ override fun getAndSetShouldPauseCallback(
+ shouldPause: ShouldPauseCallback?
+ ): ShouldPauseCallback? {
val previous = this.shouldPause
this.shouldPause = shouldPause
return previous
diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/CompositionContext.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/CompositionContext.kt
index e5b6d6b..8f4bcb7 100644
--- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/CompositionContext.kt
+++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/CompositionContext.kt
@@ -55,13 +55,13 @@
internal abstract fun composeInitialPaused(
composition: ControlledComposition,
- shouldPause: () -> Boolean,
+ shouldPause: ShouldPauseCallback,
content: @Composable () -> Unit
): ScatterSet<RecomposeScopeImpl>
internal abstract fun recomposePaused(
composition: ControlledComposition,
- shouldPause: () -> Boolean,
+ shouldPause: ShouldPauseCallback,
invalidScopes: ScatterSet<RecomposeScopeImpl>
): ScatterSet<RecomposeScopeImpl>
diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/PausableComposition.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/PausableComposition.kt
index 0eff8d6..b02efd9 100644
--- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/PausableComposition.kt
+++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/PausableComposition.kt
@@ -48,7 +48,7 @@
* @see Composition
* @see ReusableComposition
*/
-interface PausableComposition : ReusableComposition {
+sealed interface PausableComposition : ReusableComposition {
/**
* Set the content of the composition. A [PausedComposition] that is currently paused. No
* composition is performed until [PausedComposition.resume] is called.
@@ -74,6 +74,17 @@
fun setPausableContentWithReuse(content: @Composable () -> Unit): PausedComposition
}
+/** The callback type used in [PausedComposition.resume]. */
+fun interface ShouldPauseCallback {
+ /**
+ * Called to determine if a resumed [PausedComposition] should pause.
+ *
+ * @return Return `true` to indicate that the composition should pause. Otherwise the
+ * composition will continue normally.
+ */
+ @Suppress("CallbackMethodName") fun shouldPause(): Boolean
+}
+
/**
* [PausedComposition] is the result of calling [PausableComposition.setContent] or
* [PausableComposition.setContentWithReuse]. It is used to drive the paused composition to
@@ -83,7 +94,7 @@
* A [PausedComposition] is created paused and will only compose the `content` parameter when
* [resume] is called the first time.
*/
-interface PausedComposition {
+sealed interface PausedComposition {
/**
* Returns `true` when the [PausedComposition] is complete. [isComplete] matches the last value
* returned from [resume]. Once a [PausedComposition] is [isComplete] the [apply] method should
@@ -109,7 +120,7 @@
* @return `true` if the composition is complete and `false` if one or more calls to `resume`
* are required to complete composition.
*/
- fun resume(shouldPause: () -> Boolean): Boolean
+ @Suppress("ExecutorRegistration") fun resume(shouldPause: ShouldPauseCallback): Boolean
/**
* Apply the composition. This is the last step of a paused composition and is required to be
@@ -164,7 +175,7 @@
override val isComplete: Boolean
get() = state >= PausedCompositionState.ApplyPending
- override fun resume(shouldPause: () -> Boolean): Boolean {
+ override fun resume(shouldPause: ShouldPauseCallback): Boolean {
try {
when (state) {
PausedCompositionState.InitialPending -> {
diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Recomposer.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Recomposer.kt
index f5309e8..8cd4e28 100644
--- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Recomposer.kt
+++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Recomposer.kt
@@ -1163,7 +1163,7 @@
internal override fun composeInitialPaused(
composition: ControlledComposition,
- shouldPause: () -> Boolean,
+ shouldPause: ShouldPauseCallback,
content: @Composable () -> Unit
): ScatterSet<RecomposeScopeImpl> {
return try {
@@ -1178,7 +1178,7 @@
internal override fun recomposePaused(
composition: ControlledComposition,
- shouldPause: () -> Boolean,
+ shouldPause: ShouldPauseCallback,
invalidScopes: ScatterSet<RecomposeScopeImpl>
): ScatterSet<RecomposeScopeImpl> {
return try {
diff --git a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/platform/AndroidTextPaint.android.kt b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/platform/AndroidTextPaint.android.kt
index 45c3009..469ac38 100644
--- a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/platform/AndroidTextPaint.android.kt
+++ b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/platform/AndroidTextPaint.android.kt
@@ -64,6 +64,14 @@
@VisibleForTesting internal var shadow: Shadow = Shadow.None
+ /**
+ * Different than other backing properties, this variable only exists to enable easy comparison
+ * between the last set color and the new color that is going to be set. Color conversion from
+ * Compose to platform color primitive integer is expensive, so it is more efficient to skip
+ * this conversion if the color is not going to change at all.
+ */
+ private var lastColor: Color? = null
+
@VisibleForTesting internal var brush: Brush? = null
internal var shaderState: State<Shader?>? = null
@@ -99,7 +107,8 @@
}
fun setColor(color: Color) {
- if (color.isSpecified) {
+ if (lastColor != color && color.isSpecified) {
+ this.lastColor = color
this.color = color.toArgb()
clearShader()
}
@@ -132,6 +141,7 @@
}
}
composePaint.shader = this.shaderState?.value
+ this.lastColor = null
setAlpha(alpha)
}
}
diff --git a/compose/ui/ui/api/current.txt b/compose/ui/ui/api/current.txt
index 7d35139..261fc8c 100644
--- a/compose/ui/ui/api/current.txt
+++ b/compose/ui/ui/api/current.txt
@@ -2067,6 +2067,7 @@
public final class PointerIconKt {
method @androidx.compose.runtime.Stable public static androidx.compose.ui.Modifier pointerHoverIcon(androidx.compose.ui.Modifier, androidx.compose.ui.input.pointer.PointerIcon icon, optional boolean overrideDescendants);
+ method public static androidx.compose.ui.Modifier stylusHoverIcon(androidx.compose.ui.Modifier, androidx.compose.ui.input.pointer.PointerIcon icon, optional boolean overrideDescendants, optional androidx.compose.ui.node.DpTouchBoundsExpansion? touchBoundsExpansion);
}
public final class PointerIcon_androidKt {
@@ -2860,6 +2861,32 @@
method protected final void undelegate(androidx.compose.ui.node.DelegatableNode instance);
}
+ public final class DpTouchBoundsExpansion {
+ ctor public DpTouchBoundsExpansion(float start, float top, float end, float bottom, boolean isLayoutDirectionAware);
+ method public float component1-D9Ej5fM();
+ method public float component2-D9Ej5fM();
+ method public float component3-D9Ej5fM();
+ method public float component4-D9Ej5fM();
+ method public boolean component5();
+ method public androidx.compose.ui.node.DpTouchBoundsExpansion copy-lDy3nrA(float start, float top, float end, float bottom, boolean isLayoutDirectionAware);
+ method public float getBottom();
+ method public float getEnd();
+ method public float getStart();
+ method public float getTop();
+ method public boolean isLayoutDirectionAware();
+ method public long roundToTouchBoundsExpansion(androidx.compose.ui.unit.Density density);
+ property public final float bottom;
+ property public final float end;
+ property public final boolean isLayoutDirectionAware;
+ property public final float start;
+ property public final float top;
+ field public static final androidx.compose.ui.node.DpTouchBoundsExpansion.Companion Companion;
+ }
+
+ public static final class DpTouchBoundsExpansion.Companion {
+ method public androidx.compose.ui.node.DpTouchBoundsExpansion Absolute(optional float left, optional float top, optional float right, optional float bottom);
+ }
+
public interface DrawModifierNode extends androidx.compose.ui.node.DelegatableNode {
method public void draw(androidx.compose.ui.graphics.drawscope.ContentDrawScope);
method public default void onMeasureResultChanged();
@@ -2941,10 +2968,6 @@
property public default long touchBoundsExpansion;
}
- public final class PointerInputModifierNodeKt {
- method public static long TouchBoundsExpansion(optional int start, optional int top, optional int end, optional int bottom);
- }
-
public final class Ref<T> {
ctor public Ref();
method public T? getValue();
@@ -2992,6 +3015,11 @@
property public final long None;
}
+ public final class TouchBoundsExpansionKt {
+ method public static androidx.compose.ui.node.DpTouchBoundsExpansion DpTouchBoundsExpansion(optional float start, optional float top, optional float end, optional float bottom);
+ method public static long TouchBoundsExpansion(optional int start, optional int top, optional int end, optional int bottom);
+ }
+
public interface TraversableNode extends androidx.compose.ui.node.DelegatableNode {
method public Object getTraverseKey();
property public abstract Object traverseKey;
diff --git a/compose/ui/ui/api/restricted_current.txt b/compose/ui/ui/api/restricted_current.txt
index 03f5875..24770bd 100644
--- a/compose/ui/ui/api/restricted_current.txt
+++ b/compose/ui/ui/api/restricted_current.txt
@@ -2068,6 +2068,7 @@
public final class PointerIconKt {
method @androidx.compose.runtime.Stable public static androidx.compose.ui.Modifier pointerHoverIcon(androidx.compose.ui.Modifier, androidx.compose.ui.input.pointer.PointerIcon icon, optional boolean overrideDescendants);
+ method public static androidx.compose.ui.Modifier stylusHoverIcon(androidx.compose.ui.Modifier, androidx.compose.ui.input.pointer.PointerIcon icon, optional boolean overrideDescendants, optional androidx.compose.ui.node.DpTouchBoundsExpansion? touchBoundsExpansion);
}
public final class PointerIcon_androidKt {
@@ -2914,6 +2915,32 @@
method protected final void undelegate(androidx.compose.ui.node.DelegatableNode instance);
}
+ public final class DpTouchBoundsExpansion {
+ ctor public DpTouchBoundsExpansion(float start, float top, float end, float bottom, boolean isLayoutDirectionAware);
+ method public float component1-D9Ej5fM();
+ method public float component2-D9Ej5fM();
+ method public float component3-D9Ej5fM();
+ method public float component4-D9Ej5fM();
+ method public boolean component5();
+ method public androidx.compose.ui.node.DpTouchBoundsExpansion copy-lDy3nrA(float start, float top, float end, float bottom, boolean isLayoutDirectionAware);
+ method public float getBottom();
+ method public float getEnd();
+ method public float getStart();
+ method public float getTop();
+ method public boolean isLayoutDirectionAware();
+ method public long roundToTouchBoundsExpansion(androidx.compose.ui.unit.Density density);
+ property public final float bottom;
+ property public final float end;
+ property public final boolean isLayoutDirectionAware;
+ property public final float start;
+ property public final float top;
+ field public static final androidx.compose.ui.node.DpTouchBoundsExpansion.Companion Companion;
+ }
+
+ public static final class DpTouchBoundsExpansion.Companion {
+ method public androidx.compose.ui.node.DpTouchBoundsExpansion Absolute(optional float left, optional float top, optional float right, optional float bottom);
+ }
+
public interface DrawModifierNode extends androidx.compose.ui.node.DelegatableNode {
method public void draw(androidx.compose.ui.graphics.drawscope.ContentDrawScope);
method public default void onMeasureResultChanged();
@@ -2995,10 +3022,6 @@
property public default long touchBoundsExpansion;
}
- public final class PointerInputModifierNodeKt {
- method public static long TouchBoundsExpansion(optional int start, optional int top, optional int end, optional int bottom);
- }
-
public final class Ref<T> {
ctor public Ref();
method public T? getValue();
@@ -3046,6 +3069,11 @@
property public final long None;
}
+ public final class TouchBoundsExpansionKt {
+ method public static androidx.compose.ui.node.DpTouchBoundsExpansion DpTouchBoundsExpansion(optional float start, optional float top, optional float end, optional float bottom);
+ method public static long TouchBoundsExpansion(optional int start, optional int top, optional int end, optional int bottom);
+ }
+
public interface TraversableNode extends androidx.compose.ui.node.DelegatableNode {
method public Object getTraverseKey();
property public abstract Object traverseKey;
diff --git a/compose/ui/ui/samples/src/main/java/androidx/compose/ui/samples/PointerIconSample.kt b/compose/ui/ui/samples/src/main/java/androidx/compose/ui/samples/PointerIconSample.kt
index 432ebf02..1632255 100644
--- a/compose/ui/ui/samples/src/main/java/androidx/compose/ui/samples/PointerIconSample.kt
+++ b/compose/ui/ui/samples/src/main/java/androidx/compose/ui/samples/PointerIconSample.kt
@@ -17,13 +17,22 @@
package androidx.compose.ui.samples
import androidx.annotation.Sampled
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.input.pointer.PointerIcon
import androidx.compose.ui.input.pointer.pointerHoverIcon
+import androidx.compose.ui.input.pointer.stylusHoverIcon
+import androidx.compose.ui.unit.dp
@Sampled
@Composable
@@ -41,3 +50,31 @@
Text("Just text with global pointerIcon")
}
}
+
+@Sampled
+@Composable
+fun StylusHoverIconSample() {
+ Box(
+ Modifier.requiredSize(200.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+ .stylusHoverIcon(PointerIcon.Crosshair)
+ ) {
+ Text(text = "crosshair icon")
+ Box(
+ Modifier.padding(20.dp)
+ .requiredSize(150.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+ .stylusHoverIcon(PointerIcon.Text)
+ ) {
+ Text(text = "text icon")
+ Box(
+ Modifier.padding(40.dp)
+ .requiredSize(100.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+ .stylusHoverIcon(PointerIcon.Hand)
+ ) {
+ Text(text = "hand icon")
+ }
+ }
+ }
+}
diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/HandwritingTestUtils.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/HandwritingTestUtils.kt
new file mode 100644
index 0000000..43e2af7
--- /dev/null
+++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/HandwritingTestUtils.kt
@@ -0,0 +1,176 @@
+/*
+ * 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
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.ui.input.pointer
+
+import android.view.MotionEvent
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.platform.ViewConfiguration
+import androidx.compose.ui.platform.ViewRootForTest
+import androidx.compose.ui.semantics.SemanticsNode
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.SemanticsNodeInteraction
+import androidx.compose.ui.test.TouchInjectionScope
+import androidx.compose.ui.test.invokeGlobalAssertions
+import androidx.compose.ui.test.tryPerformAccessibilityChecks
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.IntSize
+import androidx.core.view.InputDeviceCompat
+import androidx.test.platform.app.InstrumentationRegistry
+import kotlin.math.roundToInt
+
+// We don't have StylusInjectionScope at the moment. This is a simplified implementation for
+// the basic use cases in tests. It only supports single stylus pointer, and the pointerId
+// is totally ignored.
+internal class HandwritingTestStylusInjectScope(semanticsNode: SemanticsNode) :
+ TouchInjectionScope, Density by semanticsNode.layoutInfo.density {
+ private val root = semanticsNode.root as ViewRootForTest
+ private val downTime: Long = System.currentTimeMillis()
+
+ private var lastPosition: Offset = Offset.Unspecified
+ private var currentTime: Long = System.currentTimeMillis()
+ private val boundsInRoot = semanticsNode.boundsInRoot
+
+ override val visibleSize: IntSize =
+ IntSize(boundsInRoot.width.roundToInt(), boundsInRoot.height.roundToInt())
+
+ override val viewConfiguration: ViewConfiguration = semanticsNode.layoutInfo.viewConfiguration
+
+ private fun localToRoot(position: Offset): Offset {
+ return position + boundsInRoot.topLeft
+ }
+
+ override fun advanceEventTime(durationMillis: Long) {
+ require(durationMillis >= 0) {
+ "duration of a delay can only be positive, not $durationMillis"
+ }
+ currentTime += durationMillis
+ }
+
+ override fun currentPosition(pointerId: Int): Offset {
+ return lastPosition
+ }
+
+ override fun down(pointerId: Int, position: Offset) {
+ lastPosition = localToRoot(position)
+ sendTouchEvent(MotionEvent.ACTION_DOWN)
+ }
+
+ override fun updatePointerTo(pointerId: Int, position: Offset) {
+ lastPosition = localToRoot(position)
+ }
+
+ override fun move(delayMillis: Long) {
+ advanceEventTime(delayMillis)
+ sendTouchEvent(MotionEvent.ACTION_MOVE)
+ }
+
+ @ExperimentalTestApi
+ override fun moveWithHistoryMultiPointer(
+ relativeHistoricalTimes: List<Long>,
+ historicalCoordinates: List<List<Offset>>,
+ delayMillis: Long
+ ) {
+ // Not needed for this test because Android only supports one stylus pointer.
+ }
+
+ override fun up(pointerId: Int) {
+ sendTouchEvent(MotionEvent.ACTION_UP)
+ }
+
+ fun hoverEnter(position: Offset = lastPosition, delayMillis: Long = eventPeriodMillis) {
+ advanceEventTime(delayMillis)
+ lastPosition = localToRoot(position)
+ sendTouchEvent(MotionEvent.ACTION_HOVER_ENTER)
+ }
+
+ fun hoverMoveTo(position: Offset, delayMillis: Long = eventPeriodMillis) {
+ advanceEventTime(delayMillis)
+ lastPosition = localToRoot(position)
+ sendTouchEvent(MotionEvent.ACTION_HOVER_MOVE)
+ }
+
+ fun hoverExit(position: Offset = lastPosition, delayMillis: Long = eventPeriodMillis) {
+ advanceEventTime(delayMillis)
+ lastPosition = localToRoot(position)
+ sendTouchEvent(MotionEvent.ACTION_HOVER_EXIT)
+ }
+
+ override fun cancel(delayMillis: Long) {
+ sendTouchEvent(MotionEvent.ACTION_CANCEL)
+ }
+
+ private fun sendTouchEvent(action: Int) {
+ val motionEvent =
+ MotionEvent.obtain(
+ /* downTime = */ downTime,
+ /* eventTime = */ currentTime,
+ /* action = */ action,
+ /* pointerCount = */ 1,
+ /* pointerProperties = */ arrayOf(
+ MotionEvent.PointerProperties().apply {
+ id = 0
+ toolType = MotionEvent.TOOL_TYPE_STYLUS
+ }
+ ),
+ /* pointerCoords = */ arrayOf(
+ MotionEvent.PointerCoords().apply {
+ val startOffset = lastPosition
+
+ // Allows for non-valid numbers/Offsets to be passed along to Compose to
+ // test if it handles them properly (versus breaking here and we not knowing
+ // if Compose properly handles these values).
+ x =
+ if (startOffset.isValid()) {
+ startOffset.x
+ } else {
+ Float.NaN
+ }
+
+ y =
+ if (startOffset.isValid()) {
+ startOffset.y
+ } else {
+ Float.NaN
+ }
+ }
+ ),
+ /* metaState = */ 0,
+ /* buttonState = */ 0,
+ /* xPrecision = */ 1f,
+ /* yPrecision = */ 1f,
+ /* deviceId = */ 0,
+ /* edgeFlags = */ 0,
+ /* source = */ InputDeviceCompat.SOURCE_STYLUS,
+ /* flags = */ 0
+ )
+
+ InstrumentationRegistry.getInstrumentation().runOnMainSync {
+ root.view.dispatchTouchEvent(motionEvent)
+ }
+ }
+}
+
+internal fun SemanticsNodeInteraction.performStylusInput(
+ block: HandwritingTestStylusInjectScope.() -> Unit
+): SemanticsNodeInteraction {
+ @OptIn(ExperimentalTestApi::class) invokeGlobalAssertions()
+ tryPerformAccessibilityChecks()
+ val node = fetchSemanticsNode("Failed to inject stylus input.")
+ val stylusInjectionScope = HandwritingTestStylusInjectScope(node)
+ block.invoke(stylusInjectionScope)
+ return this
+}
diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/PointerIconTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/PointerIconTest.kt
index 94a9896..9352d33 100644
--- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/PointerIconTest.kt
+++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/PointerIconTest.kt
@@ -79,6 +79,12 @@
override fun setIcon(value: PointerIcon?) {
currentIcon = value ?: PointerIcon.Default
}
+
+ override fun getStylusHoverIcon(): PointerIcon? {
+ return null
+ }
+
+ override fun setStylusHoverIcon(value: PointerIcon?) {}
}
}
diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/StylusHoverIconTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/StylusHoverIconTest.kt
new file mode 100644
index 0000000..398713c
--- /dev/null
+++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/StylusHoverIconTest.kt
@@ -0,0 +1,3705 @@
+/*
+ * 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
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package androidx.compose.ui.input.pointer
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.requiredSize
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.movableContentOf
+import androidx.compose.runtime.mutableStateOf
+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.graphics.Color
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.platform.InspectableValue
+import androidx.compose.ui.platform.LocalPointerIconService
+import androidx.compose.ui.platform.isDebugInspectorInfoEnabled
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.unit.dp
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import com.google.common.truth.Truth.assertThat
+import org.junit.Before
+import org.junit.Ignore
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@MediumTest
+@RunWith(AndroidJUnit4::class)
+class StylusHoverIconTest {
+ @get:Rule val rule = createComposeRule()
+ private val parentIconTag = "myParentIcon"
+ private val childIconTag = "myChildIcon"
+ private val grandchildIconTag = "myGrandchildIcon"
+ private val desiredParentIcon = PointerIcon.Crosshair // AndroidPointerIcon(type=1007)
+ private val desiredChildIcon = PointerIcon.Text // AndroidPointerIcon(type=1008)
+ private val desiredGrandchildIcon = PointerIcon.Hand // AndroidPointerIcon(type=1002)
+ private lateinit var iconService: PointerIconService
+
+ @Before
+ fun setup() {
+ iconService =
+ object : PointerIconService {
+ private var currentIcon: PointerIcon = PointerIcon.Default
+ private var currentStylusHoverIcon: PointerIcon? = null
+
+ override fun getIcon(): PointerIcon {
+ return currentIcon
+ }
+
+ override fun setIcon(value: PointerIcon?) {
+ currentIcon = value ?: PointerIcon.Default
+ }
+
+ override fun getStylusHoverIcon(): PointerIcon? {
+ return currentStylusHoverIcon
+ }
+
+ override fun setStylusHoverIcon(value: PointerIcon?) {
+ currentStylusHoverIcon = value
+ }
+ }
+ }
+
+ @Test
+ fun testInspectorValue() {
+ isDebugInspectorInfoEnabled = true
+ rule.setContent {
+ val modifier =
+ Modifier.stylusHoverIcon(PointerIcon.Hand, overrideDescendants = false)
+ as InspectableValue
+ assertThat(modifier.nameFallback).isEqualTo("stylusHoverIcon")
+ assertThat(modifier.valueOverride).isNull()
+ assertThat(modifier.inspectableElements.map { it.name }.asIterable())
+ .containsExactly(
+ "icon",
+ "overrideDescendants",
+ "touchBoundsExpansion",
+ )
+ }
+ isDebugInspectorInfoEnabled = false
+ }
+
+ /**
+ * Setup: The hierarchy for this test is setup as: Parent Box (custom icon =
+ * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box (custom icon =
+ * [PointerIcon.Text], overrideDescendants = FALSE)
+ *
+ * Expected Output: Because we don't move the cursor, the icon will be null. We also want to
+ * check that when using a .stylusHoverIcon modifier with a composable, composition only happens
+ * once (per composable).
+ */
+ @Test
+ fun parentChildFullOverlap_noOverrideDescendants_checkNumberOfCompositions() {
+
+ var numberOfCompositions = 0
+
+ rule.setContent {
+ CompositionLocalProvider(LocalPointerIconService provides iconService) {
+ Box(
+ modifier =
+ Modifier.requiredSize(200.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+ .testTag(parentIconTag)
+ .stylusHoverIcon(desiredParentIcon, overrideDescendants = false)
+ ) {
+ numberOfCompositions++
+
+ Box(
+ Modifier.requiredSize(200.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+ .testTag(childIconTag)
+ .stylusHoverIcon(desiredChildIcon, overrideDescendants = false)
+ ) {
+ numberOfCompositions++
+ }
+ }
+ }
+ }
+ // Verify initial state of pointer icon
+ rule.runOnIdle {
+ assertThat(iconService.getStylusHoverIcon()).isNull()
+ assertThat(numberOfCompositions).isEqualTo(2)
+ }
+ }
+
+ /**
+ * Setup: The hierarchy for this test is setup as: Parent Box (custom icon =
+ * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box (custom icon =
+ * [PointerIcon.Text], overrideDescendants = FALSE)
+ *
+ * Expected Output: Child Box’s [PointerIcon.Text] wins for the entire Box area because it’s
+ * lower in the hierarchy than Parent Box. If the Parent Box's overrideDescendants = false, the
+ * Child Box takes priority.
+ */
+ @Test
+ fun parentChildFullOverlap_noOverrideDescendants() {
+ rule.setContent {
+ CompositionLocalProvider(LocalPointerIconService provides iconService) {
+ Box(
+ modifier =
+ Modifier.requiredSize(200.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+ .testTag(parentIconTag)
+ .stylusHoverIcon(desiredParentIcon, overrideDescendants = false)
+ ) {
+ Box(
+ Modifier.requiredSize(200.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+ .testTag(childIconTag)
+ .stylusHoverIcon(desiredChildIcon, overrideDescendants = false)
+ )
+ }
+ }
+ }
+ // Verify initial state of pointer icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+ // Verify Child Box's icon is the desired child icon
+ verifyIconOnHover(childIconTag, desiredChildIcon)
+ // Verify Parent Box is respecting Child Box's icon
+ verifyIconOnHover(parentIconTag, desiredChildIcon)
+ }
+
+ /**
+ * Setup: The hierarchy for this test is setup as: Parent Box (custom icon =
+ * [PointerIcon.Crosshair], overrideDescendants = TRUE) ⤷ Child Box (custom icon =
+ * [PointerIcon.Text], overrideDescendants = FALSE)
+ *
+ * Expected Output: Parent Box’s [PointerIcon.Crosshair] wins for the entire Box area because
+ * it’s higher in the hierarchy than Child Box. Also the Parent Box's overrideDescendants value
+ * is TRUE, so as the topmost parent in the hierarchy with overrideDescendants = true, all its
+ * children must respect it.
+ */
+ @Test
+ fun parentChildFullOverlap_parentOverridesDescendants() {
+ rule.setContent {
+ CompositionLocalProvider(LocalPointerIconService provides iconService) {
+ Box(
+ modifier =
+ Modifier.requiredSize(200.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+ .testTag(parentIconTag)
+ .stylusHoverIcon(desiredParentIcon, overrideDescendants = true)
+ ) {
+ Box(
+ Modifier.requiredSize(200.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+ .testTag(childIconTag)
+ .stylusHoverIcon(desiredChildIcon, overrideDescendants = false)
+ )
+ }
+ }
+ }
+ // Verify initial state of pointer icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+ // Verify Parent Box's icon is the desired parent icon
+ verifyIconOnHover(parentIconTag, desiredParentIcon)
+ // Verify Child Box is respecting Parent Box's icon
+ verifyIconOnHover(childIconTag, desiredParentIcon)
+ }
+
+ /**
+ * Setup: The hierarchy for this test is setup as: Parent Box (custom icon =
+ * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box (custom icon =
+ * [PointerIcon.Text], overrideDescendants = TRUE)
+ *
+ * Expected Output: Child Box’s [PointerIcon.Text] wins for the entire Box area because its
+ * lower in priority than Parent Box. If the Parent Box's overrideDescendants = false, the Child
+ * Box takes priority.
+ */
+ @Test
+ fun parentChildFullOverlap_childOverridesDescendants() {
+ rule.setContent {
+ CompositionLocalProvider(LocalPointerIconService provides iconService) {
+ Box(
+ modifier =
+ Modifier.requiredSize(200.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+ .testTag(parentIconTag)
+ .stylusHoverIcon(desiredParentIcon, overrideDescendants = false)
+ ) {
+ Box(
+ Modifier.requiredSize(200.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+ .testTag(childIconTag)
+ .stylusHoverIcon(desiredChildIcon, overrideDescendants = true)
+ )
+ }
+ }
+ }
+ // Verify initial state of pointer icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+ // Verify Child Box's icon is the desired child icon
+ verifyIconOnHover(childIconTag, desiredChildIcon)
+ // Verify Parent Box is respecting Child Box's icon
+ verifyIconOnHover(parentIconTag, desiredChildIcon)
+ }
+
+ /**
+ * Setup: The hierarchy for this test is setup as: Parent Box (custom icon =
+ * [PointerIcon.Crosshair], overrideDescendants = TRUE) ⤷ Child Box (custom icon =
+ * [PointerIcon.Text], overrideDescendants = TRUE)
+ *
+ * Expected Output: Parent Box’s [PointerIcon.Crosshair] wins for the entire Box area because
+ * its overrideDescendants = true. The Parent Box takes precedence because it is the topmost
+ * parent in the hierarchy with overrideDescendants = true.
+ */
+ @Test
+ fun parentChildFullOverlap_bothOverrideDescendants() {
+ rule.setContent {
+ CompositionLocalProvider(LocalPointerIconService provides iconService) {
+ Box(
+ modifier =
+ Modifier.requiredSize(200.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+ .testTag(parentIconTag)
+ .stylusHoverIcon(desiredParentIcon, overrideDescendants = true)
+ ) {
+ Box(
+ Modifier.requiredSize(200.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+ .testTag(childIconTag)
+ .stylusHoverIcon(desiredChildIcon, overrideDescendants = true)
+ )
+ }
+ }
+ }
+ // Verify initial state of pointer icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+ // Verify Parent Box's icon is the desired parent icon
+ verifyIconOnHover(parentIconTag, desiredParentIcon)
+ // Verify Child Box is respecting Parent Box's icon
+ verifyIconOnHover(childIconTag, desiredParentIcon)
+ }
+
+ /**
+ * Setup: The hierarchy for this test is setup as: Parent Box (custom icon =
+ * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box (custom icon =
+ * [PointerIcon.Text], overrideDescendants = FALSE)
+ *
+ * Expected Output: Child Box’s [PointerIcon.Hand] wins for the entire Child Box surface area
+ * because there's no parent in its hierarchy that has overrideDescendants = true. Parent Box's
+ * [PointerIcon.Crosshair] wins for all remaining surface area of its Box that doesn't overlap
+ * with Child Box.
+ */
+ @Test
+ fun parentChildPartialOverlap_noOverrideDescendants() {
+ rule.setContent {
+ CompositionLocalProvider(LocalPointerIconService provides iconService) {
+ Box(
+ modifier =
+ Modifier.requiredSize(200.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+ .testTag(parentIconTag)
+ .stylusHoverIcon(desiredParentIcon, overrideDescendants = false)
+ ) {
+ Box(
+ Modifier.padding(20.dp)
+ .requiredSize(100.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+ .testTag(childIconTag)
+ .stylusHoverIcon(desiredChildIcon, overrideDescendants = false)
+ )
+ }
+ }
+ }
+ // Verify initial state of pointer icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+ // Verify Child Box's icon is the desired child icon
+ verifyIconOnHover(childIconTag, desiredChildIcon)
+ // Verify remaining Parent Box's area is the desired parent icon
+ verifyIconOnHover(parentIconTag, desiredParentIcon)
+ }
+
+ /**
+ * Setup: The hierarchy for this test is setup as: Parent Box (custom icon =
+ * [PointerIcon.Crosshair], overrideDescendants = TRUE) ⤷ Child Box (custom icon =
+ * [PointerIcon.Hand], overrideDescendants = FALSE)
+ *
+ * Expected Output: Parent Box’s [PointerIcon.Hand] wins for the entire Box area because its
+ * overrideDescendants = true, so every child underneath it in the hierarchy must respect its
+ * pointer icon since it's the topmost parent in the hierarchy with overrideDescendants = true.
+ */
+ @Test
+ fun parentChildPartialOverlap_parentOverridesDescendants() {
+ rule.setContent {
+ CompositionLocalProvider(LocalPointerIconService provides iconService) {
+ Box(
+ modifier =
+ Modifier.requiredSize(200.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+ .testTag(parentIconTag)
+ .stylusHoverIcon(desiredParentIcon, overrideDescendants = true)
+ ) {
+ Box(
+ Modifier.padding(20.dp)
+ .requiredSize(100.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+ .testTag(childIconTag)
+ .stylusHoverIcon(desiredChildIcon, overrideDescendants = false)
+ )
+ }
+ }
+ }
+ // Verify initial state of pointer icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+ // Verify Parent Box's icon is the desired parent icon
+ verifyIconOnHover(parentIconTag, desiredParentIcon)
+ // Verify Child Box is respecting Parent Box's icon
+ verifyIconOnHover(childIconTag, desiredParentIcon)
+ }
+
+ /**
+ * Setup: The hierarchy for this test is setup as: Parent Box (custom icon =
+ * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box (custom icon =
+ * [PointerIcon.Hand], overrideDescendants = TRUE)
+ *
+ * Expected Output: Child Box’s [PointerIcon.Hand] wins for the entire Child Box surface area
+ * because it’s lower in the hierarchy than Parent Box. If Parent Box's overrideDescendants =
+ * false, the Child Box takes priority.
+ */
+ @Test
+ fun parentChildPartialOverlap_childOverridesDescendants() {
+ rule.setContent {
+ CompositionLocalProvider(LocalPointerIconService provides iconService) {
+ Box(
+ modifier =
+ Modifier.requiredSize(200.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+ .testTag(parentIconTag)
+ .stylusHoverIcon(desiredParentIcon, overrideDescendants = false)
+ ) {
+ Box(
+ Modifier.padding(20.dp)
+ .requiredSize(100.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+ .testTag(childIconTag)
+ .stylusHoverIcon(desiredChildIcon, overrideDescendants = true)
+ )
+ }
+ }
+ }
+ // Verify initial state of pointer icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+ // Verify Child Box's icon is the desired child icon
+ verifyIconOnHover(childIconTag, desiredChildIcon)
+ // Verify remaining Parent Box's area is the desired parent icon
+ verifyIconOnHover(parentIconTag, desiredParentIcon)
+ }
+
+ /**
+ * Setup: The hierarchy for this test is setup as: Parent Box (custom icon =
+ * [PointerIcon.Crosshair], overrideDescendants = TRUE) ⤷ Child Box (custom icon =
+ * [PointerIcon.Hand], overrideDescendants = TRUE)
+ *
+ * Expected Output: Parent Box’s [PointerIcon.Crosshair] wins for the entire Box area because
+ * its overrideDescendants = true. If multiple locations in the hierarchy set
+ * overrideDescendants = true, the highest parent in the hierarchy takes precedence (in this
+ * example, it was Parent Box).
+ */
+ @Test
+ fun parentChildPartialOverlap_bothOverrideDescendants() {
+ rule.setContent {
+ CompositionLocalProvider(LocalPointerIconService provides iconService) {
+ Box(
+ modifier =
+ Modifier.requiredSize(200.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+ .testTag(parentIconTag)
+ .stylusHoverIcon(desiredParentIcon, overrideDescendants = true)
+ ) {
+ Box(
+ Modifier.padding(20.dp)
+ .requiredSize(100.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+ .testTag(childIconTag)
+ .stylusHoverIcon(desiredChildIcon, overrideDescendants = true)
+ )
+ }
+ }
+ }
+ // Verify initial state of pointer icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+ // Verify Parent Box's icon is the desired parent icon
+ verifyIconOnHover(parentIconTag, desiredParentIcon)
+ // Verify Child Box is respecting Parent Box's icon
+ verifyIconOnHover(childIconTag, desiredParentIcon)
+ }
+
+ /**
+ * Setup: The hierarchy for the initial setup of this test is: Parent Box (no custom icon) ⤷
+ * Child Box (custom icon = [PointerIcon.Text], overrideDescendants = FALSE)
+ *
+ * After hovering over the center of the screen, the hierarchy under the cursor updates to:
+ * Parent Box (custom icon = [PointerIcon.Crosshair], overrideDescendants = TRUE) ⤷ Child Box
+ * (custom icon = [PointerIcon.Text], overrideDescendants = FALSE)
+ *
+ * Expected Output: Initially, the Child Box's [PointerIcon.Text] should win for its entire
+ * surface area because it has no competition in the hierarchy for any other custom icons. After
+ * the Parent Box dynamically has the stylusHoverIcon Modifier added to it, the Parent Box's
+ * [PointerIcon.Crosshair] should win for the entire surface area of the Parent Box and Child
+ * Box because the Parent Box has overrideDescendants = true.
+ *
+ * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon =
+ * [PointerIcon.Crosshair])
+ */
+ @Test
+ fun parentChildPartialOverlap_parentModifierDynamicallyAdded() {
+ val isVisible = mutableStateOf(false)
+ rule.setContent {
+ CompositionLocalProvider(LocalPointerIconService provides iconService) {
+ Box(
+ modifier =
+ Modifier.requiredSize(200.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+ .testTag(parentIconTag)
+ .then(
+ if (isVisible.value)
+ Modifier.stylusHoverIcon(
+ desiredParentIcon,
+ overrideDescendants = true
+ )
+ else Modifier
+ )
+ ) {
+ Box(
+ Modifier.padding(20.dp)
+ .requiredSize(150.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+ .testTag(childIconTag)
+ .stylusHoverIcon(desiredChildIcon, overrideDescendants = false)
+ )
+ }
+ }
+ }
+ // Verify initial state of pointer icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+ // Verify Child Box is the desired child icon
+ verifyIconOnHover(childIconTag, desiredChildIcon)
+ // Verify Parent Box's icon is the desired default icon
+ verifyIconOnHover(parentIconTag, null)
+ // Dynamically add the stylusHoverIcon Modifier to the Parent Box
+ rule.runOnIdle { isVisible.value = true }
+ // Verify Parent Box's icon is the desired parent icon
+ verifyIconOnHover(parentIconTag, desiredParentIcon)
+ // Verify Child Box is respecting Parent Box's icon
+ verifyIconOnHover(childIconTag, desiredParentIcon)
+ }
+
+ /**
+ * Setup: The hierarchy for the initial setup of this test is: Parent Box (no custom icon) ⤷
+ * Child Box (custom icon = [PointerIcon.Text], overrideDescendants = FALSE)
+ *
+ * After hovering over the center of the screen, the hierarchy under the cursor updates to:
+ * Parent Box (custom icon = [PointerIcon.Crosshair], overrideDescendants = TRUE) ⤷ Child Box
+ * (custom icon = [PointerIcon.Text], overrideDescendants = FALSE)
+ *
+ * Expected Output: Initially, the Child Box's [PointerIcon.Text] should win for its entire
+ * surface area because it has no competition in the hierarchy for any other custom icons. After
+ * the Parent Box dynamically has the stylusHoverIcon Modifier added to it, the Parent Box's
+ * [PointerIcon.Crosshair] should win for the entire surface area of the Parent Box and Child
+ * Box because the Parent Box has overrideDescendants = true.
+ *
+ * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon =
+ * [PointerIcon.Crosshair])
+ */
+ @Ignore("b/299482894 - not yet implemented")
+ @Test
+ fun parentChildPartialOverlap_parentModifierDynamicallyAddedWithMoveEvents() {
+ val isVisible = mutableStateOf(false)
+ rule.setContent {
+ CompositionLocalProvider(LocalPointerIconService provides iconService) {
+ Box(
+ modifier =
+ Modifier.requiredSize(200.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+ .testTag(parentIconTag)
+ .then(
+ if (isVisible.value)
+ Modifier.stylusHoverIcon(
+ desiredParentIcon,
+ overrideDescendants = true
+ )
+ else Modifier
+ )
+ ) {
+ Box(
+ Modifier.padding(20.dp)
+ .requiredSize(150.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+ .testTag(childIconTag)
+ .stylusHoverIcon(desiredChildIcon, overrideDescendants = false)
+ )
+ }
+ }
+ }
+ // Verify initial state of pointer icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+ // Hover over Child Box and verify it has the desired child icon
+ rule.onNodeWithTag(childIconTag).performStylusInput { hoverEnter(bottomRight) }
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) }
+ // Move to Parent Box and verify its icon is the desired default icon
+ rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomRight) }
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+ // Move back to the Child Box
+ rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(center) }
+ // Dynamically add the stylusHoverIcon Modifier to the Parent Box
+ rule.runOnIdle { isVisible.value = true }
+ // Verify the Child Box has updated to respect the desired parent icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+ // Move within the Child Box and verify it is still respecting the desired parent icon
+ rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(bottomRight) }
+ // Move to the Parent Box and verify it also has the desired parent icon
+ rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomRight) }
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+ // Exit hovering over Parent Box
+ rule.onNodeWithTag(parentIconTag).performStylusInput { hoverExit() }
+ }
+
+ /**
+ * Setup: The hierarchy for the initial setup of this test is: Parent Box (custom icon =
+ * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box (custom icon =
+ * [PointerIcon.Text], overrideDescendants = FALSE)
+ *
+ * After hovering over the center of the screen, the hierarchy under the cursor updates to:
+ * Parent Box (custom icon = [PointerIcon.Crosshair], overrideDescendants = TRUE) ⤷ Child Box
+ * (custom icon = [PointerIcon.Text], overrideDescendants = FALSE)
+ *
+ * After several assertions, it reverts back to false in the parent: Parent Box (custom icon =
+ * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box (custom icon =
+ * [PointerIcon.Text], overrideDescendants = FALSE)
+ *
+ * Expected Output: Initially, the Child Box's [PointerIcon.Text] should win for its entire
+ * surface area because the parent does not override descendants. After the Parent Box
+ * dynamically changes overrideDescendants to true, the Parent Box's [PointerIcon.Crosshair]
+ * should win for the entire surface area of the Parent Box and Child Box because the Parent Box
+ * has overrideDescendants = true.
+ *
+ * It should then revert back to Child Box's [PointerIcon.Text] after the Parent Box's
+ * overrideDescendants is set back to false.
+ */
+ @Test
+ fun parentChildPartialOverlap_parentModifierDynamicallyChangedToOverrideWithMoveEvents() {
+ var parentOverrideDescendants by mutableStateOf(false)
+ rule.setContent {
+ CompositionLocalProvider(LocalPointerIconService provides iconService) {
+ Box(
+ modifier =
+ Modifier.requiredSize(200.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+ .testTag(parentIconTag)
+ .then(
+ Modifier.stylusHoverIcon(
+ desiredParentIcon,
+ overrideDescendants = parentOverrideDescendants
+ )
+ )
+ ) {
+ Box(
+ Modifier.padding(20.dp)
+ .requiredSize(150.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+ .testTag(childIconTag)
+ .stylusHoverIcon(desiredChildIcon, overrideDescendants = false)
+ )
+ }
+ }
+ }
+ // Verify initial state of pointer icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+ // Hover over Child Box and verify it has the desired child icon
+ rule.onNodeWithTag(childIconTag).performStylusInput { hoverEnter(bottomRight) }
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) }
+ // Move to Parent Box and verify its icon is the desired parent icon
+ rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomRight) }
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+ // Move back to the Child Box
+ rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(center) }
+
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) }
+
+ // Dynamically change the stylusHoverIcon Modifier to the Parent Box to
+ // override descendants.
+ rule.runOnIdle { parentOverrideDescendants = true }
+
+ // Verify the Child Box has updated to respect the desired parent icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+
+ // Move within the Child Box and verify it is still respecting the desired parent icon
+ rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(bottomRight) }
+
+ // Verify the Child Box has updated to respect the desired parent icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+
+ // Move to the Parent Box and verify it also has the desired parent icon
+ rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomRight) }
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+
+ // Move within the Child Box and verify it is still respecting the desired parent icon
+ rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(bottomRight) }
+
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+
+ // Dynamically change the stylusHoverIcon Modifier to the Parent Box to NOT
+ // override descendants.
+ rule.runOnIdle { parentOverrideDescendants = false }
+
+ // Verify it's changed to child icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) }
+
+ // Move to Parent Box and verify its icon is the desired parent icon
+ rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomRight) }
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+ // Move back to the Child Box
+ rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(center) }
+
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) }
+
+ // Exit hovering over Parent Box
+ rule.onNodeWithTag(parentIconTag).performStylusInput { hoverExit() }
+ }
+
+ /**
+ * Setup: The hierarchy for the initial setup of this test is: Parent Box (custom icon =
+ * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box (custom icon =
+ * [PointerIcon.Text], overrideDescendants = FALSE)
+ *
+ * After hovering over the center of the screen, the hierarchy under the cursor updates to:
+ * Parent Box (custom icon = [PointerIcon.Crosshair], overrideDescendants = TRUE) ⤷ Child Box
+ * (custom icon = [PointerIcon.Text], overrideDescendants = FALSE)
+ *
+ * Expected Output: Initially, Child Box’s [PointerIcon.Hand] wins for the entire Child Box
+ * surface area because there's no parent in its hierarchy that has overrideDescendants = true.
+ * Additionally, Parent Box's [PointerIcon.Crosshair] would initially win for all remaining
+ * surface area of its Box that doesn't overlap with Child Box. Once Parent Box's
+ * overrideDescendants parameter is dynamically updated to true, the Parent Box's icon should
+ * win for its entire surface area, including within Child Box.
+ */
+ @Test
+ fun parentChildPartialOverlap_parentOverrideDescendantsDynamicallyUpdated() {
+ val parentOverrideState = mutableStateOf(false)
+ rule.setContent {
+ CompositionLocalProvider(LocalPointerIconService provides iconService) {
+ Box(
+ modifier =
+ Modifier.requiredSize(200.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+ .testTag(parentIconTag)
+ .stylusHoverIcon(
+ desiredParentIcon,
+ overrideDescendants = parentOverrideState.value
+ )
+ ) {
+ Box(
+ Modifier.padding(20.dp)
+ .requiredSize(150.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+ .testTag(childIconTag)
+ .stylusHoverIcon(desiredChildIcon, overrideDescendants = false)
+ )
+ }
+ }
+ }
+ // Verify initial state of pointer icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+ // Verify Child Box's icon is the desired child icon
+ verifyIconOnHover(childIconTag, desiredChildIcon)
+ // Verify remaining Parent Box's area is the desired parent icon
+ verifyIconOnHover(parentIconTag, desiredParentIcon)
+ rule.runOnIdle { parentOverrideState.value = true }
+ // Verify Child Box's icon is the desired parent icon
+ verifyIconOnHover(childIconTag, desiredParentIcon)
+ // Verify Parent Box also has the desired parent icon
+ verifyIconOnHover(parentIconTag, desiredParentIcon)
+ }
+
+ /**
+ * Setup: The hierarchy for the initial setup of this test is: Parent Box (custom icon =
+ * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box (custom icon =
+ * [PointerIcon.Text], overrideDescendants = FALSE)
+ *
+ * After hovering over various parts of the screen and verify the results, we update the
+ * parent's overrideDescendants to true: Parent Box (custom icon = [PointerIcon.Crosshair],
+ * overrideDescendants = TRUE) ⤷ Child Box (custom icon = [PointerIcon.Text],
+ * overrideDescendants = FALSE)
+ *
+ * After several assertions, it reverts back to false in the parent: Parent Box (custom icon =
+ * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box (custom icon =
+ * [PointerIcon.Text], overrideDescendants = FALSE)
+ *
+ * Expected Output: Initially, the Child Box's [PointerIcon.Text] should win for its entire
+ * surface area because the parent does not override descendants. After the Parent Box
+ * dynamically changes overrideDescendants to true, the Parent Box's [PointerIcon.Crosshair]
+ * should win for the child's surface area within the Parent Box BUT NOT the portion of the
+ * Child Box that is outside the Parent Box.
+ *
+ * It should then revert back to Child Box's [PointerIcon.Text] (in all scenarios) after the
+ * Parent Box's overrideDescendants is set back to false.
+ */
+ @Test
+ fun parentChildPartialOverlapAndExtendsBeyondParent_dynamicOverrideDescendants() {
+ var parentOverrideDescendants by mutableStateOf(false)
+ rule.setContent {
+ CompositionLocalProvider(LocalPointerIconService provides iconService) {
+ Box(
+ modifier =
+ Modifier.requiredSize(300.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Green)))
+ ) {
+ // This child extends beyond the borders of the parent (enabling this test)
+ Box(
+ modifier =
+ Modifier.size(150.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+ .testTag(parentIconTag)
+ .then(
+ Modifier.stylusHoverIcon(
+ desiredParentIcon,
+ overrideDescendants = parentOverrideDescendants
+ )
+ )
+ ) {
+ Box(
+ Modifier.padding(20.dp)
+ .offset(100.dp)
+ .width(300.dp)
+ .height(100.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+ .testTag(childIconTag)
+ .stylusHoverIcon(desiredChildIcon, overrideDescendants = false)
+ )
+ }
+ }
+ }
+ }
+ // Verify initial state of pointer icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+ // Hover over Child Box and verify it has the desired child icon (outside parent)
+ rule.onNodeWithTag(childIconTag).performStylusInput { hoverEnter(bottomRight) }
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) }
+
+ // Hover over Child Box and verify it has the desired child icon (inside parent)
+ rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(bottomLeft) }
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) }
+
+ // Move to Parent Box and verify its icon is the desired parent icon
+ rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomRight) }
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+ // Move back to the Child Box (portion inside parent)
+ rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(bottomLeft) }
+
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) }
+
+ // Dynamically change the stylusHoverIcon Modifier of the Parent Box to
+ // override descendants.
+ rule.runOnIdle { parentOverrideDescendants = true }
+
+ // Verify the Child Box has updated to respect the desired parent icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+
+ // Hover over Child Box and verify it has the desired child icon (outside parent)
+ rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(bottomRight) }
+
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) }
+
+ // Move to the Parent Box and verify it also has the desired parent icon
+ rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomRight) }
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+
+ // Move within the Child Box (portion inside parent) and verify it is still
+ // respecting the desired parent icon
+ rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(bottomLeft) }
+
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+
+ // Dynamically change the stylusHoverIcon Modifier of the Parent Box to NOT
+ // override descendants.
+ rule.runOnIdle { parentOverrideDescendants = false }
+
+ // Verify it's changed to child icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) }
+
+ // Move to Parent Box and verify its icon is the desired parent icon
+ rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomRight) }
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+ // Move back to the Child Box
+ rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(bottomLeft) }
+
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) }
+
+ // Exit hovering over Parent Box
+ rule.onNodeWithTag(parentIconTag).performStylusInput { hoverExit() }
+ }
+
+ /**
+ * Setup: The hierarchy for this test is setup as: Parent Box (no custom icon set) ⤷ ChildA Box
+ * (custom icon = [PointerIcon.Text], overrideDescendants = FALSE) ⤷ ChildB Box (custom icon =
+ * [PointerIcon.Hand], overrideDescendants = FALSE)
+ *
+ * Expected Output: ChildA Box’s [PointerIcon.Text] wins for the entire surface area of ChildA's
+ * Box. ChildB Box's [PointerIcon.Hand] wins for the entire surface area of ChildB's Box. No
+ * icon wins for the remainder of the surface area of Parent Box that's not covered by ChildA
+ * Box or ChildB Box. In this example, there's no competition for pointer icons because the
+ * parent has no icon set and neither ChildA or ChildB Boxes overlap.
+ *
+ * Parent Box (output icon = null) ⤷ Child Box (output icon = [PointerIcon.Text]) ⤷ Child Box
+ * (output icon = [PointerIcon.Hand])
+ */
+ @Test
+ fun NonOverlappingSiblings_noOverrideDescendants() {
+ rule.setContent {
+ CompositionLocalProvider(LocalPointerIconService provides iconService) {
+ Box(
+ modifier =
+ Modifier.requiredSize(200.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+ .testTag(parentIconTag)
+ ) {
+ Column {
+ Box(
+ Modifier.padding(20.dp)
+ .requiredSize(50.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+ .testTag(childIconTag)
+ .stylusHoverIcon(desiredChildIcon, overrideDescendants = false)
+ )
+ // Referencing grandchild tag/icon for ChildB in this test
+ Box(
+ Modifier.padding(40.dp)
+ .requiredSize(50.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+ .testTag(grandchildIconTag)
+ .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = false)
+ )
+ }
+ }
+ }
+ }
+ // Verify initial state of pointer icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+ // Verify ChildA Box's icon is the desired ChildA icon
+ verifyIconOnHover(childIconTag, desiredChildIcon)
+ // Verify ChildB Box's icon is the desired ChildB icon
+ verifyIconOnHover(grandchildIconTag, desiredGrandchildIcon)
+ // Verify remaining Parent Box's icon is the default icon
+ verifyIconOnHover(parentIconTag, null)
+ }
+
+ /**
+ * Setup: The hierarchy for this test is setup as: Parent Box (no custom icon set) ⤷ ChildA Box
+ * (custom icon = [PointerIcon.Text], overrideDescendants = TRUE) ⤷ ChildB Box (custom icon =
+ * [PointerIcon.Hand], overrideDescendants = FALSE)
+ *
+ * Expected Output: ChildA Box’s [PointerIcon.Text] wins for the entire surface area of ChildA's
+ * Box. ChildB Box's [PointerIcon.Hand] wins for the entire surface area of ChildB's Box. No
+ * icon wins for the remainder of the surface area of Parent Box that's not covered by ChildA
+ * Box or ChildB Box. In this example, it doesn't matter whether ChildA Box's
+ * overrideDescendants = true or false because there's no competition for pointer icons in this
+ * example.
+ *
+ * Parent Box (output icon = null) ⤷ Child Box (output icon = [PointerIcon.Text]) ⤷ Child Box
+ * (output icon = [PointerIcon.Hand])
+ */
+ @Test
+ fun NonOverlappingSiblings_firstChildOverridesDescendants() {
+ rule.setContent {
+ CompositionLocalProvider(LocalPointerIconService provides iconService) {
+ Box(
+ modifier =
+ Modifier.requiredSize(200.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+ .testTag(parentIconTag)
+ ) {
+ Column {
+ Box(
+ Modifier.padding(20.dp)
+ .requiredSize(50.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+ .testTag(childIconTag)
+ .stylusHoverIcon(desiredChildIcon, overrideDescendants = true)
+ )
+ // Referencing grandchild tag/icon for ChildB in this test
+ Box(
+ Modifier.padding(40.dp)
+ .requiredSize(50.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+ .testTag(grandchildIconTag)
+ .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = false)
+ )
+ }
+ }
+ }
+ }
+ // Verify initial state of pointer icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+ // Verify ChildA Box's icon is the desired ChildA icon
+ verifyIconOnHover(childIconTag, desiredChildIcon)
+ // Verify ChildB Box's icon is the desired ChildB icon
+ verifyIconOnHover(grandchildIconTag, desiredGrandchildIcon)
+ // Verify remaining Parent Box's icon is the default icon
+ verifyIconOnHover(parentIconTag, null)
+ }
+
+ /**
+ * Setup: The hierarchy for this test is setup as: Parent Box (no custom icon set) ⤷ ChildA Box
+ * (custom icon = [PointerIcon.Text], overrideDescendants = FALSE) ⤷ ChildB Box (custom icon =
+ * [PointerIcon.Hand], overrideDescendants = TRUE)
+ *
+ * Expected Output: ChildA Box’s [PointerIcon.Text] wins for the entire surface area of ChildA's
+ * Box. ChildB Box's [PointerIcon.Hand] wins for the entire surface area of ChildB's Box. No
+ * icon wins for the remainder of the surface area of Parent Box that's not covered by ChildA
+ * Box or ChildB Box. In this example, it doesn't matter whether ChildB Box's
+ * overrideDescendants = true or false because there's no competition for pointer icons in this
+ * example.
+ *
+ * Parent Box (output icon = null) ⤷ Child Box (output icon = [PointerIcon.Text]) ⤷ Child Box
+ * (output icon = [PointerIcon.Hand])
+ */
+ @Test
+ fun NonOverlappingSiblings_secondChildOverridesDescendants() {
+ rule.setContent {
+ CompositionLocalProvider(LocalPointerIconService provides iconService) {
+ Box(
+ modifier =
+ Modifier.requiredSize(200.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+ .testTag(parentIconTag)
+ ) {
+ Column {
+ Box(
+ Modifier.padding(20.dp)
+ .requiredSize(50.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+ .testTag(childIconTag)
+ .stylusHoverIcon(desiredChildIcon, overrideDescendants = false)
+ )
+ // Referencing grandchild tag/icon for ChildB in this test
+ Box(
+ Modifier.padding(40.dp)
+ .requiredSize(50.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+ .testTag(grandchildIconTag)
+ .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = true)
+ )
+ }
+ }
+ }
+ }
+ // Verify initial state of pointer icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+ // Verify ChildA Box's icon is the desired ChildA icon
+ verifyIconOnHover(childIconTag, desiredChildIcon)
+ // Verify ChildB Box's icon is the desired ChildB icon
+ verifyIconOnHover(grandchildIconTag, desiredGrandchildIcon)
+ // Verify remaining Parent Box's icon is the default icon
+ verifyIconOnHover(parentIconTag, null)
+ }
+
+ /**
+ * Setup: The hierarchy for this test is setup as: Parent Box (no custom icon set) ⤷ ChildA Box
+ * (custom icon = [PointerIcon.Text], overrideDescendants = TRUE) ⤷ ChildB Box (custom icon =
+ * [PointerIcon.Hand], overrideDescendants = TRUE)
+ *
+ * Expected Output: ChildA Box’s [PointerIcon.Text] wins for the entire surface area of ChildA's
+ * Box. ChildB Box's [PointerIcon.Hand] wins for the entire surface area of ChildB's Box. No
+ * icon wins for the remainder of the surface area of Parent Box that's not covered by ChildA
+ * Box or ChildB Box. In this example, it doesn't matter whether ChildA Box and ChildB Box's
+ * overrideDescendants = true or false because there's no competition for pointer icons in this
+ * example.
+ *
+ * Parent Box (output icon = null) ⤷ ChildA Box (output icon = [PointerIcon.Text]) ⤷ ChildB Box
+ * (output icon = [PointerIcon.Hand])
+ */
+ @Test
+ fun NonOverlappingSiblings_bothOverrideDescendants() {
+ rule.setContent {
+ CompositionLocalProvider(LocalPointerIconService provides iconService) {
+ Box(
+ modifier =
+ Modifier.requiredSize(200.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+ .testTag(parentIconTag)
+ ) {
+ Column {
+ Box(
+ Modifier.padding(20.dp)
+ .requiredSize(50.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+ .testTag(childIconTag)
+ .stylusHoverIcon(desiredChildIcon, overrideDescendants = true)
+ )
+ // Referencing grandchild tag/icon for ChildB in this test
+ Box(
+ Modifier.padding(40.dp)
+ .requiredSize(50.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+ .testTag(grandchildIconTag)
+ .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = true)
+ )
+ }
+ }
+ }
+ }
+ // Verify initial state of pointer icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+ // Verify ChildA Box's icon is the desired ChildA icon
+ verifyIconOnHover(childIconTag, desiredChildIcon)
+ // Verify ChildB Box's icon is the desired ChildB icon
+ verifyIconOnHover(grandchildIconTag, desiredGrandchildIcon)
+ // Verify remaining Parent Box's icon is the default icon
+ verifyIconOnHover(parentIconTag, null)
+ }
+
+ /**
+ * Setup: The hierarchy for this test is setup as: Parent Box (no custom icon set) ⤷ ChildA Box
+ * (custom icon = [PointerIcon.Text], overrideDescendants = FALSE) ⤷ ChildB Box (custom icon =
+ * [PointerIcon.Hand], overrideDescendants = FALSE) where ChildB Box's surface area overlaps
+ * with its sibling, ChildA, within the Parent Box
+ *
+ * Expected Output: ChildB Box's [PointerIcon.Hand] wins for the entire surface area of ChildB's
+ * Box. ChildA Box's [PointerIcon.Text] wins for the remaining surface area of ChildA Box not
+ * covered by ChildB Box. No icon wins for the remainder of the surface area of Parent Box
+ * that's not covered by ChildA Box or ChildB Box.
+ *
+ * Parent Box (output icon = null) ⤷ ChildA Box (output icon = [PointerIcon.Text]) ⤷ ChildB Box
+ * (output icon = [PointerIcon.Hand])
+ */
+ @Test
+ fun OverlappingSiblings_noOverrideDescendants() {
+ rule.setContent {
+ CompositionLocalProvider(LocalPointerIconService provides iconService) {
+ Box(
+ modifier =
+ Modifier.requiredSize(200.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+ .testTag(parentIconTag)
+ ) {
+ Box(
+ Modifier.padding(20.dp)
+ .requiredSize(120.dp, 60.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+ .testTag(childIconTag)
+ .stylusHoverIcon(desiredChildIcon, overrideDescendants = false)
+ )
+ // Referencing grandchild tag/icon for ChildB in this test
+ Box(
+ Modifier.padding(horizontal = 100.dp, vertical = 40.dp)
+ .requiredSize(120.dp, 20.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+ .testTag(grandchildIconTag)
+ .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = false)
+ )
+ }
+ }
+ }
+
+ verifyOverlappingSiblings()
+ }
+
+ /**
+ * Setup: The hierarchy for this test is setup as: Parent Box (no custom icon set) ⤷ ChildA Box
+ * (custom icon = [PointerIcon.Text], overrideDescendants = TRUE) ⤷ ChildB Box (custom icon =
+ * [PointerIcon.Hand], overrideDescendants = FALSE) where ChildB Box's surface area overlaps
+ * with its sibling, ChildA, within the Parent Box
+ *
+ * Expected Output: ChildB Box's [PointerIcon.Hand] wins for the entire surface area of ChildB's
+ * Box. ChildA Box's [PointerIcon.Text] wins for the remaining surface area of ChildA Box not
+ * covered by ChildB Box. No icon wins for the remainder of the surface area of Parent Box
+ * that's not covered by ChildA Box or ChildB Box. The overrideDescendants param only affects
+ * that element's children. So in this example, it doesn't matter whether ChildA Box's
+ * overrideDescendants = true because ChildB is its sibling and is therefore unaffected by this
+ * param.
+ *
+ * Parent Box (output icon = null) ⤷ ChildA Box (output icon = [PointerIcon.Text]) ⤷ ChildB Box
+ * (output icon = [PointerIcon.Hand])
+ */
+ @Test
+ fun OverlappingSiblings_childAOverridesDescendants() {
+ rule.setContent {
+ CompositionLocalProvider(LocalPointerIconService provides iconService) {
+ Box(
+ modifier =
+ Modifier.requiredSize(200.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+ .testTag(parentIconTag)
+ ) {
+ Box(
+ Modifier.padding(20.dp)
+ .requiredSize(120.dp, 60.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+ .testTag(childIconTag)
+ .stylusHoverIcon(desiredChildIcon, overrideDescendants = true)
+ )
+ // Referencing grandchild tag/icon for ChildB in this test
+ Box(
+ Modifier.padding(horizontal = 100.dp, vertical = 40.dp)
+ .requiredSize(120.dp, 20.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+ .testTag(grandchildIconTag)
+ .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = false)
+ )
+ }
+ }
+ }
+
+ verifyOverlappingSiblings()
+ }
+
+ /**
+ * Setup: The hierarchy for this test is setup as: Parent Box (no custom icon set) ⤷ ChildA Box
+ * (custom icon = [PointerIcon.Text], overrideDescendants = FALSE) ⤷ ChildB Box (custom icon =
+ * [PointerIcon.Hand], overrideDescendants = TRUE) where ChildB Box's surface area overlaps with
+ * its sibling, ChildA, within the Parent Box
+ *
+ * Expected Output: ChildB Box's [PointerIcon.Hand] wins for the entire surface area of ChildB's
+ * Box. ChildA Box's [PointerIcon.Text] wins for the remaining surface area of ChildA Box not
+ * covered by ChildB Box. No icon wins for the remainder of the surface area of Parent Box
+ * that's not covered by ChildA Box or ChildB Box. The overrideDescendants param only affects
+ * that element's children. So in this example, it doesn't matter whether ChildB Box's
+ * overrideDescendants = true because ChildA is its sibling and is therefore unaffected by this
+ * param.
+ *
+ * Parent Box (output icon = null) ⤷ ChildA Box (output icon = [PointerIcon.Text]) ⤷ ChildB Box
+ * (output icon = [PointerIcon.Hand])
+ */
+ @Test
+ fun OverlappingSiblings_childBOverridesDescendants() {
+ rule.setContent {
+ CompositionLocalProvider(LocalPointerIconService provides iconService) {
+ Box(
+ modifier =
+ Modifier.requiredSize(200.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+ .testTag(parentIconTag)
+ ) {
+ Box(
+ Modifier.padding(20.dp)
+ .requiredSize(120.dp, 60.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+ .testTag(childIconTag)
+ .stylusHoverIcon(desiredChildIcon, overrideDescendants = false)
+ )
+ // Referencing grandchild tag/icon for ChildB in this test
+ Box(
+ Modifier.padding(horizontal = 100.dp, vertical = 40.dp)
+ .requiredSize(120.dp, 20.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+ .testTag(grandchildIconTag)
+ .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = true)
+ )
+ }
+ }
+ }
+
+ verifyOverlappingSiblings()
+ }
+
+ /**
+ * Setup: The hierarchy for this test is setup as: Parent Box (no custom icon set) ⤷ ChildA Box
+ * (custom icon = [PointerIcon.Text], overrideDescendants = TRUE) ⤷ ChildB Box (custom icon =
+ * [PointerIcon.Hand], overrideDescendants = TRUE) where ChildB Box's surface area overlaps with
+ * its sibling, ChildA, within the Parent Box
+ *
+ * Expected Output: ChildB Box's [PointerIcon.Hand] wins for the entire surface area of ChildB's
+ * Box. ChildA Box's [PointerIcon.Text] wins for the remaining surface area of ChildA Box not
+ * covered by ChildB Box. No icon wins for the remainder of the surface area of Parent Box
+ * that's not covered by ChildA Box or ChildB Box. The overrideDescendants param only affects
+ * that element's children. So in this example, it doesn't matter whether ChildA Box or ChildB
+ * Box's overrideDescendants = true because ChildA and ChildB Boxes are siblings and are
+ * unaffected by each other's overrideDescendants param.
+ *
+ * Parent Box (output icon = null) ⤷ ChildA Box (output icon = [PointerIcon.Text]) ⤷ ChildB Box
+ * (output icon = [PointerIcon.Hand])
+ */
+ @Test
+ fun OverlappingSiblings_bothOverrideDescendants() {
+ rule.setContent {
+ CompositionLocalProvider(LocalPointerIconService provides iconService) {
+ Box(
+ modifier =
+ Modifier.requiredSize(200.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+ .testTag(parentIconTag)
+ ) {
+ Box(
+ Modifier.padding(20.dp)
+ .requiredSize(120.dp, 60.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+ .testTag(childIconTag)
+ .stylusHoverIcon(desiredChildIcon, overrideDescendants = true)
+ )
+ // Referencing grandchild tag/icon for ChildB in this test
+ Box(
+ Modifier.padding(horizontal = 100.dp, vertical = 40.dp)
+ .requiredSize(120.dp, 20.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+ .testTag(grandchildIconTag)
+ .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = true)
+ )
+ }
+ }
+ }
+
+ verifyOverlappingSiblings()
+ }
+
+ /**
+ * Setup: The hierarchy for this test is setup as: Parent Box (custom icon =
+ * [PointerIcon.Crosshair], overrideDescendants = TRUE) ⤷ ChildA Box (custom icon =
+ * [PointerIcon.Text], overrideDescendants = TRUE) ⤷ ChildB Box (custom icon =
+ * [PointerIcon.Hand], overrideDescendants = TRUE) where ChildB Box's surface area overlaps with
+ * its sibling, ChildA, within the Parent Box
+ *
+ * Expected Output: Parent Box's [PointerIcon.Crosshair] wins for the entire surface area of its
+ * box, including the surface area within ChildA Box and ChildB Box. Parent Box has
+ * overrideDescendants = true, which takes priority over any custom icon set by its children.
+ *
+ * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ ChildA Box (output icon =
+ * [PointerIcon.Crosshair]) ⤷ ChildB Box (output icon = [PointerIcon.Crosshair])
+ */
+ @Test
+ fun OverlappingSiblings_parentOverridesDescendants() {
+ rule.setContent {
+ CompositionLocalProvider(LocalPointerIconService provides iconService) {
+ Box(
+ modifier =
+ Modifier.requiredSize(200.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+ .testTag(parentIconTag)
+ .stylusHoverIcon(desiredParentIcon, overrideDescendants = true)
+ ) {
+ Box(
+ Modifier.padding(20.dp)
+ .requiredSize(120.dp, 60.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+ .testTag(childIconTag)
+ .stylusHoverIcon(desiredChildIcon, overrideDescendants = true)
+ )
+ // Referencing grandchild tag/icon for ChildB in this test
+ Box(
+ Modifier.padding(horizontal = 100.dp, vertical = 40.dp)
+ .requiredSize(120.dp, 20.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+ .testTag(grandchildIconTag)
+ .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = true)
+ )
+ }
+ }
+ }
+
+ // Verify initial state of pointer icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+ // Hover over ChildB (bottom right corner) and verify desired Parent icon
+ rule.onNodeWithTag(grandchildIconTag).performStylusInput { hoverEnter(bottomRight) }
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+ // Then hover to parent (bottom right corner) and icon hasn't changed
+ rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomRight) }
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+ // Then hover to ChildA (bottom left corner) and verify icon hasn't changed
+ rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(bottomLeft) }
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+ // Exit hovering
+ rule.onNodeWithTag(parentIconTag).performStylusInput { hoverExit() }
+ }
+
+ /**
+ * Setup: The hierarchy for this test is setup as: Parent Box (no custom icon set) ⤷ Child Box
+ * (no custom icon set) ⤷ Grandchild Box (custom icon = [PointerIcon.Hand], overrideDescendants
+ * = FALSE)
+ *
+ * Expected Output: Grandchild Box’s [PointerIcon.Hand] wins for the entire surface area of
+ * Grandchild's Box. No icon wins for the remainder of the surface area of Parent Box that isn't
+ * covered by Grandchild Box.
+ *
+ * Parent Box (output icon = null) ⤷ Child Box (output icon = null) ⤷ Grandchild Box (output
+ * icon = [PointerIcon.Hand])
+ */
+ @Test
+ fun multiLayeredNesting_grandchildCustomIconNoOverride() {
+ rule.setContent {
+ CompositionLocalProvider(LocalPointerIconService provides iconService) {
+ Box(
+ modifier =
+ Modifier.requiredSize(200.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+ .testTag(parentIconTag)
+ ) {
+ Box(
+ Modifier.padding(20.dp)
+ .requiredSize(150.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+ .testTag(childIconTag)
+ ) {
+ Box(
+ Modifier.padding(40.dp)
+ .requiredSize(100.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+ .testTag(grandchildIconTag)
+ .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = false)
+ )
+ }
+ }
+ }
+ }
+ // Verify initial state of pointer icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+ // Verify Grandchild Box's icon is the desired grandchild icon
+ verifyIconOnHover(grandchildIconTag, desiredGrandchildIcon)
+ // Verify remaining Child Box's area is the default arrow icon
+ verifyIconOnHover(childIconTag, null)
+ // Verify remaining Parent Box's area is the default arrow icon
+ verifyIconOnHover(parentIconTag, null)
+ }
+
+ /**
+ * Setup: The hierarchy for this test is setup as: Parent Box (no custom icon set) ⤷ Child Box
+ * (no custom icon set) ⤷ Grandchild Box (custom icon = [PointerIcon.Hand], overrideDescendants
+ * = TRUE)
+ *
+ * Expected Output: Grandchild Box’s [PointerIcon.Hand] wins for the entire surface area of
+ * Grandchild's Box. No icon wins for the remainder of the surface area of Parent Box that isn't
+ * covered by Grandchild Box.
+ *
+ * Parent Box (output icon = null) ⤷ Child Box (output icon = null) ⤷ Grandchild Box (output
+ * icon = [PointerIcon.Hand])
+ */
+ @Test
+ fun multiLayeredNesting_grandchildCustomIconHasOverride() {
+ rule.setContent {
+ CompositionLocalProvider(LocalPointerIconService provides iconService) {
+ Box(
+ modifier =
+ Modifier.requiredSize(200.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+ .testTag(parentIconTag)
+ ) {
+ Box(
+ Modifier.padding(20.dp)
+ .requiredSize(150.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+ .testTag(childIconTag)
+ ) {
+ Box(
+ Modifier.padding(40.dp)
+ .requiredSize(100.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+ .testTag(grandchildIconTag)
+ .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = true)
+ )
+ }
+ }
+ }
+ }
+ // Verify initial state of pointer icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+ // Verify Grandchild Box's icon is the desired grandchild icon
+ verifyIconOnHover(grandchildIconTag, desiredGrandchildIcon)
+ // Verify remaining Child Box's area is the default arrow icon
+ verifyIconOnHover(childIconTag, null)
+ // Verify remaining Parent Box's area is the default arrow icon
+ verifyIconOnHover(parentIconTag, null)
+ }
+
+ /**
+ * Setup: The hierarchy for this test is setup as: Parent Box (no custom icon set) ⤷ Child Box
+ * (custom icon = [PointerIcon.Text], overrideDescendants = FALSE) ⤷ Grandchild Box (custom icon
+ * = [PointerIcon.Hand], overrideDescendants = FALSE)
+ *
+ * Expected Output: Grandchild Box's [PointerIcon.Hand] wins for the entire surface area of the
+ * Grandchild Box. Child Box’s [PointerIcon.Text] wins for remaining surface area of its Box not
+ * covered by the Grandchild Box. No icon wins for the remainder of the surface area of Parent
+ * Box that isn't covered by Child Box.
+ *
+ * Parent Box (output icon = null) ⤷ Child Box (output icon = [PointerIcon.Text]) ⤷ Grandchild
+ * Box (output icon = [PointerIcon.Hand])
+ */
+ @Test
+ fun multiLayeredNesting_childAndGrandchildCustomIconsNoOverrides() {
+ rule.setContent {
+ CompositionLocalProvider(LocalPointerIconService provides iconService) {
+ Box(
+ modifier =
+ Modifier.requiredSize(200.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+ .testTag(parentIconTag)
+ ) {
+ Box(
+ Modifier.padding(20.dp)
+ .requiredSize(150.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+ .testTag(childIconTag)
+ .stylusHoverIcon(desiredChildIcon, overrideDescendants = false)
+ ) {
+ Box(
+ Modifier.padding(40.dp)
+ .requiredSize(100.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+ .testTag(grandchildIconTag)
+ .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = false)
+ )
+ }
+ }
+ }
+ }
+ // Verify initial state of pointer icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+ // Verify Grandchild Box's icon is the desired grandchild icon
+ verifyIconOnHover(grandchildIconTag, desiredGrandchildIcon)
+ // Verify remaining Child Box's icon is the desired child icon
+ verifyIconOnHover(childIconTag, desiredChildIcon)
+ // Verify remaining Parent Box's area is the default arrow icon
+ verifyIconOnHover(parentIconTag, null)
+ }
+
+ /**
+ * Setup: The hierarchy for this test is setup as: Parent Box (no custom icon set) ⤷ Child Box
+ * (custom icon = [PointerIcon.Text], overrideDescendants = FALSE) ⤷ Grandchild Box (custom icon
+ * = [PointerIcon.Hand], overrideDescendants = TRUE)
+ *
+ * Expected Output: Grandchild Box's [PointerIcon.Hand] wins for the entire surface area of the
+ * Grandchild Box. Child Box’s [PointerIcon.Text] wins for the remainder of the Child Box's
+ * surface area that's not covered by the Grandchild box. No icon wins for the remainder of the
+ * surface area of Parent Box that isn't covered by Child Box.
+ *
+ * Parent Box (output icon = null) ⤷ Child Box (output icon = [PointerIcon.Text]) ⤷ Grandchild
+ * Box (output icon = [PointerIcon.Hand])
+ */
+ @Test
+ fun multiLayeredNesting_childCustomIconGrandchildHasOverride() {
+ rule.setContent {
+ CompositionLocalProvider(LocalPointerIconService provides iconService) {
+ Box(
+ modifier =
+ Modifier.requiredSize(200.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+ .testTag(parentIconTag)
+ ) {
+ Box(
+ Modifier.padding(20.dp)
+ .requiredSize(150.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+ .testTag(childIconTag)
+ .stylusHoverIcon(desiredChildIcon, overrideDescendants = false)
+ ) {
+ Box(
+ Modifier.padding(40.dp)
+ .requiredSize(100.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+ .testTag(grandchildIconTag)
+ .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = true)
+ )
+ }
+ }
+ }
+ }
+ // Verify initial state of pointer icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+ // Verify Grandchild Box's icon is the desired grandchild icon
+ verifyIconOnHover(grandchildIconTag, desiredGrandchildIcon)
+ // Verify remaining Child Box's icon is the desired child icon
+ verifyIconOnHover(childIconTag, desiredChildIcon)
+ // Verify remaining Parent Box's area is the default arrow icon
+ verifyIconOnHover(parentIconTag, null)
+ }
+
+ /**
+ * Setup: The hierarchy for this test is setup as: Parent Box (no custom icon set) ⤷ Child Box
+ * (custom icon = [PointerIcon.Text], overrideDescendants = TRUE) ⤷ Grandchild Box (custom icon
+ * = [PointerIcon.Hand], overrideDescendants = FALSE)
+ *
+ * Expected Output: Child Box’s [PointerIcon.Text] wins for the entire surface area of its Box
+ * (including all of the Grandchild Box since it is contained within Child Box's surface area).
+ * No icon wins for the remainder of the surface area of Parent Box that isn't covered by Child
+ * Box.
+ *
+ * Parent Box (output icon = null) ⤷ Child Box (output icon = [PointerIcon.Text]) ⤷ Grandchild
+ * Box (output icon = [PointerIcon.Text])
+ */
+ @Test
+ fun multiLayeredNesting_grandchildCustomIconChildHasOverride() {
+ rule.setContent {
+ CompositionLocalProvider(LocalPointerIconService provides iconService) {
+ Box(
+ modifier =
+ Modifier.requiredSize(200.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+ .testTag(parentIconTag)
+ ) {
+ Box(
+ Modifier.padding(20.dp)
+ .requiredSize(150.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+ .testTag(childIconTag)
+ .stylusHoverIcon(desiredChildIcon, overrideDescendants = true)
+ ) {
+ Box(
+ Modifier.padding(40.dp)
+ .requiredSize(100.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+ .testTag(grandchildIconTag)
+ .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = false)
+ )
+ }
+ }
+ }
+ }
+ // Verify initial state of pointer icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+ // Verify Child Box's icon is the desired child icon
+ verifyIconOnHover(childIconTag, desiredChildIcon)
+ // Verify Grandchild Box is respecting Child Box's icon
+ verifyIconOnHover(grandchildIconTag, desiredChildIcon)
+ // Verify remaining Parent Box's area is the default arrow icon
+ verifyIconOnHover(parentIconTag, null)
+ }
+
+ /**
+ * Setup: The hierarchy for this test is setup as: Parent Box (no custom icon set) ⤷ Child Box
+ * (custom icon = [PointerIcon.Text], overrideDescendants = TRUE) ⤷ Grandchild Box (custom icon
+ * = [PointerIcon.Hand], overrideDescendants = TRUE)
+ *
+ * Expected Output: Child Box’s [PointerIcon.Text] wins for the entire surface area of its Box
+ * (including all of the Grandchild Box since it is contained within Child Box's surface area).
+ * No icon wins for the remainder of the surface area of Parent Box that isn't covered by Child
+ * Box.
+ *
+ * Parent Box (output icon = null) ⤷ Child Box (output icon = [PointerIcon.Text]) ⤷ Grandchild
+ * Box (output icon = [PointerIcon.Text])
+ */
+ @Test
+ fun multiLayeredNesting_childAndGrandchildOverrideDescendants() {
+ rule.setContent {
+ CompositionLocalProvider(LocalPointerIconService provides iconService) {
+ Box(
+ modifier =
+ Modifier.requiredSize(200.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+ .testTag(parentIconTag)
+ ) {
+ Box(
+ Modifier.padding(20.dp)
+ .requiredSize(150.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+ .testTag(childIconTag)
+ .stylusHoverIcon(desiredChildIcon, overrideDescendants = true)
+ ) {
+ Box(
+ Modifier.padding(40.dp)
+ .requiredSize(100.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+ .testTag(grandchildIconTag)
+ .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = true)
+ )
+ }
+ }
+ }
+ }
+ // Verify initial state of pointer icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+ // Verify Child Box's icon is the desired child icon
+ verifyIconOnHover(childIconTag, desiredChildIcon)
+ // Verify Grandchild Box is respecting Child Box's icon
+ verifyIconOnHover(grandchildIconTag, desiredChildIcon)
+ // Verify remaining Parent Box's area is the default arrow icon
+ verifyIconOnHover(parentIconTag, null)
+ }
+
+ /**
+ * Setup: The hierarchy for this test is setup as: Parent Box (custom icon =
+ * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box (no icon set) ⤷ Grandchild
+ * Box (custom icon = [PointerIcon.Hand], overrideDescendants = FALSE)
+ *
+ * Expected Output: Grandchild Box's [PointerIcon.Hand] wins for the entire surface area of the
+ * Grandchild Box. Parent Box’s [PointerIcon.Crosshair] wins for the remaining surface area of
+ * the Pare Box that's not covered by the Grandchild Box.
+ *
+ * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon =
+ * [PointerIcon.Crosshair]) ⤷ Grandchild Box (output icon = [PointerIcon.Hand])
+ */
+ @Test
+ fun multiLayeredNesting_parentAndGrandchildCustomIconNoOverrides() {
+ rule.setContent {
+ CompositionLocalProvider(LocalPointerIconService provides iconService) {
+ Box(
+ modifier =
+ Modifier.requiredSize(200.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+ .testTag(parentIconTag)
+ .stylusHoverIcon(desiredParentIcon, overrideDescendants = false)
+ ) {
+ Box(
+ Modifier.padding(20.dp)
+ .requiredSize(150.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+ .testTag(childIconTag)
+ ) {
+ Box(
+ Modifier.padding(40.dp)
+ .requiredSize(100.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+ .testTag(grandchildIconTag)
+ .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = false)
+ )
+ }
+ }
+ }
+ }
+ // Verify initial state of pointer icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+ // Verify Grandchild Box's icon is the desired grandchild icon
+ verifyIconOnHover(grandchildIconTag, desiredGrandchildIcon)
+ // Verify remaining Child Box is respecting Parent Box's icon
+ verifyIconOnHover(childIconTag, desiredParentIcon)
+ // Verify remaining Parent Box's icon is the desired parent icon
+ verifyIconOnHover(parentIconTag, desiredParentIcon)
+ }
+
+ /**
+ * Setup: The hierarchy for this test is setup as: Parent Box (custom icon =
+ * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box (no icon set) ⤷ Grandchild
+ * Box (custom icon = [PointerIcon.Hand], overrideDescendants = TRUE)
+ *
+ * Expected Output: Grandchild Box's [PointerIcon.Hand] wins for the entire surface area of its
+ * Box. Parent Box’s [PointerIcon.Crosshair] wins for the remaining surface area of the Pare Box
+ * that's not covered by the Grandchild Box.
+ *
+ * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon =
+ * [PointerIcon.Crosshair]) ⤷ Grandchild Box (output icon = [PointerIcon.Hand])
+ */
+ @Test
+ fun multiLayeredNesting_parentCustomIconGrandchildOverrides() {
+ rule.setContent {
+ CompositionLocalProvider(LocalPointerIconService provides iconService) {
+ Box(
+ modifier =
+ Modifier.requiredSize(200.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+ .testTag(parentIconTag)
+ .stylusHoverIcon(desiredParentIcon, overrideDescendants = false)
+ ) {
+ Box(
+ Modifier.padding(20.dp)
+ .requiredSize(150.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+ .testTag(childIconTag)
+ ) {
+ Box(
+ Modifier.padding(40.dp)
+ .requiredSize(100.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+ .testTag(grandchildIconTag)
+ .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = true)
+ )
+ }
+ }
+ }
+ }
+ // Verify initial state of pointer icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+ // Verify Grandchild Box's icon is the desired grandchild icon
+ verifyIconOnHover(grandchildIconTag, desiredGrandchildIcon)
+ // Verify remaining Child Box is respecting Parent Box's icon
+ verifyIconOnHover(childIconTag, desiredParentIcon)
+ // Verify remaining Parent Box's icon is the desired parent icon
+ verifyIconOnHover(parentIconTag, desiredParentIcon)
+ }
+
+ /**
+ * Setup: The hierarchy for this test is setup as: Parent Box (custom icon =
+ * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box (custom icon =
+ * [PointerIcon.Text], overrideDescendants = FALSE) ⤷ Grandchild Box (custom icon =
+ * [PointerIcon.Hand], overrideDescendants = FALSE)
+ *
+ * Expected Output: Grandchild Box's [PointerIcon.Hand] wins for the entire surface area of the
+ * Grandchild Box. Child Box's [PointerIcon.Text] wins for the remaining surface area of the
+ * Child Box not covered by the Grandchild Box. Parent Box’s [PointerIcon.Crosshair] wins for
+ * the remaining surface area not covered by the Child Box.
+ *
+ * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon =
+ * [PointerIcon.Text]) ⤷ Grandchild Box (output icon = [PointerIcon.Hand])
+ */
+ @Test
+ fun multiLayeredNesting_allCustomIconsNoOverrides() {
+ rule.setContent {
+ CompositionLocalProvider(LocalPointerIconService provides iconService) {
+ Box(
+ modifier =
+ Modifier.requiredSize(200.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+ .testTag(parentIconTag)
+ .stylusHoverIcon(desiredParentIcon, overrideDescendants = false)
+ ) {
+ Box(
+ Modifier.padding(20.dp)
+ .requiredSize(150.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+ .testTag(childIconTag)
+ .stylusHoverIcon(desiredChildIcon, overrideDescendants = false)
+ ) {
+ Box(
+ Modifier.padding(40.dp)
+ .requiredSize(100.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+ .testTag(grandchildIconTag)
+ .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = false)
+ )
+ }
+ }
+ }
+ }
+ // Verify initial state of pointer icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+ // Verify Grandchild Box's icon is the desired grandchild icon
+ verifyIconOnHover(grandchildIconTag, desiredGrandchildIcon)
+ // Verify remaining Child Box's icon is the desired child icon
+ verifyIconOnHover(childIconTag, desiredChildIcon)
+ // Verify remaining Parent Box's icon is the desired parent icon
+ verifyIconOnHover(parentIconTag, desiredParentIcon)
+ }
+
+ /**
+ * Setup: The hierarchy for this test is setup as: Parent Box (custom icon =
+ * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box (custom icon =
+ * [PointerIcon.Text], overrideDescendants = FALSE) ⤷ Grandchild Box (custom icon =
+ * [PointerIcon.Hand], overrideDescendants = TRUE)
+ *
+ * Expected Output: Grandchild Box's [PointerIcon.Hand] wins for the entire surface area of the
+ * Grandchild Box. Child Box's [PointerIcon.Text] wins for the remaining surface area of the
+ * Child Box not covered by the Grandchild Box. Parent Box’s [PointerIcon.Crosshair] wins for
+ * the remaining surface area not covered by the Child Box.
+ *
+ * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon =
+ * [PointerIcon.Text]) ⤷ Grandchild Box (output icon = [PointerIcon.Hand])
+ */
+ @Test
+ fun multiLayeredNesting_allCustomIconsGrandchildOverrides() {
+ rule.setContent {
+ CompositionLocalProvider(LocalPointerIconService provides iconService) {
+ Box(
+ modifier =
+ Modifier.requiredSize(200.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+ .testTag(parentIconTag)
+ .stylusHoverIcon(desiredParentIcon, overrideDescendants = false)
+ ) {
+ Box(
+ Modifier.padding(20.dp)
+ .requiredSize(150.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+ .testTag(childIconTag)
+ .stylusHoverIcon(desiredChildIcon, overrideDescendants = false)
+ ) {
+ Box(
+ Modifier.padding(40.dp)
+ .requiredSize(100.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+ .testTag(grandchildIconTag)
+ .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = true)
+ )
+ }
+ }
+ }
+ }
+ // Verify initial state of pointer icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+ // Verify Grandchild Box's icon is the desired grandchild icon
+ verifyIconOnHover(grandchildIconTag, desiredGrandchildIcon)
+ // Verify remaining Child Box's icon is the desired child icon
+ verifyIconOnHover(childIconTag, desiredChildIcon)
+ // Verify remaining Parent Box's icon is the desired parent icon
+ verifyIconOnHover(parentIconTag, desiredParentIcon)
+ }
+
+ /**
+ * Setup: The hierarchy for this test is setup as: Parent Box (custom icon =
+ * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box (custom icon =
+ * [PointerIcon.Text], overrideDescendants = TRUE) ⤷ Grandchild Box (custom icon =
+ * [PointerIcon.Hand], overrideDescendants = FALSE)
+ *
+ * Expected Output: Child Box's [PointerIcon.Hand] wins for the entire surface area of its Box.
+ * Parent Box’s [PointerIcon.Crosshair] wins for the remaining surface area of its Box.
+ *
+ * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon =
+ * [PointerIcon.Text]) ⤷ Grandchild Box (output icon = [PointerIcon.Text])
+ */
+ @Test
+ fun multiLayeredNesting_allCustomIconsChildOverrides() {
+ rule.setContent {
+ CompositionLocalProvider(LocalPointerIconService provides iconService) {
+ Box(
+ modifier =
+ Modifier.requiredSize(200.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+ .testTag(parentIconTag)
+ .stylusHoverIcon(desiredParentIcon, overrideDescendants = false)
+ ) {
+ Box(
+ Modifier.padding(20.dp)
+ .requiredSize(150.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+ .testTag(childIconTag)
+ .stylusHoverIcon(desiredChildIcon, overrideDescendants = true)
+ ) {
+ Box(
+ Modifier.padding(40.dp)
+ .requiredSize(100.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+ .testTag(grandchildIconTag)
+ .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = false)
+ )
+ }
+ }
+ }
+ }
+ // Verify initial state of pointer icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+ // Verify Child Box's icon is the desired child icon
+ verifyIconOnHover(childIconTag, desiredChildIcon)
+ // Verify Grandchild Box is respecting Child Box's icon
+ verifyIconOnHover(grandchildIconTag, desiredChildIcon)
+ // Verify remaining Parent Box's icon is the desired parent icon
+ verifyIconOnHover(parentIconTag, desiredParentIcon)
+ }
+
+ /**
+ * Setup: The hierarchy for this test is setup as: Parent Box (custom icon =
+ * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box (custom icon =
+ * [PointerIcon.Text], overrideDescendants = TRUE) ⤷ Grandchild Box (custom icon =
+ * [PointerIcon.Hand], overrideDescendants = TRUE)
+ *
+ * Expected Output: Child Box's [PointerIcon.Hand] wins for the entire surface area of its Box.
+ * Parent Box’s [PointerIcon.Crosshair] wins for the remaining surface area of its Box. The
+ * addition of Grandchild Box’s overrideDescendants = true in this test doesn’t impact the
+ * outcome; this is because Child Box is Grandchild Box's parent in the hierarchy and it already
+ * has overrideDescendants = true, which takes priority over anything Grandchild Box sets.
+ *
+ * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon =
+ * [PointerIcon.Text]) ⤷ Grandchild Box (output icon = [PointerIcon.Text])
+ */
+ @Test
+ fun multiLayeredNesting_allCustomIconsChildAndGrandchildOverrides() {
+ rule.setContent {
+ CompositionLocalProvider(LocalPointerIconService provides iconService) {
+ Box(
+ modifier =
+ Modifier.requiredSize(200.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+ .testTag(parentIconTag)
+ .stylusHoverIcon(desiredParentIcon, overrideDescendants = false)
+ ) {
+ Box(
+ Modifier.padding(20.dp)
+ .requiredSize(150.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+ .testTag(childIconTag)
+ .stylusHoverIcon(desiredChildIcon, overrideDescendants = true)
+ ) {
+ Box(
+ Modifier.padding(40.dp)
+ .requiredSize(100.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+ .testTag(grandchildIconTag)
+ .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = true)
+ )
+ }
+ }
+ }
+ }
+ // Verify initial state of pointer icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+ // Verify Child Box's icon is the desired child icon
+ verifyIconOnHover(childIconTag, desiredChildIcon)
+ // Verify Grandchild Box is respecting Child Box's icon
+ verifyIconOnHover(grandchildIconTag, desiredChildIcon)
+ // Verify remaining Parent Box's icon is the desired parent icon
+ verifyIconOnHover(parentIconTag, desiredParentIcon)
+ }
+
+ /**
+ * Setup: The hierarchy for this test is setup as: Parent Box (custom icon =
+ * [PointerIcon.Crosshair], overrideDescendants = TRUE) ⤷ Child Box (no icon set) ⤷ Grandchild
+ * Box (custom icon = [PointerIcon.Hand], overrideDescendants = FALSE)
+ *
+ * Expected Output: Parent Box’s [PointerIcon.Crosshair] wins for the entire surface area of its
+ * Box. Even though the Grandchild Box’s icon was set, the Parent Box will always take priority
+ * because it's the highestmost level in the hierarchy where overrideDescendants = true.
+ *
+ * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon =
+ * [PointerIcon.Crosshair]) ⤷ Grandchild Box (output icon = [PointerIcon.Crosshair])
+ */
+ @Test
+ fun multiLayeredNesting_parentGrandChildCustomIconsParentOverrides() {
+ rule.setContent {
+ CompositionLocalProvider(LocalPointerIconService provides iconService) {
+ Box(
+ modifier =
+ Modifier.requiredSize(200.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+ .testTag(parentIconTag)
+ .stylusHoverIcon(desiredParentIcon, overrideDescendants = true)
+ ) {
+ Box(
+ Modifier.padding(20.dp)
+ .requiredSize(150.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+ .testTag(childIconTag)
+ ) {
+ Box(
+ Modifier.padding(40.dp)
+ .requiredSize(100.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+ .testTag(grandchildIconTag)
+ .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = false)
+ )
+ }
+ }
+ }
+ }
+ // Verify initial state of pointer icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+ // Verify Parent Box's icon is the desired parent icon
+ verifyIconOnHover(parentIconTag, desiredParentIcon)
+ // Verify Child Box is respecting Parent Box's icon
+ verifyIconOnHover(childIconTag, desiredParentIcon)
+ // Verify Grandchild Box is respecting Parent Box's icon
+ verifyIconOnHover(grandchildIconTag, desiredParentIcon)
+ }
+
+ /**
+ * Setup: The hierarchy for this test is setup as: Parent Box (custom icon =
+ * [PointerIcon.Crosshair], overrideDescendants = TRUE) ⤷ Child Box (no icon set) ⤷ Grandchild
+ * Box (custom icon = [PointerIcon.Hand], overrideDescendants = TRUE)
+ *
+ * Expected Output: Parent Box’s [PointerIcon.Crosshair] wins for the entire surface area of its
+ * Box. Even though the Grandchild Box’s icon was set, the Parent Box will always take priority
+ * because it's the highestmost level in the hierarchy where overrideDescendants = true.
+ *
+ * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon =
+ * [PointerIcon.Crosshair]) ⤷ Grandchild Box (output icon = [PointerIcon.Crosshair])
+ */
+ @Test
+ fun multiLayeredNesting_parentGrandChildCustomIconsBothOverride() {
+ rule.setContent {
+ CompositionLocalProvider(LocalPointerIconService provides iconService) {
+ Box(
+ modifier =
+ Modifier.requiredSize(200.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+ .testTag(parentIconTag)
+ .stylusHoverIcon(desiredParentIcon, overrideDescendants = true)
+ ) {
+ Box(
+ Modifier.padding(20.dp)
+ .requiredSize(150.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+ .testTag(childIconTag)
+ .stylusHoverIcon(desiredChildIcon, overrideDescendants = false)
+ ) {
+ Box(
+ Modifier.padding(40.dp)
+ .requiredSize(100.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+ .testTag(grandchildIconTag)
+ .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = true)
+ )
+ }
+ }
+ }
+ }
+ // Verify initial state of pointer icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+ // Verify Parent Box's icon is the desired parent icon
+ verifyIconOnHover(parentIconTag, desiredParentIcon)
+ // Verify Child Box is respecting Parent Box's icon
+ verifyIconOnHover(childIconTag, desiredParentIcon)
+ // Verify Grandchild Box is respecting Parent Box's icon
+ verifyIconOnHover(grandchildIconTag, desiredParentIcon)
+ }
+
+ /**
+ * Setup: The hierarchy for this test is setup as: Parent Box (custom icon =
+ * [PointerIcon.Crosshair], overrideDescendants = TRUE) ⤷ Child Box (custom icon =
+ * [PointerIcon.Text], overrideDescendants = FALSE) ⤷ Grandchild Box (custom icon =
+ * [PointerIcon.Hand], overrideDescendants = FALSE)
+ *
+ * Expected Output: Parent Box’s [PointerIcon.Crosshair] wins for the entire surface area of its
+ * Box. Even though the Child and Grandchild Box’s icons were set, the Parent Box will always
+ * take priority because it's the highestmost level in the hierarchy where overrideDescendants =
+ * true.
+ *
+ * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon =
+ * [PointerIcon.Crosshair]) ⤷ Grandchild Box (output icon = [PointerIcon.Crosshair])
+ */
+ @Test
+ fun multiLayeredNesting_allCustomIconsParentOverrides() {
+ rule.setContent {
+ CompositionLocalProvider(LocalPointerIconService provides iconService) {
+ Box(
+ modifier =
+ Modifier.requiredSize(200.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+ .testTag(parentIconTag)
+ .stylusHoverIcon(desiredParentIcon, overrideDescendants = true)
+ ) {
+ Box(
+ Modifier.padding(20.dp)
+ .requiredSize(150.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+ .testTag(childIconTag)
+ .stylusHoverIcon(desiredChildIcon, overrideDescendants = false)
+ ) {
+ Box(
+ Modifier.padding(40.dp)
+ .requiredSize(100.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+ .testTag(grandchildIconTag)
+ .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = false)
+ )
+ }
+ }
+ }
+ }
+ // Verify initial state of pointer icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+ // Verify Parent Box's icon is the desired parent icon
+ verifyIconOnHover(parentIconTag, desiredParentIcon)
+ // Verify Child Box is respecting Parent Box's icon
+ verifyIconOnHover(childIconTag, desiredParentIcon)
+ // Verify Grandchild Box is respecting Parent Box's icon
+ verifyIconOnHover(grandchildIconTag, desiredParentIcon)
+ }
+
+ /**
+ * Setup: The hierarchy for this test is setup as: Parent Box (custom icon =
+ * [PointerIcon.Crosshair], overrideDescendants = TRUE) ⤷ Child Box (custom icon =
+ * [PointerIcon.Text], overrideDescendants = FALSE) ⤷ Grandchild Box (custom icon =
+ * [PointerIcon.Hand], overrideDescendants = TRUE)
+ *
+ * Expected Output: Parent Box’s [PointerIcon.Crosshair] wins for the entire surface area of its
+ * Box. Even though the Child and Grandchild Box’s icons were set, the Parent Box will always
+ * take priority because it's the highestmost level in the hierarchy where overrideDescendants =
+ * true.
+ *
+ * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon =
+ * [PointerIcon.Crosshair]) ⤷ Grandchild Box (output icon = [PointerIcon.Crosshair])
+ */
+ @Test
+ fun multiLayeredNesting_allCustomIconsParentAndGrandchildOverride() {
+ rule.setContent {
+ CompositionLocalProvider(LocalPointerIconService provides iconService) {
+ Box(
+ modifier =
+ Modifier.requiredSize(200.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+ .testTag(parentIconTag)
+ .stylusHoverIcon(desiredParentIcon, overrideDescendants = true)
+ ) {
+ Box(
+ Modifier.padding(20.dp)
+ .requiredSize(150.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+ .testTag(childIconTag)
+ .stylusHoverIcon(desiredChildIcon, overrideDescendants = false)
+ ) {
+ Box(
+ Modifier.padding(40.dp)
+ .requiredSize(100.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+ .testTag(grandchildIconTag)
+ .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = true)
+ )
+ }
+ }
+ }
+ }
+ // Verify initial state of pointer icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+ // Verify Parent Box's icon is the desired parent icon
+ verifyIconOnHover(parentIconTag, desiredParentIcon)
+ // Verify Child Box is respecting Parent Box's icon
+ verifyIconOnHover(childIconTag, desiredParentIcon)
+ // Verify Grandchild Box is respecting Parent Box's icon
+ verifyIconOnHover(grandchildIconTag, desiredParentIcon)
+ }
+
+ /**
+ * Setup: The hierarchy for this test is setup as: Parent Box (custom icon =
+ * [PointerIcon.Crosshair], overrideDescendants = TRUE) ⤷ Child Box (custom icon =
+ * [PointerIcon.Text], overrideDescendants = TRUE) ⤷ Grandchild Box (custom icon =
+ * [PointerIcon.Hand], overrideDescendants = FALSE)
+ *
+ * Expected Output: Parent Box’s [PointerIcon.Crosshair] wins for the entire surface area of its
+ * Box. Even though the Child and Grandchild Box’s icons were set, the Parent Box will always
+ * take priority because it's the highestmost level in the hierarchy where overrideDescendants =
+ * true.
+ *
+ * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon =
+ * [PointerIcon.Crosshair]) ⤷ Grandchild Box (output icon = [PointerIcon.Crosshair])
+ */
+ @Test
+ fun multiLayeredNesting_allCustomIconsParentAndChildOverride() {
+ rule.setContent {
+ CompositionLocalProvider(LocalPointerIconService provides iconService) {
+ Box(
+ modifier =
+ Modifier.requiredSize(200.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+ .testTag(parentIconTag)
+ .stylusHoverIcon(desiredParentIcon, overrideDescendants = true)
+ ) {
+ Box(
+ Modifier.padding(20.dp)
+ .requiredSize(150.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+ .testTag(childIconTag)
+ .stylusHoverIcon(desiredChildIcon, overrideDescendants = true)
+ ) {
+ Box(
+ Modifier.padding(40.dp)
+ .requiredSize(100.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+ .testTag(grandchildIconTag)
+ .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = false)
+ )
+ }
+ }
+ }
+ }
+ // Verify initial state of pointer icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+ // Verify Parent Box's icon is the desired parent icon
+ verifyIconOnHover(parentIconTag, desiredParentIcon)
+ // Verify Child Box is respecting Parent Box's icon
+ verifyIconOnHover(childIconTag, desiredParentIcon)
+ // Verify Grandchild Box is respecting Parent Box's icon
+ verifyIconOnHover(grandchildIconTag, desiredParentIcon)
+ }
+
+ /**
+ * Setup: The hierarchy for this test is setup as: Parent Box (custom icon =
+ * [PointerIcon.Crosshair], overrideDescendants = TRUE) ⤷ Child Box (custom icon =
+ * [PointerIcon.Text], overrideDescendants = TRUE) ⤷ Grandchild Box (custom icon =
+ * [PointerIcon.Hand], overrideDescendants = TRUE)
+ *
+ * Expected Output: Parent Box’s [PointerIcon.Crosshair] wins for the entire surface area of its
+ * Box. Even though the Child and Grandchild Box’s icons were set, the Parent Box will always
+ * take priority because it's the highestmost level in the hierarchy where overrideDescendants =
+ * true.
+ *
+ * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon =
+ * [PointerIcon.Crosshair]) ⤷ Grandchild Box (output icon = [PointerIcon.Crosshair])
+ */
+ @Test
+ fun multiLayeredNesting_allIconsOverride() {
+ rule.setContent {
+ CompositionLocalProvider(LocalPointerIconService provides iconService) {
+ Box(
+ modifier =
+ Modifier.requiredSize(200.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+ .testTag(parentIconTag)
+ .stylusHoverIcon(desiredParentIcon, overrideDescendants = true)
+ ) {
+ Box(
+ Modifier.padding(20.dp)
+ .requiredSize(150.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+ .testTag(childIconTag)
+ .stylusHoverIcon(desiredChildIcon, overrideDescendants = true)
+ ) {
+ Box(
+ Modifier.padding(40.dp)
+ .requiredSize(100.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+ .testTag(grandchildIconTag)
+ .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = true)
+ )
+ }
+ }
+ }
+ }
+ // Verify initial state of pointer icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+ // Verify Parent Box's icon is the desired parent icon
+ verifyIconOnHover(parentIconTag, desiredParentIcon)
+ // Verify Child Box is respecting Parent Box's icon
+ verifyIconOnHover(childIconTag, desiredParentIcon)
+ // Verify Grandchild Box is respecting Parent Box's icon
+ verifyIconOnHover(grandchildIconTag, desiredParentIcon)
+ }
+
+ /**
+ * This test takes an existing Box with a custom icon and changes the custom icon to a different
+ * custom icon while the cursor is hovered over the box.
+ */
+ @Test
+ fun dynamicallyUpdatedIcon() {
+ val icon = mutableStateOf(desiredChildIcon)
+
+ rule.setContent {
+ CompositionLocalProvider(LocalPointerIconService provides iconService) {
+ Box(
+ modifier =
+ Modifier.requiredSize(200.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+ .testTag(parentIconTag)
+ .stylusHoverIcon(desiredParentIcon, overrideDescendants = false)
+ ) {
+ Box(
+ Modifier.padding(20.dp)
+ .requiredSize(100.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+ .testTag(childIconTag)
+ .stylusHoverIcon(icon.value, overrideDescendants = false)
+ )
+ }
+ }
+ }
+
+ // Verify initial state of pointer icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+ // Hover over Child Box
+ rule.onNodeWithTag(childIconTag).performStylusInput { hoverEnter(bottomRight) }
+ // Verify Child Box has the desired child icon and dynamically update the icon assigned to
+ // the Child Box while hovering over Child Box
+ rule.runOnIdle {
+ assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon)
+ icon.value = desiredGrandchildIcon
+ }
+ // Verify the icon has been updated to the desired grandchild icon
+ rule.runOnIdle {
+ assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandchildIcon)
+ }
+ // Move cursor within Child Box and verify it still has the updated icon
+ rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(centerRight) }
+ rule.runOnIdle {
+ assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandchildIcon)
+ }
+ // Exit hovering over Child Box
+ rule.onNodeWithTag(childIconTag).performStylusInput { hoverExit() }
+ }
+
+ /**
+ * Setup: The hierarchy for the initial setup of this test is: Parent Box (custom icon =
+ * [PointerIcon.Crosshair], overrideDescendants = FALSE)
+ *
+ * After hovering over the center of the screen, the hierarchy under the cursor updates to:
+ * Parent Box (custom icon = [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box
+ * (custom icon = [PointerIcon.Text], overrideDescendants = FALSE)
+ *
+ * Expected Output: Initially, the Parent Box's [PointerIcon.Crosshair] should win for its
+ * entire surface area because it has no competition in the hierarchy for any other custom
+ * icons. After the Child Box is dynamically added under the cursor, the Child Box's
+ * [PointerIcon.Text] should win for the entire surface area of the Child Box. This also
+ * requires updating the user facing cursor icon to reflect the Child Box that was added under
+ * the cursor.
+ *
+ * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon =
+ * [PointerIcon.Text])
+ */
+ @Test
+ fun dynamicallyAddAndRemoveChild_noOverrideDescendants() {
+ val isChildVisible = mutableStateOf(false)
+
+ rule.setContent {
+ CompositionLocalProvider(LocalPointerIconService provides iconService) {
+ Box(
+ modifier =
+ Modifier.requiredSize(200.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+ .testTag(parentIconTag)
+ .stylusHoverIcon(desiredParentIcon, overrideDescendants = false),
+ contentAlignment = Alignment.Center
+ ) {
+ if (isChildVisible.value) {
+ Box(
+ modifier =
+ Modifier.padding(20.dp)
+ .requiredSize(100.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+ .testTag(childIconTag)
+ .stylusHoverIcon(desiredChildIcon, overrideDescendants = false)
+ )
+ }
+ }
+ }
+ }
+
+ // Verify initial state of pointer icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+ // Hover over center of Parent Box
+ rule.onNodeWithTag(parentIconTag).performStylusInput { hoverEnter(center) }
+ // Verify Parent Box has the desired parent icon and dynamically add the Child Box under the
+ // cursor
+ rule.runOnIdle {
+ assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon)
+ isChildVisible.value = true
+ }
+ // Verify the icon has been updated to the desired child icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) }
+ // Move cursor within Child Box and verify it still has the updated icon
+ rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(centerRight) }
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) }
+ // Move cursor outside Child Box and verify the icon is updated to the desired parent icon
+ rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomCenter) }
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+ // Move cursor back to the center of the Child Box
+ rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(center) }
+ // Dynamically remove the Child Box
+ rule.runOnIdle { isChildVisible.value = false }
+ // Verify the icon has been updated to the desired parent icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+ // Exit hovering over Parent Box
+ rule.onNodeWithTag(parentIconTag).performStylusInput { hoverExit() }
+ }
+
+ /**
+ * Setup: The hierarchy for the initial setup of this test is: Parent Box (custom icon =
+ * [PointerIcon.Crosshair], overrideDescendants = TRUE)
+ *
+ * After hovering over the center of the screen, the hierarchy under the cursor updates to:
+ * Parent Box (custom icon = [PointerIcon.Crosshair], overrideDescendants = TRUE) ⤷ Child Box
+ * (custom icon = [PointerIcon.Text], overrideDescendants = FALSE)
+ *
+ * Expected Output: The Parent Box's [PointerIcon.Crosshair] should win for its entire surface
+ * area regardless of whether the Child Box is visible or not. This is because the Parent Box's
+ * overrideDescendants = true, so its children should always respect Parent Box's custom icon.
+ *
+ * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon =
+ * [PointerIcon.Crosshair])
+ */
+ @Test
+ fun dynamicallyAddAndRemoveChild_parentOverridesDescendants() {
+ val isChildVisible = mutableStateOf(false)
+
+ rule.setContent {
+ CompositionLocalProvider(LocalPointerIconService provides iconService) {
+ Box(
+ modifier =
+ Modifier.requiredSize(200.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+ .testTag(parentIconTag)
+ .stylusHoverIcon(desiredParentIcon, overrideDescendants = true),
+ contentAlignment = Alignment.Center
+ ) {
+ if (isChildVisible.value) {
+ Box(
+ modifier =
+ Modifier.padding(20.dp)
+ .requiredSize(100.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+ .testTag(childIconTag)
+ .stylusHoverIcon(desiredChildIcon, overrideDescendants = false)
+ )
+ }
+ }
+ }
+ }
+
+ // Verify initial state of pointer icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+ // Hover over center of Parent Box
+ rule.onNodeWithTag(parentIconTag).performStylusInput { hoverEnter(center) }
+ // Verify Parent Box has the desired parent icon and dynamically add the Child Box under the
+ // cursor
+ rule.runOnIdle {
+ assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon)
+ isChildVisible.value = true
+ }
+ // Verify the icon stays as the parent icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+ // Move cursor within Child Box and verify it still is the parent icon
+ rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(centerRight) }
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+ // Move cursor outside Child Box and verify the icon is updated to the desired parent icon
+ rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomCenter) }
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+ // Move cursor back to the center of the Child Box
+ rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(center) }
+ // Dynamically remove the Child Box
+ rule.runOnIdle { isChildVisible.value = false }
+ // Verify the icon still the desired parent icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+ // Exit hovering over Parent Box
+ rule.onNodeWithTag(parentIconTag).performStylusInput { hoverExit() }
+ }
+
+ /**
+ * Setup: The hierarchy for the initial setup of this test is: Parent Box (custom icon =
+ * [PointerIcon.Crosshair], overrideDescendants = FALSE)
+ *
+ * After hovering over the center of the screen, the hierarchy under the cursor updates to:
+ * Parent Box (custom icon = [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box
+ * (custom icon = [PointerIcon.Text], overrideDescendants = FALSE) ⤷ Grandchild Box (custom icon
+ * = [PointerIcon.Hand], overrideDescendants = FALSE)
+ *
+ * Expected Output: Initially, the Parent Box's [PointerIcon.Crosshair] should win for its
+ * entire surface area because it has no competition in the hierarchy for any other custom
+ * icons. After the Child Box and the Grandchild Box are dynamically added under the cursor, the
+ * Grandchild Box's [PointerIcon.Hand] should win for the entire surface area of the Grandchild
+ * Box. The Child Box's [PointerIcon.Text] should win for the remaining surface area of the
+ * Child Box not covered by the Grandchild Box. This also requires updating the user facing
+ * cursor icon to reflect the Child Box and Grandchild Box that were added under the cursor.
+ *
+ * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon =
+ * [PointerIcon.Text]) ⤷ Grandchild Box (output icon = [PointerIcon.Hand])
+ */
+ @Test
+ fun dynamicallyAddAndRemoveChildAndGrandchild_noOverrideDescendants() {
+ val areDescendantsVisible = mutableStateOf(false)
+
+ rule.setContent {
+ CompositionLocalProvider(LocalPointerIconService provides iconService) {
+ Box(
+ modifier =
+ Modifier.requiredSize(200.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+ .testTag(parentIconTag)
+ .stylusHoverIcon(desiredParentIcon, overrideDescendants = false),
+ contentAlignment = Alignment.Center
+ ) {
+ if (areDescendantsVisible.value) {
+ Box(
+ modifier =
+ Modifier.padding(20.dp)
+ .requiredSize(150.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+ .testTag(childIconTag)
+ .stylusHoverIcon(desiredChildIcon, overrideDescendants = false),
+ contentAlignment = Alignment.Center
+ ) {
+ Box(
+ modifier =
+ Modifier.padding(40.dp)
+ .requiredSize(100.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+ .testTag(grandchildIconTag)
+ .stylusHoverIcon(
+ desiredGrandchildIcon,
+ overrideDescendants = false
+ )
+ )
+ }
+ }
+ }
+ }
+ }
+
+ // Verify initial state of pointer icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+ // Hover over center of Parent Box
+ rule.onNodeWithTag(parentIconTag).performStylusInput { hoverEnter(center) }
+ // Verify Parent Box has the desired parent icon and dynamically add the Child Box and
+ // Grandchild Box under the cursor
+ rule.runOnIdle {
+ assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon)
+ areDescendantsVisible.value = true
+ }
+ // Verify the icon has been updated to the desired grandchild icon
+ rule.runOnIdle {
+ assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandchildIcon)
+ }
+ // Move cursor within Grandchild Box and verify it still has the grandchild icon
+ rule.onNodeWithTag(grandchildIconTag).performStylusInput { hoverMoveTo(centerRight) }
+ rule.runOnIdle {
+ assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandchildIcon)
+ }
+ // Move cursor outside Grandchild Box within Child Box and verify it has the child icon
+ rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(bottomRight) }
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) }
+ // Move cursor outside Child Box and verify the icon is updated to the desired parent icon
+ rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomCenter) }
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+ // Move cursor back to the center of the Grandchild Box
+ rule.onNodeWithTag(grandchildIconTag).performStylusInput { hoverMoveTo(center) }
+ // Dynamically remove the Child Box and Grandchild Box
+ rule.runOnIdle { areDescendantsVisible.value = false }
+ // Verify the icon has been updated to the desired parent icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+ // Exit hovering over Parent Box
+ rule.onNodeWithTag(parentIconTag).performStylusInput { hoverExit() }
+ }
+
+ /**
+ * Setup: The hierarchy for the initial setup of this test is: Parent Box (custom icon =
+ * [PointerIcon.Crosshair], overrideDescendants = FALSE)
+ *
+ * After hovering over the center of the screen, the hierarchy under the cursor updates to:
+ * Parent Box (custom icon = [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box
+ * (custom icon = [PointerIcon.Text], overrideDescendants = FALSE) ⤷ Grandchild Box (custom icon
+ * = [PointerIcon.Hand], overrideDescendants = TRUE)
+ *
+ * Expected Output: Initially, the Parent Box's [PointerIcon.Crosshair] should win for its
+ * entire surface area because it has no competition in the hierarchy for any other custom
+ * icons. After the Child Box and the Grandchild Box are dynamically added under the cursor, the
+ * Grandchild Box's [PointerIcon.Hand] should win for the entire surface area of the Grandchild
+ * Box. Because the Grandchild Box is the lowest level in the hierarchy, the outcome doesn't
+ * change whether it has overrideDescendants = true or not. The Child Box's [PointerIcon.Text]
+ * should win for the remaining surface area of the Child Box not covered by the Grandchild Box.
+ * This also requires updating the user facing cursor icon to reflect the Child Box and
+ * Grandchild Box that were added under the cursor.
+ *
+ * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon =
+ * [PointerIcon.Text]) ⤷ Grandchild Box (output icon = [PointerIcon.Hand])
+ */
+ @Test
+ fun dynamicallyAddAndRemoveChildAndGrandchild_grandchildOverridesDescendants() {
+ val areDescendantsVisible = mutableStateOf(false)
+
+ rule.setContent {
+ CompositionLocalProvider(LocalPointerIconService provides iconService) {
+ Box(
+ modifier =
+ Modifier.requiredSize(200.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+ .testTag(parentIconTag)
+ .stylusHoverIcon(desiredParentIcon, overrideDescendants = false),
+ contentAlignment = Alignment.Center
+ ) {
+ if (areDescendantsVisible.value) {
+ Box(
+ modifier =
+ Modifier.padding(20.dp)
+ .requiredSize(150.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+ .testTag(childIconTag)
+ .stylusHoverIcon(desiredChildIcon, overrideDescendants = false),
+ contentAlignment = Alignment.Center
+ ) {
+ Box(
+ modifier =
+ Modifier.padding(40.dp)
+ .requiredSize(100.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+ .testTag(grandchildIconTag)
+ .stylusHoverIcon(
+ desiredGrandchildIcon,
+ overrideDescendants = true
+ )
+ )
+ }
+ }
+ }
+ }
+ }
+
+ // Verify initial state of pointer icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+ // Hover over center of Parent Box
+ rule.onNodeWithTag(parentIconTag).performStylusInput { hoverEnter(center) }
+ // Verify Parent Box has the desired parent icon and dynamically add the Child Box and
+ // Grandchild Box under the cursor
+ rule.runOnIdle {
+ assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon)
+ areDescendantsVisible.value = true
+ }
+ // Verify the icon has been updated to the desired grandchild icon
+ rule.runOnIdle {
+ assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandchildIcon)
+ }
+ // Move cursor within Grandchild Box and verify it still has the grandchild icon
+ rule.onNodeWithTag(grandchildIconTag).performStylusInput { hoverMoveTo(centerRight) }
+ rule.runOnIdle {
+ assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandchildIcon)
+ }
+ // Move cursor outside Grandchild Box within Child Box and verify it has the child icon
+ rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(bottomRight) }
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) }
+ // Move cursor outside Child Box and verify the icon is updated to the desired parent icon
+ rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomCenter) }
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+ // Move cursor back to the center of the Grandchild Box
+ rule.onNodeWithTag(grandchildIconTag).performStylusInput { hoverMoveTo(center) }
+ // Dynamically remove the Child Box and Grandchild Box
+ rule.runOnIdle { areDescendantsVisible.value = false }
+ // Verify the icon has been updated to the desired parent icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+ // Exit hovering over Parent Box
+ rule.onNodeWithTag(parentIconTag).performStylusInput { hoverExit() }
+ }
+
+ /**
+ * Setup: The hierarchy for the initial setup of this test is: Parent Box (custom icon =
+ * [PointerIcon.Crosshair], overrideDescendants = FALSE)
+ *
+ * After hovering over the center of the screen, the hierarchy under the cursor updates to:
+ * Parent Box (custom icon = [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box
+ * (custom icon = [PointerIcon.Text], overrideDescendants = TRUE) ⤷ Grandchild Box (custom icon
+ * = [PointerIcon.Hand], overrideDescendants = FALSE)
+ *
+ * Expected Output: Initially, the Parent Box's [PointerIcon.Crosshair] should win for its
+ * entire surface area because it has no competition in the hierarchy for any other custom
+ * icons. After the Child Box and the Grandchild Box are dynamically added under the cursor, the
+ * Child Box's [PointerIcon.Text] should win for the entire surface area of the Child Box. This
+ * includes the Grandchild Box's [PointerIcon.Text] should win for the remaining surface area of
+ * the Child Box not covered by the Grandchild Box. This also requires updating the user facing
+ * cursor icon to reflect the Child Box and Grandchild Box that were added under the cursor.
+ *
+ * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon =
+ * [PointerIcon.Text]) ⤷ Grandchild Box (output icon = [PointerIcon.Hand])
+ */
+ @Test
+ fun dynamicallyAddAndRemoveChildAndGrandchild_childOverridesDescendants() {
+ val areDescendantsVisible = mutableStateOf(false)
+
+ rule.setContent {
+ CompositionLocalProvider(LocalPointerIconService provides iconService) {
+ Box(
+ modifier =
+ Modifier.requiredSize(200.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+ .testTag(parentIconTag)
+ .stylusHoverIcon(desiredParentIcon, overrideDescendants = false),
+ contentAlignment = Alignment.Center
+ ) {
+ if (areDescendantsVisible.value) {
+ Box(
+ modifier =
+ Modifier.padding(20.dp)
+ .requiredSize(150.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+ .testTag(childIconTag)
+ .stylusHoverIcon(desiredChildIcon, overrideDescendants = true),
+ contentAlignment = Alignment.Center
+ ) {
+ Box(
+ modifier =
+ Modifier.padding(40.dp)
+ .requiredSize(100.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+ .testTag(grandchildIconTag)
+ .stylusHoverIcon(
+ desiredGrandchildIcon,
+ overrideDescendants = false
+ )
+ )
+ }
+ }
+ }
+ }
+ }
+
+ // Verify initial state of pointer icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+ // Hover over center of Parent Box
+ rule.onNodeWithTag(parentIconTag).performStylusInput { hoverEnter(center) }
+ // Verify Parent Box has the desired parent icon, then dynamically add the Child Box and
+ // Grandchild Box under the cursor
+ rule.runOnIdle {
+ assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon)
+ areDescendantsVisible.value = true
+ }
+ // Verify the icon has been updated to the desired child icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) }
+ // Move cursor within Grandchild Box and verify it still has the child icon
+ rule.onNodeWithTag(grandchildIconTag).performStylusInput { hoverMoveTo(centerRight) }
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) }
+ // Move cursor outside Grandchild Box within Child Box to verify it still has the child icon
+ rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(bottomRight) }
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) }
+ // Move cursor outside Child Box and verify the icon is updated to the desired parent icon
+ rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomCenter) }
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+ // Move cursor back to the center of the Grandchild Box
+ rule.onNodeWithTag(grandchildIconTag).performStylusInput { hoverMoveTo(center) }
+ // Dynamically remove the Child Box and Grandchild Box
+ rule.runOnIdle { areDescendantsVisible.value = false }
+ // Verify the icon has been updated to the desired parent icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+ // Exit hovering over Parent Box
+ rule.onNodeWithTag(parentIconTag).performStylusInput { hoverExit() }
+ }
+
+ /**
+ * Setup: The hierarchy for the initial setup of this test is: Child Box (custom icon =
+ * [PointerIcon.Text], overrideDescendants = FALSE)
+ *
+ * After hovering over the center of the screen, the hierarchy under the cursor updates to:
+ * Parent Box (custom icon = [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box
+ * (custom icon = [PointerIcon.Text], overrideDescendants = FALSE)
+ *
+ * Expected Output: The Child Box's [PointerIcon.Text] should win for its entire surface area
+ * regardless of whether there's a Parent Box present or not. This is because the Parent Box has
+ * overrideDescendants = false and should therefore not have its custom icon take priority over
+ * the Child Box's custom icon. The Parent Box's [PointerIcon.Crosshair] should win for its
+ * remaining surface area not covered by the Child Box.
+ *
+ * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon =
+ * [PointerIcon.Text])
+ */
+ @Test
+ fun dynamicallyAddAndRemoveParent_noOverrideDescendants() {
+ val isParentVisible = mutableStateOf(false)
+ val child = movableContentOf {
+ Box(
+ modifier =
+ Modifier.requiredSize(150.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+ .testTag(childIconTag)
+ .stylusHoverIcon(desiredChildIcon, overrideDescendants = false)
+ )
+ }
+
+ rule.setContent {
+ CompositionLocalProvider(LocalPointerIconService provides iconService) {
+ if (isParentVisible.value) {
+ Box(
+ modifier =
+ Modifier.requiredSize(200.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+ .testTag(parentIconTag)
+ .stylusHoverIcon(desiredParentIcon, overrideDescendants = false),
+ contentAlignment = Alignment.Center
+ ) {
+ child()
+ }
+ } else {
+ child()
+ }
+ }
+ }
+
+ // Verify initial state of pointer icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+ // Hover over center of Child Box
+ rule.onNodeWithTag(childIconTag).performStylusInput { hoverEnter(center) }
+ // Verify Child Box has the desired child icon and dynamically add the Parent Box under the
+ // cursor
+ rule.runOnIdle {
+ assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon)
+ isParentVisible.value = true
+ }
+ // Verify the icon stays as the desired child icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) }
+ // Move cursor within Child Box and verify it still has the child icon
+ rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(centerRight) }
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) }
+ // Move cursor outside Child Box and verify the icon is updated to the desired parent icon
+ rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomCenter) }
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+ // Move cursor back to the center of the Child Box
+ rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(center) }
+ // Dynamically remove the Parent Box
+ rule.runOnIdle { isParentVisible.value = false }
+ // Verify the icon stays as the desired child icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) }
+ // Exit hovering over Child Box
+ rule.onNodeWithTag(childIconTag).performStylusInput { hoverExit() }
+ }
+
+ /**
+ * Setup: The hierarchy for the initial setup of this test is: Child Box (custom icon =
+ * [PointerIcon.Text], overrideDescendants = FALSE)
+ *
+ * After hovering over the center of the screen, the hierarchy under the cursor updates to:
+ * Parent Box (custom icon = [PointerIcon.Crosshair], overrideDescendants = TRUE) ⤷ Child Box
+ * (custom icon = [PointerIcon.Text], overrideDescendants = FALSE)
+ *
+ * Expected Output: The Child Box's [PointerIcon.Text] should win for its entire surface area
+ * when the Parent Box isn't present. Once the Parent Box becomes visible, the Parent Box's
+ * [PointerIcon.Crosshair] should win for its entire surface area. This is because the Parent
+ * Box's overrideDescendants = true, so its children should always respect Parent Box's custom
+ * icon. This also requires updating the user facing cursor icon to reflect the Parent Box that
+ * was added under the cursor.
+ *
+ * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon =
+ * [PointerIcon.Crosshair])
+ */
+ @Test
+ fun dynamicallyAddAndRemoveParent_parentOverridesDescendants() {
+ val isParentVisible = mutableStateOf(false)
+ val child = movableContentOf {
+ Box(
+ modifier =
+ Modifier.requiredSize(150.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+ .testTag(childIconTag)
+ .stylusHoverIcon(desiredChildIcon, overrideDescendants = false)
+ )
+ }
+
+ rule.setContent {
+ CompositionLocalProvider(LocalPointerIconService provides iconService) {
+ if (isParentVisible.value) {
+ Box(
+ modifier =
+ Modifier.requiredSize(200.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+ .testTag(parentIconTag)
+ .stylusHoverIcon(desiredParentIcon, overrideDescendants = true),
+ contentAlignment = Alignment.Center
+ ) {
+ child()
+ }
+ } else {
+ child()
+ }
+ }
+ }
+
+ // Verify initial state of pointer icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+ // Hover over center of Child Box
+ rule.onNodeWithTag(childIconTag).performStylusInput { hoverEnter(center) }
+ // Verify Child Box has the desired child icon and dynamically add the Parent Box under the
+ // cursor
+ rule.runOnIdle {
+ assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon)
+ isParentVisible.value = true
+ }
+ // Verify the icon has been updated to the desired parent icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+ // Move cursor within Child Box and verify it still has the parent icon
+ rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(centerRight) }
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+ // Move cursor outside Child Box and verify the icon is still the parent icon
+ rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomCenter) }
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+ // Move cursor back to the center of the Child Box
+ rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(center) }
+ // Dynamically remove the Parent Box
+ rule.runOnIdle { isParentVisible.value = false }
+ // Verify the icon has been updated to the desired child icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) }
+ // Exit hovering over Child Box
+ rule.onNodeWithTag(childIconTag).performStylusInput { hoverExit() }
+ }
+
+ /**
+ * Setup: The hierarchy for the initial setup of this test is: Parent Box (custom icon =
+ * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Grandchild Box (custom icon =
+ * [PointerIcon.Hand], overrideDescendants = FALSE)
+ *
+ * After hovering over the center of the screen, the hierarchy under the cursor updates to:
+ * Parent Box (custom icon = [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box
+ * (custom icon = [PointerIcon.Text], overrideDescendants = FALSE) ⤷ Grandchild Box (custom icon
+ * = [PointerIcon.Hand], overrideDescendants = FALSE)
+ *
+ * Expected Output: The Grandchild Box's [PointerIcon.Hand] should win for its entire surface
+ * area regardless of whether there's a Child Box or Parent Box present. This is because the
+ * Parent Box and Child Box have overrideDescendants = false and should therefore not have their
+ * custom icons take priority over the Grandchild Box's custom icon. The Child Box should win
+ * for its remaining surface area not covered by the Grandchild Box. The Parent Box's
+ * [PointerIcon.Crosshair] should win for its remaining surface area not covered by the Child
+ * Box.
+ *
+ * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon =
+ * [PointerIcon.Text]) ⤷ Grandchild Box (output icon = [PointerIcon.Hand])
+ */
+ @Test
+ fun dynamicallyAddAndRemoveNestedChild_noOverrideDescendants() {
+ val isChildVisible = mutableStateOf(false)
+ val grandchild = movableContentOf {
+ Box(
+ modifier =
+ Modifier.requiredSize(100.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+ .testTag(grandchildIconTag)
+ .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = false)
+ )
+ }
+
+ rule.setContent {
+ CompositionLocalProvider(LocalPointerIconService provides iconService) {
+ Box(
+ modifier =
+ Modifier.requiredSize(200.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+ .testTag(parentIconTag)
+ .stylusHoverIcon(desiredParentIcon, overrideDescendants = false),
+ contentAlignment = Alignment.Center
+ ) {
+ if (isChildVisible.value) {
+ Box(
+ modifier =
+ Modifier.requiredSize(150.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+ .testTag(childIconTag)
+ .stylusHoverIcon(desiredChildIcon, overrideDescendants = false),
+ contentAlignment = Alignment.Center
+ ) {
+ grandchild()
+ }
+ } else {
+ grandchild()
+ }
+ }
+ }
+ }
+
+ // Verify initial state of pointer icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+ // Hover over center of Grandchild Box
+ rule.onNodeWithTag(grandchildIconTag).performStylusInput { hoverEnter(center) }
+ // Verify Grandchild Box has the desired grandchild icon and dynamically add the Child Box
+ // under the cursor
+ rule.runOnIdle {
+ assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandchildIcon)
+ isChildVisible.value = true
+ }
+ // Verify the icon stays as the desired grandchild icon
+ rule.runOnIdle {
+ assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandchildIcon)
+ }
+ // Move cursor within Grandchild Box and verify it still has the grandchild icon
+ rule.onNodeWithTag(grandchildIconTag).performStylusInput { hoverMoveTo(centerRight) }
+ rule.runOnIdle {
+ assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandchildIcon)
+ }
+ // Move cursor outside Grandchild Box within Child Box to verify icon is now the child icon
+ rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(bottomRight) }
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) }
+ // Move cursor outside Child Box and verify the icon is updated to the desired parent icon
+ rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomRight) }
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+ // Move cursor back to the center of the Grandchild Box
+ rule.onNodeWithTag(grandchildIconTag).performStylusInput { hoverMoveTo(center) }
+ // Dynamically remove the Child Box
+ rule.runOnIdle { isChildVisible.value = false }
+ // Verify the icon has been updated to the desired grandchild icon
+ rule.runOnIdle {
+ assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandchildIcon)
+ }
+ // Exit hovering over Parent Box
+ rule.onNodeWithTag(parentIconTag).performStylusInput { hoverExit() }
+ }
+
+ /**
+ * Setup: The hierarchy for the initial setup of this test is: Parent Box (custom icon =
+ * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Grandchild Box (custom icon =
+ * [PointerIcon.Hand], overrideDescendants = FALSE)
+ *
+ * After hovering over the center of the screen, the hierarchy under the cursor updates to:
+ * Parent Box (custom icon = [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box
+ * (custom icon = [PointerIcon.Text], overrideDescendants = TRUE) ⤷ Grandchild Box (custom icon
+ * = [PointerIcon.Hand], overrideDescendants = FALSE)
+ *
+ * Expected Output: The Grandchild Box's [PointerIcon.Hand] should win for its entire surface
+ * area regardless of whether there's a Child Box or Parent Box present. This is because the
+ * Parent Box and Child Box have overrideDescendants = false and should therefore not have thei
+ * custom icona take priority over the Grandchild Box's custom icon. The Child Box should win
+ * for its remaining surface area not covered by the Grandchild Box. The Parent Box's
+ * [PointerIcon.Crosshair] should win for its remaining surface area not covered by the Child
+ * Box. Initially, the Parent Box's [PointerIcon.Crosshair] should win for its entire surface
+ * area because it has no competition in the hierarchy for any other custom icons. After the
+ * Child Box and the Grandchild Box are dynamically added under the cursor, the Child Box's
+ * [PointerIcon.Text] should win for the entire surface area of the Child Box. This includes the
+ * Grandchild Box's [PointerIcon.Text] should win for the remaining surface area of the Child
+ * Box not covered by the Grandchild Box. This also requires updating the user facing cursor
+ * icon to reflect the Child Box and Grandchild Box that were added under the cursor.
+ *
+ * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon =
+ * [PointerIcon.Text]) ⤷ Grandchild Box (output icon = [PointerIcon.Hand])
+ */
+ @Test
+ fun dynamicallyAddAndRemoveNestedChild_ChildOverridesDescendants() {
+ val isChildVisible = mutableStateOf(false)
+ val grandchild = movableContentOf {
+ Box(
+ modifier =
+ Modifier.requiredSize(100.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+ .testTag(grandchildIconTag)
+ .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = false)
+ )
+ }
+
+ rule.setContent {
+ CompositionLocalProvider(LocalPointerIconService provides iconService) {
+ Box(
+ modifier =
+ Modifier.requiredSize(200.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+ .testTag(parentIconTag)
+ .stylusHoverIcon(desiredParentIcon, overrideDescendants = false),
+ contentAlignment = Alignment.Center
+ ) {
+ if (isChildVisible.value) {
+ Box(
+ modifier =
+ Modifier.requiredSize(150.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+ .testTag(childIconTag)
+ .stylusHoverIcon(desiredChildIcon, overrideDescendants = true),
+ contentAlignment = Alignment.Center
+ ) {
+ grandchild()
+ }
+ } else {
+ grandchild()
+ }
+ }
+ }
+ }
+
+ // Verify initial state of pointer icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+ // Hover over center of Grandchild Box
+ rule.onNodeWithTag(grandchildIconTag).performStylusInput { hoverEnter(center) }
+ // Verify Grandchild Box has the desired grandchild icon and dynamically add the Child Box
+ // under the cursor
+ rule.runOnIdle {
+ assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandchildIcon)
+ isChildVisible.value = true
+ }
+ // Verify the icon has been updated to the desired child icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) }
+ // Move cursor within Grandchild Box and verify it still has the child icon
+ rule.onNodeWithTag(grandchildIconTag).performStylusInput { hoverMoveTo(centerRight) }
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) }
+ // Move cursor outside Grandchild Box within Child Box to verify it still has the child icon
+ rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(bottomRight) }
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) }
+ // Move cursor outside Child Box and verify the icon is updated to the desired parent icon
+ rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomCenter) }
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+ // Move cursor back to center of the Grandchild Box
+ rule.onNodeWithTag(grandchildIconTag).performStylusInput { hoverMoveTo(center) }
+ // Dynamically remove the Child Box
+ rule.runOnIdle { isChildVisible.value = false }
+ // Verify the icon has been updated to the desired grandchild icon
+ rule.runOnIdle {
+ assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandchildIcon)
+ }
+ // Exit hovering over Parent Box
+ rule.onNodeWithTag(parentIconTag).performStylusInput { hoverExit() }
+ }
+
+ /**
+ * Setup: The hierarchy for the initial setup of this test is: Grandparent Box (custom icon =
+ * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Parent Box (custom icon =
+ * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Grandchild Box (custom icon =
+ * [PointerIcon.Hand], overrideDescendants = FALSE)
+ *
+ * After hovering over the corner of the Grandparent Box that doesn't overlap with any
+ * descendant, the hierarchy of the screen updates to: Grandparent Box (custom icon =
+ * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Parent Box (custom icon =
+ * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box (custom icon =
+ * [PointerIcon.Text], overrideDescendants = FALSE) ⤷ Grandchild Box (custom icon =
+ * [PointerIcon.Hand], overrideDescendants = FALSE)
+ *
+ * Expected Output: The Grandchild Box's [PointerIcon.Hand] should win for its entire surface
+ * area regardless of whether there's a Child, Parent, or Grandparent Box present. This is
+ * because the Grandparent, Parent, and Child Boxes have overrideDescendants = false and should
+ * therefore not have their custom icons take priority over the Grandchild Box's custom icon.
+ * The Child Box should win for its remaining surface area not covered by the Grandchild Box.
+ * The Parent Box's [PointerIcon.Crosshair] should win for its remaining surface area not
+ * covered by the Child Box. And the Grandparent Box should win for its remaining surface area
+ * not covered by the Parent Box.
+ *
+ * Grandparent Box (output icon = [PointerIcon.Crosshair]) ⤷ Parent Box (output icon =
+ * [PointerIcon.Crosshair]) ⤷ Child Box (output icon = [PointerIcon.Text]) ⤷ Grandchild Box
+ * (output icon = [PointerIcon.Hand])
+ */
+ @Test
+ fun dynamicallyAddAndRemoveNestedChild_notHoveredOverChild() {
+ val grandparentIconTag = "myGrandparentIcon"
+ val desiredGrandparentIcon = desiredParentIcon
+ val isChildVisible = mutableStateOf(false)
+ val grandchild = movableContentOf {
+ Box(
+ modifier =
+ Modifier.requiredSize(100.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+ .testTag(grandchildIconTag)
+ .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = false)
+ )
+ }
+
+ rule.setContent {
+ CompositionLocalProvider(LocalPointerIconService provides iconService) {
+ Box(
+ modifier =
+ Modifier.requiredSize(200.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+ .testTag(grandparentIconTag)
+ .stylusHoverIcon(desiredGrandparentIcon, overrideDescendants = false),
+ contentAlignment = Alignment.Center
+ ) {
+ Box(
+ modifier =
+ Modifier.requiredSize(175.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+ .testTag(parentIconTag)
+ .stylusHoverIcon(desiredParentIcon, overrideDescendants = false),
+ contentAlignment = Alignment.Center
+ ) {
+ if (isChildVisible.value) {
+ Box(
+ modifier =
+ Modifier.requiredSize(150.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+ .testTag(childIconTag)
+ .stylusHoverIcon(
+ desiredChildIcon,
+ overrideDescendants = false
+ ),
+ contentAlignment = Alignment.Center
+ ) {
+ grandchild()
+ }
+ } else {
+ grandchild()
+ }
+ }
+ }
+ }
+ }
+
+ // Verify initial state of pointer icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+ // Hover over center of Grandchild Box
+ rule.onNodeWithTag(grandchildIconTag).performStylusInput { hoverEnter(center) }
+ // Verify Grandchild Box has the desired grandchild icon
+ rule.runOnIdle {
+ assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandchildIcon)
+ }
+ // Move to corner of Grandparent Box where no descendants are under the cursor
+ rule.onNodeWithTag(grandparentIconTag).performStylusInput { hoverMoveTo(bottomRight) }
+ // Verify the icon is the desired grandparent icon and dynamically add the Child Box
+ rule.runOnIdle {
+ assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandparentIcon)
+ isChildVisible.value = true
+ }
+ // Verify the icon stays as the desired grandparent icon
+ rule.runOnIdle {
+ assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandparentIcon)
+ }
+ // Move cursor within Grandparent Box and verify it still has the grandparent icon
+ rule.onNodeWithTag(grandparentIconTag).performStylusInput { hoverMoveTo(centerRight) }
+ rule.runOnIdle {
+ assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandparentIcon)
+ }
+ // Move cursor outside Grandparent Box to Parent Box to verify icon is now the parent icon
+ rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomRight) }
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+ // Move cursor back to corner of Grandparent Box where no descendants are under the cursor
+ rule.onNodeWithTag(grandparentIconTag).performStylusInput { hoverMoveTo(bottomRight) }
+ // Dynamically remove the Child Box
+ rule.runOnIdle { isChildVisible.value = false }
+ // Verify the icon stays as the grandparent icon
+ rule.runOnIdle {
+ assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandparentIcon)
+ }
+ // Exit hovering over Parent Box
+ rule.onNodeWithTag(parentIconTag).performStylusInput { hoverExit() }
+ }
+
+ /**
+ * Setup: The hierarchy for the initial setup of this test is: Parent Box (custom icon =
+ * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box (custom icon =
+ * [PointerIcon.Text], overrideDescendants = FALSE)
+ *
+ * After hovering over the corner of the Parent Box that doesn't overlap with any descendant,
+ * the hierarchy of the screen updates to: Parent Box (custom icon = [PointerIcon.Crosshair],
+ * overrideDescendants = FALSE) ⤷ Child Box (custom icon = [PointerIcon.Text],
+ * overrideDescendants = FALSE) ⤷ Grandchild Box (custom icon = [PointerIcon.Hand],
+ * overrideDescendants = FALSE)
+ *
+ * Expected Output: The Grandchild Box's [PointerIcon.Hand] should win for its entire surface
+ * area regardless of whether there's a Child or Parent Box present. This is because the Parent
+ * and Child Boxes have overrideDescendants = false and should therefore not have their custom
+ * icons take priority over the Grandchild Box's custom icon. The Child Box should win for its
+ * remaining surface area not covered by the Grandchild Box. The Parent Box's
+ * [PointerIcon.Crosshair] should win for its remaining surface area not covered by the Child
+ * Box.
+ *
+ * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon =
+ * [PointerIcon.Text]) ⤷ Grandchild Box (output icon = [PointerIcon.Hand])
+ */
+ @Test
+ fun dynamicallyAddAndRemoveGrandchild_notHoveredOverGrandchild() {
+ val isGrandchildVisible = mutableStateOf(false)
+
+ rule.setContent {
+ CompositionLocalProvider(LocalPointerIconService provides iconService) {
+ Box(
+ modifier =
+ Modifier.requiredSize(200.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+ .testTag(parentIconTag)
+ .stylusHoverIcon(desiredParentIcon, overrideDescendants = false),
+ contentAlignment = Alignment.Center
+ ) {
+ Box(
+ modifier =
+ Modifier.requiredSize(150.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+ .testTag(childIconTag)
+ .stylusHoverIcon(desiredChildIcon, overrideDescendants = false),
+ contentAlignment = Alignment.Center
+ ) {
+ if (isGrandchildVisible.value) {
+ Box(
+ modifier =
+ Modifier.requiredSize(100.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+ .testTag(grandchildIconTag)
+ .stylusHoverIcon(
+ desiredGrandchildIcon,
+ overrideDescendants = false
+ )
+ )
+ }
+ }
+ }
+ }
+ }
+
+ // Verify initial state of pointer icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+ // Hover over center of Child Box
+ rule.onNodeWithTag(childIconTag).performStylusInput { hoverEnter(center) }
+ // Verify Child Box has the desired child icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) }
+ // Move to corner of Parent Box where no descendants are under the cursor
+ rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomRight) }
+ // Verify the icon is the desired parent icon and dynamically add the Grandchild Box
+ rule.runOnIdle {
+ assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon)
+ isGrandchildVisible.value = true
+ }
+ // Verify the icon stays as the desired parent icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+ // Move cursor within Parent Box and verify it still has the grandparent icon
+ rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(centerRight) }
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+ // Move cursor outside Parent Box to Child Box to verify icon is now the child icon
+ rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(bottomRight) }
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) }
+ // Move cursor back to corner of Parent Box where no descendants are under the cursor
+ rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomRight) }
+ // Dynamically remove the Grandchild Box
+ rule.runOnIdle { isGrandchildVisible.value = false }
+ // Verify the icon stays as the parent icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+ // Exit hovering over Parent Box
+ rule.onNodeWithTag(parentIconTag).performStylusInput { hoverExit() }
+ }
+
+ /**
+ * Setup: The hierarchy for the initial setup of this test is: Parent Box (custom icon =
+ * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ ChildA Box (custom icon =
+ * [PointerIcon.Text], overrideDescendants = FALSE)
+ *
+ * After hovering over the area where ChildB will be, the hierarchy of the screen updates to:
+ * Parent Box (no custom icon set) ⤷ ChildA Box (custom icon = [PointerIcon.Text],
+ * overrideDescendants = FALSE) ⤷ ChildB Box (custom icon = [PointerIcon.Hand],
+ * overrideDescendants = FALSE)
+ *
+ * Expected Output: Regardless of the presence of ChildB Box, ChildA Box's [PointerIcon.Text]
+ * should win for its entire surface area. Once ChildB Box appears, ChildB Box's
+ * [PointerIcon.Hand] should win for its entire surface area. Initially, Parent Box's
+ * [PointerIcon.Crosshair] should win for its entire surface area not covered by ChildA Box.
+ * Once ChildA Box appears, Parent Box should win for its entire surface not covered by either
+ * ChildA or ChildB Boxes.
+ *
+ * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon =
+ * [PointerIcon.Text]) ⤷ Child Box (output icon = [PointerIcon.Hand])
+ */
+ @Ignore("b/271277248 - Remove Ignore annotation once input event bug is fixed")
+ @Test
+ fun dynamicallyAddAndRemoveSibling_hoveredOverAppearingSibling() {
+ val isChildBVisible = mutableStateOf(false)
+
+ rule.setContent {
+ CompositionLocalProvider(LocalPointerIconService provides iconService) {
+ Box(
+ modifier =
+ Modifier.requiredSize(150.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+ .testTag(parentIconTag)
+ .stylusHoverIcon(desiredParentIcon, overrideDescendants = false)
+ ) {
+ Column {
+ Box(
+ Modifier.requiredSize(50.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+ .testTag(childIconTag)
+ .stylusHoverIcon(desiredChildIcon, overrideDescendants = false)
+ )
+ if (isChildBVisible.value) {
+ // Referencing grandchild tag/icon for ChildB in this test
+ Box(
+ Modifier.requiredSize(50.dp)
+ .offset(y = 100.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+ .testTag(grandchildIconTag)
+ .stylusHoverIcon(
+ desiredGrandchildIcon,
+ overrideDescendants = false
+ )
+ )
+ }
+ }
+ }
+ }
+ }
+ // Verify initial state of pointer icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+ // Hover over corner of Parent Box
+ rule.onNodeWithTag(parentIconTag).performStylusInput { hoverEnter(bottomRight) }
+ // Verify Parent Box has the desired parent icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+ // Move to center of ChildA Box
+ rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(center) }
+ // Verify ChildA Box has the desired child icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) }
+ // Move to left corner of Parent Box where ChildB will be added
+ rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomLeft) }
+ // Dynamically add the ChildB Box
+ rule.runOnIdle {
+ assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon)
+ isChildBVisible.value = true
+ }
+ // Verify the icon is updated to the desired ChildB icon
+ rule.runOnIdle {
+ assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandchildIcon)
+ }
+ // Move to corner of ChildB Box
+ rule.onNodeWithTag(grandchildIconTag).performStylusInput { hoverMoveTo(bottomRight) }
+ // Verify ChildB Box has the desired grandchild icon
+ rule.runOnIdle {
+ assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandchildIcon)
+ }
+ // Move cursor back to the center of ChildA Box
+ rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(center) }
+ // Verify that icon is updated to the desired ChildA icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) }
+ // Move cursor back to the location of ChildB
+ rule.onNodeWithTag(grandchildIconTag).performStylusInput { hoverMoveTo(center) }
+ // Dynamically remove the ChildB Box
+ rule.runOnIdle {
+ assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandchildIcon)
+ isChildBVisible.value = false
+ }
+ // Verify the icon updates to the parent icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+ // Exit hovering over ChildA Box
+ rule.onNodeWithTag(parentIconTag).performStylusInput { hoverExit() }
+ }
+
+ /**
+ * Setup: The hierarchy for the initial setup of this test is: Parent Box (custom icon =
+ * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ ChildA Box (custom icon =
+ * [PointerIcon.Text], overrideDescendants = FALSE)
+ *
+ * After hovering over ChildA, the hierarchy of the screen updates to: Parent Box (no custom
+ * icon set) ⤷ ChildA Box (custom icon = [PointerIcon.Text], overrideDescendants = FALSE) ⤷
+ * ChildB Box (custom icon = [PointerIcon.Hand], overrideDescendants = FALSE)
+ *
+ * Expected Output: Regardless of the presence of ChildB Box, ChildA Box's [PointerIcon.Text]
+ * should win for its entire surface area. Once ChildB Box appears, ChildB Box's
+ * [PointerIcon.Hand] should win for its entire surface area. Initially, Parent Box's
+ * [PointerIcon.Crosshair] should win for its entire surface area not covered by ChildA Box.
+ * Once ChildA Box appears, Parent Box should win for its entire surface not covered by either
+ * ChildA or ChildB Boxes.
+ *
+ * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon =
+ * [PointerIcon.Text]) ⤷ Child Box (output icon = [PointerIcon.Hand])
+ */
+ @Ignore("b/271277248 - Remove Ignore annotation once input event bug is fixed")
+ @Test
+ fun dynamicallyAddAndRemoveSibling_notHoveredOverAppearingSibling() {
+ val isChildBVisible = mutableStateOf(false)
+
+ rule.setContent {
+ CompositionLocalProvider(LocalPointerIconService provides iconService) {
+ Box(
+ modifier =
+ Modifier.requiredSize(200.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+ .testTag(parentIconTag)
+ .stylusHoverIcon(desiredParentIcon, overrideDescendants = false)
+ ) {
+ Column {
+ Box(
+ Modifier.requiredSize(50.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+ .testTag(childIconTag)
+ .stylusHoverIcon(desiredChildIcon, overrideDescendants = false)
+ )
+ if (isChildBVisible.value) {
+ // Referencing grandchild tag/icon for ChildB in this test
+ Box(
+ Modifier.requiredSize(50.dp)
+ .offset(y = 100.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+ .testTag(grandchildIconTag)
+ .stylusHoverIcon(
+ desiredGrandchildIcon,
+ overrideDescendants = false
+ )
+ )
+ }
+ }
+ }
+ }
+ }
+ // Verify initial state of pointer icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+ // Hover over corner of Parent Box
+ rule.onNodeWithTag(parentIconTag).performStylusInput { hoverEnter(bottomRight) }
+ // Verify Parent Box has the desired parent icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+ // Move to center of ChildA Box
+ rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(center) }
+ // Verify ChildA Box has the desired child icon and dynamically add the ChildB Box
+ rule.runOnIdle {
+ assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon)
+ isChildBVisible.value = true
+ }
+ // Verify the icon stays as the desired child icon since the cursor hasn't moved
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) }
+ // Move to corner of ChildB Box
+ rule.onNodeWithTag(grandchildIconTag).performStylusInput { hoverMoveTo(bottomRight) }
+ // Verify ChildB Box has the desired grandchild icon
+ rule.runOnIdle {
+ assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandchildIcon)
+ }
+ // Move cursor back to the center of ChildA Box
+ rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(center) }
+ // Dynamically remove the ChildB Box
+ rule.runOnIdle {
+ assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon)
+ isChildBVisible.value = false
+ }
+ // Verify the icon stays as the desired child icon since the cursor hasn't moved
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) }
+ // Exit hovering over ChildA Box
+ rule.onNodeWithTag(childIconTag).performStylusInput { hoverExit() }
+ }
+
+ /**
+ * Setup: The hierarchy for this test is setup as: Default Box (no custom icon set) ⤷ Parent Box
+ * (custom icon = [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box (custom icon
+ * = [PointerIcon.Text], overrideDescendants = FALSE) ⤷ Grandchild Box (custom icon =
+ * [PointerIcon.Hand], overrideDescendants = FALSE)
+ *
+ * Expected Output: Grandchild Box's [PointerIcon.Hand] wins for the entire surface area of the
+ * Grandchild Box. Child Box's [PointerIcon.Text] wins for the remaining surface area of the
+ * Child Box not covered by the Grandchild Box. Parent Box’s [PointerIcon.Crosshair] wins for
+ * the remaining surface area not covered by the Child Box. No icon wins for the remaining
+ * surface area of
+ *
+ * Default Box (output icon = null) ⤷ Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child
+ * Box (output icon = [PointerIcon.Text]) ⤷ Grandchild Box (output icon = [PointerIcon.Hand])
+ */
+ @Test
+ fun childNotFullyContainedInParent_noOverrideDescendants() {
+ val defaultIconTag = "myDefaultWrapper"
+ rule.setContent {
+ CompositionLocalProvider(LocalPointerIconService provides iconService) {
+ Box(
+ modifier =
+ Modifier.fillMaxSize()
+ .border(BorderStroke(2.dp, SolidColor(Color.Yellow)))
+ .testTag(defaultIconTag)
+ ) {
+ Box(
+ modifier =
+ Modifier.requiredSize(width = 200.dp, height = 150.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+ .testTag(parentIconTag)
+ .stylusHoverIcon(desiredParentIcon, overrideDescendants = false)
+ ) {
+ Box(
+ Modifier.requiredSize(width = 150.dp, height = 125.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+ .testTag(childIconTag)
+ .stylusHoverIcon(desiredChildIcon, overrideDescendants = false)
+ ) {
+ Box(
+ Modifier.requiredSize(width = 300.dp, height = 100.dp)
+ .offset(x = 100.dp)
+ .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+ .testTag(grandchildIconTag)
+ .stylusHoverIcon(
+ desiredGrandchildIcon,
+ overrideDescendants = false
+ )
+ )
+ }
+ }
+ }
+ }
+ }
+
+ // Verify initial state of pointer icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+ // Hover over the default wrapping box and verify the cursor is still the default icon
+ rule.onNodeWithTag(defaultIconTag).performStylusInput { hoverEnter(center) }
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+ // Move cursor to the corner of the Grandchild Box and verify it has the desired grandchild
+ // icon
+ rule.onNodeWithTag(grandchildIconTag).performStylusInput { hoverMoveTo(bottomRight) }
+ rule.runOnIdle {
+ assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandchildIcon)
+ }
+ // Move cursor to the center right of the Child Box and verify it still has the desired
+ // grandchild icon
+ rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(centerRight) }
+ rule.runOnIdle {
+ assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandchildIcon)
+ }
+ // Move cursor to the corner of the Child Box and verify it has updated to the desired child
+ // icon
+ rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(bottomRight) }
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) }
+ // Move cursor to the center right of the Parent Box and verify it has the desired
+ // grandchild icon
+ rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(centerRight) }
+ rule.runOnIdle {
+ assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandchildIcon)
+ }
+ // Move cursor to the corner of the Parent Box and verify it has the desired parent icon
+ rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomRight) }
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+ }
+
+ @Test
+ fun resetPointerIconWhenChildRemoved_parentDoesSetIcon_iconIsHand() {
+ val defaultIconTag = "myDefaultWrapper"
+ var show by mutableStateOf(true)
+ rule.setContent {
+ CompositionLocalProvider(LocalPointerIconService provides iconService) {
+ Box(
+ modifier =
+ Modifier.fillMaxSize()
+ .stylusHoverIcon(PointerIcon.Hand)
+ .testTag(defaultIconTag)
+ ) {
+ if (show) {
+ Box(
+ modifier = Modifier.stylusHoverIcon(PointerIcon.Text).size(10.dp, 10.dp)
+ )
+ }
+ }
+ }
+ }
+
+ rule.runOnIdle {
+ // No stylus movement yet, should be default
+ assertThat(iconService.getStylusHoverIcon()).isNull()
+ }
+
+ rule.onNodeWithTag(defaultIconTag).performStylusInput {
+ hoverMoveTo(Offset(x = 5f, y = 5f))
+ }
+
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(PointerIcon.Text) }
+
+ show = false
+
+ rule.onNodeWithTag(defaultIconTag).performStylusInput {
+ hoverMoveTo(Offset(x = 6f, y = 6f))
+ }
+
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(PointerIcon.Hand) }
+ }
+
+ @Test
+ fun resetPointerIconWhenChildRemoved_parentDoesNotSetIcon_iconIsDefault() {
+ val defaultIconTag = "myDefaultWrapper"
+ var show by mutableStateOf(true)
+ rule.setContent {
+ CompositionLocalProvider(LocalPointerIconService provides iconService) {
+ Box(modifier = Modifier.fillMaxSize().testTag(defaultIconTag)) {
+ if (show) {
+ Box(
+ modifier = Modifier.stylusHoverIcon(PointerIcon.Text).size(10.dp, 10.dp)
+ )
+ }
+ }
+ }
+ }
+
+ rule.runOnIdle {
+ // No stylus movement yet, should be default
+ assertThat(iconService.getStylusHoverIcon()).isNull()
+ }
+
+ rule.onNodeWithTag(defaultIconTag).performStylusInput {
+ hoverMoveTo(Offset(x = 5f, y = 5f))
+ }
+
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(PointerIcon.Text) }
+
+ show = false
+
+ rule.onNodeWithTag(defaultIconTag).performStylusInput {
+ hoverMoveTo(Offset(x = 6f, y = 6f))
+ }
+
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+ }
+
+ private fun verifyIconOnHover(tag: String, expectedIcon: PointerIcon?) {
+ // Hover over element with specified tag
+ rule.onNodeWithTag(tag).performStylusInput { hoverEnter(bottomRight) }
+ // Verify the current icon is the expected icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(expectedIcon) }
+ // Exit hovering over element
+ rule.onNodeWithTag(tag).performStylusInput { hoverExit() }
+ }
+
+ private fun verifyOverlappingSiblings() {
+ // Verify initial state of pointer icon
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+ // Hover over ChildB (bottom right corner) and verify desired ChildB icon
+ rule.onNodeWithTag(grandchildIconTag).performStylusInput { hoverEnter(bottomRight) }
+ rule.runOnIdle {
+ assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandchildIcon)
+ }
+
+ // Then hover to parent (bottom right corner) and verify default arrow icon
+ rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomRight) }
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+
+ // Then hover back over ChildB in area that overlaps with sibling (bottom left corner) and
+ // verify desired ChildB icon
+ rule.onNodeWithTag(grandchildIconTag).performStylusInput { hoverMoveTo(bottomLeft) }
+ rule.runOnIdle {
+ assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandchildIcon)
+ }
+
+ // Then hover to ChildA (bottom left corner) and verify desired ChildA icon (hand)
+ rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(bottomLeft) }
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) }
+
+ // Then hover over parent (bottom left corner) and verify default arrow icon
+ rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomLeft) }
+ rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+
+ // Exit hovering
+ rule.onNodeWithTag(parentIconTag).performStylusInput { hoverExit() }
+ }
+}
diff --git a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidComposeView.android.kt b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidComposeView.android.kt
index 4dd57c0..db16500 100644
--- a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidComposeView.android.kt
+++ b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidComposeView.android.kt
@@ -34,6 +34,7 @@
import android.util.LongSparseArray
import android.util.SparseArray
import android.view.FocusFinder
+import android.view.InputDevice
import android.view.KeyEvent as AndroidKeyEvent
import android.view.MotionEvent
import android.view.MotionEvent.ACTION_CANCEL
@@ -2444,23 +2445,55 @@
return null
}
+ @RequiresApi(N)
+ override fun onResolvePointerIcon(
+ event: MotionEvent,
+ pointerIndex: Int
+ ): android.view.PointerIcon {
+ val toolType = event.getToolType(pointerIndex)
+ if (
+ !event.isFromSource(InputDevice.SOURCE_MOUSE) &&
+ event.isFromSource(InputDevice.SOURCE_STYLUS) &&
+ (toolType == MotionEvent.TOOL_TYPE_STYLUS ||
+ toolType == MotionEvent.TOOL_TYPE_ERASER)
+ ) {
+ val icon = pointerIconService.getStylusHoverIcon()
+ if (icon != null) {
+ return AndroidComposeViewVerificationHelperMethodsN.toAndroidPointerIcon(
+ context,
+ icon
+ )
+ }
+ }
+ return super.onResolvePointerIcon(event, pointerIndex)
+ }
+
override val pointerIconService: PointerIconService =
object : PointerIconService {
- private var currentIcon: PointerIcon = PointerIcon.Default
+ private var currentMouseCursorIcon: PointerIcon = PointerIcon.Default
+ private var currentStylusHoverIcon: PointerIcon? = null
override fun getIcon(): PointerIcon {
- return currentIcon
+ return currentMouseCursorIcon
}
override fun setIcon(value: PointerIcon?) {
- currentIcon = value ?: PointerIcon.Default
+ currentMouseCursorIcon = value ?: PointerIcon.Default
if (SDK_INT >= N) {
AndroidComposeViewVerificationHelperMethodsN.setPointerIcon(
this@AndroidComposeView,
- currentIcon
+ currentMouseCursorIcon
)
}
}
+
+ override fun getStylusHoverIcon(): PointerIcon? {
+ return currentStylusHoverIcon
+ }
+
+ override fun setStylusHoverIcon(value: PointerIcon?) {
+ currentStylusHoverIcon = value
+ }
}
/**
@@ -2601,20 +2634,22 @@
@RequiresApi(N)
private object AndroidComposeViewVerificationHelperMethodsN {
+ @RequiresApi(N)
+ fun toAndroidPointerIcon(context: Context, icon: PointerIcon?): android.view.PointerIcon =
+ when (icon) {
+ is AndroidPointerIcon -> icon.pointerIcon
+ is AndroidPointerIconType -> android.view.PointerIcon.getSystemIcon(context, icon.type)
+ else ->
+ android.view.PointerIcon.getSystemIcon(
+ context,
+ android.view.PointerIcon.TYPE_DEFAULT
+ )
+ }
+
@DoNotInline
@RequiresApi(N)
fun setPointerIcon(view: View, icon: PointerIcon?) {
- val iconToSet =
- when (icon) {
- is AndroidPointerIcon -> icon.pointerIcon
- is AndroidPointerIconType ->
- android.view.PointerIcon.getSystemIcon(view.context, icon.type)
- else ->
- android.view.PointerIcon.getSystemIcon(
- view.context,
- android.view.PointerIcon.TYPE_DEFAULT
- )
- }
+ val iconToSet = toAndroidPointerIcon(view.context, icon)
if (view.pointerIcon != iconToSet) {
view.pointerIcon = iconToSet
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/pointer/PointerIcon.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/pointer/PointerIcon.kt
index be06d69..258d4c9 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/pointer/PointerIcon.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/pointer/PointerIcon.kt
@@ -20,18 +20,24 @@
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.PointerEventPass.Main
import androidx.compose.ui.node.CompositionLocalConsumerModifierNode
+import androidx.compose.ui.node.DpTouchBoundsExpansion
import androidx.compose.ui.node.ModifierNodeElement
import androidx.compose.ui.node.PointerInputModifierNode
+import androidx.compose.ui.node.TouchBoundsExpansion
import androidx.compose.ui.node.TraversableNode
import androidx.compose.ui.node.TraversableNode.Companion.TraverseDescendantsAction
import androidx.compose.ui.node.currentValueOf
+import androidx.compose.ui.node.requireDensity
import androidx.compose.ui.node.traverseAncestors
import androidx.compose.ui.node.traverseDescendants
import androidx.compose.ui.platform.InspectorInfo
import androidx.compose.ui.platform.LocalPointerIconService
import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.util.fastAny
-/** Represents a pointer icon to use in [Modifier.pointerHoverIcon] */
+/**
+ * Represents a pointer icon to use in [Modifier.pointerHoverIcon] or [Modifier.stylusHoverIcon].
+ */
@Stable
interface PointerIcon {
@@ -64,6 +70,10 @@
fun getIcon(): PointerIcon
fun setIcon(value: PointerIcon?)
+
+ fun getStylusHoverIcon(): PointerIcon?
+
+ fun setStylusHoverIcon(value: PointerIcon?)
}
/**
@@ -72,10 +82,10 @@
* icon using this modifier.
*
* @sample androidx.compose.ui.samples.PointerIconSample
- * @param icon The icon to set
- * @param overrideDescendants when false (by default) descendants are able to set their own pointer
+ * @param icon the icon to set
+ * @param overrideDescendants when false (by default), descendants are able to set their own pointer
* icon. If true, no descendants under this parent are eligible to change the icon (it will be set
- * to the this [the parent's] icon).
+ * to the this (the parent's) icon).
*/
@Stable
fun Modifier.pointerHoverIcon(icon: PointerIcon, overrideDescendants: Boolean = false) =
@@ -112,15 +122,93 @@
internal class PointerHoverIconModifierNode(
icon: PointerIcon,
overrideDescendants: Boolean = false
+) : HoverIconModifierNode(icon, overrideDescendants) {
+ /* Traversal key used with the [TraversableNode] interface to enable all the traversing
+ * functions (ancestor, child, subtree, and subtreeIf).
+ */
+ override val traverseKey = "androidx.compose.ui.input.pointer.PointerHoverIcon"
+
+ override fun isRelevantPointerType(pointerType: PointerType) =
+ pointerType != PointerType.Stylus && pointerType != PointerType.Eraser
+
+ override fun displayIcon(icon: PointerIcon?) {
+ pointerIconService?.setIcon(icon)
+ }
+}
+
+/**
+ * Modifier that lets a developer define a pointer icon to display when a stylus is hovered over the
+ * element. When [overrideDescendants] is set to true, descendants cannot override the pointer icon
+ * using this modifier.
+ *
+ * @param icon the icon to set
+ * @param overrideDescendants when false (by default), descendants are able to set their own pointer
+ * icon. If true, no descendants under this parent are eligible to change the icon (it will be set
+ * to the this (the parent's) icon).
+ * @param touchBoundsExpansion amount by which the element's bounds is expanded
+ * @sample androidx.compose.ui.samples.StylusHoverIconSample
+ */
+fun Modifier.stylusHoverIcon(
+ icon: PointerIcon,
+ overrideDescendants: Boolean = false,
+ touchBoundsExpansion: DpTouchBoundsExpansion? = null
+) =
+ this then
+ StylusHoverIconModifierElement(
+ icon = icon,
+ overrideDescendants = overrideDescendants,
+ touchBoundsExpansion = touchBoundsExpansion
+ )
+
+internal data class StylusHoverIconModifierElement(
+ val icon: PointerIcon,
+ val overrideDescendants: Boolean = false,
+ val touchBoundsExpansion: DpTouchBoundsExpansion? = null
+) : ModifierNodeElement<StylusHoverIconModifierNode>() {
+ override fun create() =
+ StylusHoverIconModifierNode(icon, overrideDescendants, touchBoundsExpansion)
+
+ override fun update(node: StylusHoverIconModifierNode) {
+ node.icon = icon
+ node.overrideDescendants = overrideDescendants
+ node.dpTouchBoundsExpansion = touchBoundsExpansion
+ }
+
+ override fun InspectorInfo.inspectableProperties() {
+ name = "stylusHoverIcon"
+ properties["icon"] = icon
+ properties["overrideDescendants"] = overrideDescendants
+ properties["touchBoundsExpansion"] = touchBoundsExpansion
+ }
+}
+
+internal class StylusHoverIconModifierNode(
+ icon: PointerIcon,
+ overrideDescendants: Boolean = false,
+ touchBoundsExpansion: DpTouchBoundsExpansion? = null
+) : HoverIconModifierNode(icon, overrideDescendants, touchBoundsExpansion) {
+ /* Traversal key used with the [TraversableNode] interface to enable all the traversing
+ * functions (ancestor, child, subtree, and subtreeIf).
+ */
+ override val traverseKey = "androidx.compose.ui.input.pointer.StylusHoverIcon"
+
+ override fun isRelevantPointerType(pointerType: PointerType) =
+ pointerType == PointerType.Stylus || pointerType == PointerType.Eraser
+
+ override fun displayIcon(icon: PointerIcon?) {
+ pointerIconService?.setStylusHoverIcon(icon)
+ }
+}
+
+internal abstract class HoverIconModifierNode(
+ icon: PointerIcon,
+ overrideDescendants: Boolean = false,
+ var dpTouchBoundsExpansion: DpTouchBoundsExpansion? = null
) :
Modifier.Node(),
TraversableNode,
PointerInputModifierNode,
CompositionLocalConsumerModifierNode {
- /* Traversal key used with the [TraversableNode] interface to enable all the traversing
- * functions (ancestor, child, subtree, and subtreeIf).
- */
- override val traverseKey = "androidx.compose.ui.input.pointer.PointerHoverIcon"
var icon = icon
set(value) {
@@ -151,7 +239,7 @@
}
// Service used to actually update the icon with the system when needed.
- private val pointerIconService: PointerIconService?
+ protected val pointerIconService: PointerIconService?
get() = currentValueOf(LocalPointerIconService)
private var cursorInBoundsOfNode = false
@@ -162,7 +250,7 @@
pass: PointerEventPass,
bounds: IntSize
) {
- if (pass == Main) {
+ if (pass == Main && pointerEvent.changes.fastAny { isRelevantPointerType(it.type) }) {
// Cursor within the surface area of this node's bounds
if (pointerEvent.type == PointerEventType.Enter) {
onEnter()
@@ -199,15 +287,20 @@
super.onDetach()
}
+ override val touchBoundsExpansion: TouchBoundsExpansion
+ get() =
+ dpTouchBoundsExpansion?.roundToTouchBoundsExpansion(requireDensity())
+ ?: TouchBoundsExpansion.None
+
+ abstract fun isRelevantPointerType(pointerType: PointerType): Boolean
+
+ abstract fun displayIcon(icon: PointerIcon?)
+
private fun displayIcon() {
// If there are any ancestor that override this node, we must use that icon. Otherwise, we
// use the current node's icon
val iconToUse = findOverridingAncestorNode()?.icon ?: icon
- pointerIconService?.setIcon(iconToUse)
- }
-
- private fun displayDefaultIcon() {
- pointerIconService?.setIcon(null)
+ displayIcon(iconToUse)
}
private fun displayIconIfDescendantsDoNotHavePriority() {
@@ -239,22 +332,22 @@
*
* Note: Multiple descendant nodes may have `cursorInBoundsOfNode` set to true (for when the
* cursor enters their bounds). The lowest one is the one that is the correct node for the
- * mouse (see example for explanation).
+ * pointer (see example for explanation).
*
* Example: Parent node contains a child node within its visual border (both are pointer icon
* nodes).
- * - Mouse moves over the PARENT node triggers the pointer input handler ENTER event which sets
- * `cursorInBoundsOfNode` = `true`.
- * - Mouse moves over CHILD node triggers the pointer input handler ENTER event which sets
+ * - Pointer moves over the PARENT node triggers the pointer input handler ENTER event which
+ * sets `cursorInBoundsOfNode` = `true`.
+ * - Pointer moves over CHILD node triggers the pointer input handler ENTER event which sets
* `cursorInBoundsOfNode` = `true`.
*
* They are both true now because the pointer input event's exit is not triggered (which would
- * set cursorInBoundsOfNode` = `false`) unless the mouse moves outside the parent node. Because
- * the child node is contained visually within the parent node, it is not triggered. That is why
- * we need to get the lowest node with `cursorInBoundsOfNode` set to true.
+ * set cursorInBoundsOfNode` = `false`) unless the pointer moves outside the parent node.
+ * Because the child node is contained visually within the parent node, it is not triggered.
+ * That is why we need to get the lowest node with `cursorInBoundsOfNode` set to true.
*/
- private fun findDescendantNodeWithCursorInBounds(): PointerHoverIconModifierNode? {
- var descendantNodeWithCursorInBounds: PointerHoverIconModifierNode? = null
+ private fun findDescendantNodeWithCursorInBounds(): HoverIconModifierNode? {
+ var descendantNodeWithCursorInBounds: HoverIconModifierNode? = null
traverseDescendants {
var actionForSubtreeOfCurrentNode = TraverseDescendantsAction.ContinueTraversal
@@ -277,54 +370,52 @@
private fun displayIconFromCurrentNodeOrDescendantsWithCursorInBounds() {
if (!cursorInBoundsOfNode) return
- var pointerHoverIconModifierNode: PointerHoverIconModifierNode = this
+ var hoverIconModifierNode: HoverIconModifierNode = this
if (!overrideDescendants) {
- findDescendantNodeWithCursorInBounds()?.let { pointerHoverIconModifierNode = it }
+ findDescendantNodeWithCursorInBounds()?.let { hoverIconModifierNode = it }
}
- pointerHoverIconModifierNode.displayIcon()
+ hoverIconModifierNode.displayIcon()
}
- private fun findOverridingAncestorNode(): PointerHoverIconModifierNode? {
- var pointerHoverIconModifierNode: PointerHoverIconModifierNode? = null
+ private fun findOverridingAncestorNode(): HoverIconModifierNode? {
+ var hoverIconModifierNode: HoverIconModifierNode? = null
traverseAncestors {
if (it.overrideDescendants && it.cursorInBoundsOfNode) {
- pointerHoverIconModifierNode = it
+ hoverIconModifierNode = it
}
// continue traversal
true
}
- return pointerHoverIconModifierNode
+ return hoverIconModifierNode
}
/*
- * Sets the icon to either the ancestor where the mouse is in its bounds (or to its
+ * Sets the icon to either the ancestor where the pointer is in its bounds (or to its
* ancestors if one overrides it) or to a default icon.
*/
private fun displayIconFromAncestorNodeWithCursorInBoundsOrDefaultIcon() {
- var pointerHoverIconModifierNode: PointerHoverIconModifierNode? = null
+ var hoverIconModifierNode: HoverIconModifierNode? = null
traverseAncestors {
- if (pointerHoverIconModifierNode == null && it.cursorInBoundsOfNode) {
- pointerHoverIconModifierNode = it
+ if (hoverIconModifierNode == null && it.cursorInBoundsOfNode) {
+ hoverIconModifierNode = it
// We should only assign a node that override its descendants if there was a node
- // below it where the mouse was in bounds meaning the pointerHoverIconModifierNode
- // will not be null.
+ // below it where the pointer was in bounds meaning the hoverIconModifierNode will
+ // not be null.
} else if (
- pointerHoverIconModifierNode != null &&
- it.overrideDescendants &&
- it.cursorInBoundsOfNode
+ hoverIconModifierNode != null && it.overrideDescendants && it.cursorInBoundsOfNode
) {
- pointerHoverIconModifierNode = it
+ hoverIconModifierNode = it
}
// continue traversal
true
}
- pointerHoverIconModifierNode?.displayIcon() ?: displayDefaultIcon()
+ hoverIconModifierNode?.displayIcon() ?: displayIcon(null)
}
}
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/PointerInputModifierNode.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/PointerInputModifierNode.kt
index 1e3ba09..ed62306 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/PointerInputModifierNode.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/PointerInputModifierNode.kt
@@ -21,13 +21,8 @@
import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.input.pointer.SuspendingPointerInputModifierNode
import androidx.compose.ui.input.pointer.SuspendingPointerInputModifierNodeImpl
-import androidx.compose.ui.internal.requirePrecondition
import androidx.compose.ui.layout.LayoutCoordinates
-import androidx.compose.ui.node.TouchBoundsExpansion.Companion.MAX_VALUE
-import androidx.compose.ui.node.TouchBoundsExpansion.Companion.pack
import androidx.compose.ui.unit.IntSize
-import androidx.compose.ui.unit.LayoutDirection
-import kotlin.jvm.JvmInline
/**
* A [androidx.compose.ui.Modifier.Node] that receives [PointerInputChange]s, interprets them, and
@@ -133,148 +128,6 @@
get() = TouchBoundsExpansion.None
}
-/**
- * Describes the expansion of a [PointerInputModifierNode]'s touch bounds along each edges. See
- * [TouchBoundsExpansion] factories and [Absolute] for convenient ways to build
- * [TouchBoundsExpansion].
- *
- * @see PointerInputModifierNode.touchBoundsExpansion
- */
-@JvmInline
-value class TouchBoundsExpansion internal constructor(private val packedValue: Long) {
- companion object {
- /**
- * Creates a [TouchBoundsExpansion] that's unaware of [LayoutDirection]. The `left`, `top`,
- * `right` and `bottom` represent the amount of pixels that the touch bounds is expanded
- * along the corresponding edge. Each value must be in the range of 0 to 32767 (inclusive).
- */
- fun Absolute(
- left: Int = 0,
- top: Int = 0,
- right: Int = 0,
- bottom: Int = 0
- ): TouchBoundsExpansion {
- requirePrecondition(left in 0..MAX_VALUE) {
- "Start must be in the range of 0 .. $MAX_VALUE"
- }
- requirePrecondition(top in 0..MAX_VALUE) {
- "Top must be in the range of 0 .. $MAX_VALUE"
- }
- requirePrecondition(right in 0..MAX_VALUE) {
- "End must be in the range of 0 .. $MAX_VALUE"
- }
- requirePrecondition(bottom in 0..MAX_VALUE) {
- "Bottom must be in the range of 0 .. $MAX_VALUE"
- }
- return TouchBoundsExpansion(pack(left, top, right, bottom, false))
- }
-
- /** Constant that represents no touch bounds expansion. */
- val None = TouchBoundsExpansion(0)
-
- internal fun pack(
- start: Int,
- top: Int,
- end: Int,
- bottom: Int,
- isLayoutDirectionAware: Boolean
- ): Long {
- return trimAndShift(start, 0) or
- trimAndShift(top, 1) or
- trimAndShift(end, 2) or
- trimAndShift(bottom, 3) or
- if (isLayoutDirectionAware) IS_LAYOUT_DIRECTION_AWARE else 0L
- }
-
- private const val MASK = 0x7FFF
-
- private const val SHIFT = 15
-
- internal const val MAX_VALUE = MASK
-
- private const val IS_LAYOUT_DIRECTION_AWARE = 1L shl 63
-
- // We stored all
- private fun unpack(packedValue: Long, position: Int): Int =
- (packedValue shr (position * SHIFT)).toInt() and MASK
-
- private fun trimAndShift(int: Int, position: Int): Long =
- (int and MASK).toLong() shl (position * SHIFT)
- }
-
- /**
- * The amount of pixels the touch bounds should be expanded along the start edge. When
- * [isLayoutDirectionAware] is `true`, it's applied to the left edge when [LayoutDirection] is
- * [LayoutDirection.Ltr] and vice versa. When [isLayoutDirectionAware] is `false`, it's always
- * applied to the left edge.
- */
- val start: Int
- get() = unpack(packedValue, 0)
-
- /** The amount of pixels the touch bounds should be expanded along the top edge. */
- val top: Int
- get() = unpack(packedValue, 1)
-
- /**
- * The amount of pixels the touch bounds should be expanded along the end edge. When
- * [isLayoutDirectionAware] is `true`, it's applied to the left edge when [LayoutDirection] is
- * [LayoutDirection.Ltr] and vice versa. When [isLayoutDirectionAware] is `false`, it's always
- * applied to the left edge.
- */
- val end: Int
- get() = unpack(packedValue, 2)
-
- /** The amount of pixels the touch bounds should be expanded along the bottom edge. */
- val bottom: Int
- get() = unpack(packedValue, 3)
-
- /**
- * Whether this [TouchBoundsExpansion] is aware of [LayoutDirection] or not. See [start] and
- * [end] for more details.
- */
- val isLayoutDirectionAware: Boolean
- get() = (packedValue and IS_LAYOUT_DIRECTION_AWARE) != 0L
-
- /** Returns the amount of pixels the touch bounds is expanded towards left. */
- internal fun computeLeft(layoutDirection: LayoutDirection): Int {
- return if (!isLayoutDirectionAware || layoutDirection == LayoutDirection.Ltr) {
- start
- } else {
- end
- }
- }
-
- /** Returns the amount of pixels the touch bounds is expanded towards right. */
- internal fun computeRight(layoutDirection: LayoutDirection): Int {
- return if (!isLayoutDirectionAware || layoutDirection == LayoutDirection.Ltr) {
- end
- } else {
- start
- }
- }
-}
-
-/**
- * Creates a [TouchBoundsExpansion] that's aware of [LayoutDirection]. See
- * [TouchBoundsExpansion.start] and [TouchBoundsExpansion.end] for more details about
- * [LayoutDirection].
- *
- * The `start`, `top`, `end` and `bottom` represent the amount of pixels that the touch bounds is
- * expanded along the corresponding edge. Each value must be in the range of 0 to 32767 (inclusive).
- */
-fun TouchBoundsExpansion(
- start: Int = 0,
- top: Int = 0,
- end: Int = 0,
- bottom: Int = 0
-): TouchBoundsExpansion {
- requirePrecondition(start in 0..MAX_VALUE) { "Start must be in the range of 0 .. $MAX_VALUE" }
- requirePrecondition(top in 0..MAX_VALUE) { "Top must be in the range of 0 .. $MAX_VALUE" }
- requirePrecondition(end in 0..MAX_VALUE) { "End must be in the range of 0 .. $MAX_VALUE" }
- requirePrecondition(bottom in 0..MAX_VALUE) { "Bottom must be in the range of 0 .. $MAX_VALUE" }
- return TouchBoundsExpansion(packedValue = pack(start, top, end, bottom, true))
-}
-
internal val PointerInputModifierNode.isAttached: Boolean
get() = node.isAttached
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/TouchBoundsExpansion.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/TouchBoundsExpansion.kt
new file mode 100644
index 0000000..14ba4ab
--- /dev/null
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/TouchBoundsExpansion.kt
@@ -0,0 +1,247 @@
+/*
+ * 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
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.ui.node
+
+import androidx.compose.ui.internal.requirePrecondition
+import androidx.compose.ui.node.DpTouchBoundsExpansion.Companion.Absolute
+import androidx.compose.ui.node.TouchBoundsExpansion.Companion.Absolute
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+import kotlin.jvm.JvmInline
+
+/**
+ * Describes the expansion of a [PointerInputModifierNode]'s touch bounds along each edges. See
+ * [TouchBoundsExpansion] factories and [Absolute] for convenient ways to build
+ * [TouchBoundsExpansion].
+ *
+ * @see PointerInputModifierNode.touchBoundsExpansion
+ */
+@JvmInline
+value class TouchBoundsExpansion internal constructor(private val packedValue: Long) {
+ companion object {
+ /**
+ * Creates a [TouchBoundsExpansion] that's unaware of [LayoutDirection]. The `left`, `top`,
+ * `right` and `bottom` represent the amount of pixels that the touch bounds is expanded
+ * along the corresponding edge. Each value must be in the range of 0 to 32767 (inclusive).
+ */
+ fun Absolute(
+ left: Int = 0,
+ top: Int = 0,
+ right: Int = 0,
+ bottom: Int = 0
+ ): TouchBoundsExpansion {
+ requirePrecondition(left in 0..MAX_VALUE) {
+ "Start must be in the range of 0 .. $MAX_VALUE"
+ }
+ requirePrecondition(top in 0..MAX_VALUE) {
+ "Top must be in the range of 0 .. $MAX_VALUE"
+ }
+ requirePrecondition(right in 0..MAX_VALUE) {
+ "End must be in the range of 0 .. $MAX_VALUE"
+ }
+ requirePrecondition(bottom in 0..MAX_VALUE) {
+ "Bottom must be in the range of 0 .. $MAX_VALUE"
+ }
+ return TouchBoundsExpansion(pack(left, top, right, bottom, false))
+ }
+
+ /** Constant that represents no touch bounds expansion. */
+ val None = TouchBoundsExpansion(0)
+
+ internal fun pack(
+ start: Int,
+ top: Int,
+ end: Int,
+ bottom: Int,
+ isLayoutDirectionAware: Boolean
+ ): Long {
+ return trimAndShift(start, 0) or
+ trimAndShift(top, 1) or
+ trimAndShift(end, 2) or
+ trimAndShift(bottom, 3) or
+ if (isLayoutDirectionAware) IS_LAYOUT_DIRECTION_AWARE else 0L
+ }
+
+ private const val MASK = 0x7FFF
+
+ private const val SHIFT = 15
+
+ internal const val MAX_VALUE = MASK
+
+ private const val IS_LAYOUT_DIRECTION_AWARE = 1L shl 63
+
+ // We stored all
+ private fun unpack(packedValue: Long, position: Int): Int =
+ (packedValue shr (position * SHIFT)).toInt() and MASK
+
+ private fun trimAndShift(int: Int, position: Int): Long =
+ (int and MASK).toLong() shl (position * SHIFT)
+ }
+
+ /**
+ * The amount of pixels the touch bounds should be expanded along the start edge. When
+ * [isLayoutDirectionAware] is `true`, it's applied to the left edge when [LayoutDirection] is
+ * [LayoutDirection.Ltr] and vice versa. When [isLayoutDirectionAware] is `false`, it's always
+ * applied to the left edge.
+ */
+ val start: Int
+ get() = unpack(packedValue, 0)
+
+ /** The amount of pixels the touch bounds should be expanded along the top edge. */
+ val top: Int
+ get() = unpack(packedValue, 1)
+
+ /**
+ * The amount of pixels the touch bounds should be expanded along the end edge. When
+ * [isLayoutDirectionAware] is `true`, it's applied to the left edge when [LayoutDirection] is
+ * [LayoutDirection.Ltr] and vice versa. When [isLayoutDirectionAware] is `false`, it's always
+ * applied to the left edge.
+ */
+ val end: Int
+ get() = unpack(packedValue, 2)
+
+ /** The amount of pixels the touch bounds should be expanded along the bottom edge. */
+ val bottom: Int
+ get() = unpack(packedValue, 3)
+
+ /**
+ * Whether this [TouchBoundsExpansion] is aware of [LayoutDirection] or not. See [start] and
+ * [end] for more details.
+ */
+ val isLayoutDirectionAware: Boolean
+ get() = (packedValue and IS_LAYOUT_DIRECTION_AWARE) != 0L
+
+ /** Returns the amount of pixels the touch bounds is expanded towards left. */
+ internal fun computeLeft(layoutDirection: LayoutDirection): Int {
+ return if (!isLayoutDirectionAware || layoutDirection == LayoutDirection.Ltr) {
+ start
+ } else {
+ end
+ }
+ }
+
+ /** Returns the amount of pixels the touch bounds is expanded towards right. */
+ internal fun computeRight(layoutDirection: LayoutDirection): Int {
+ return if (!isLayoutDirectionAware || layoutDirection == LayoutDirection.Ltr) {
+ end
+ } else {
+ start
+ }
+ }
+}
+
+/**
+ * Describes the expansion of a [PointerInputModifierNode]'s touch bounds along each edges using
+ * [Dp] for units. See [DpTouchBoundsExpansion] factories and [Absolute] for convenient ways to
+ * build [DpTouchBoundsExpansion].
+ *
+ * @see PointerInputModifierNode.touchBoundsExpansion
+ */
+data class DpTouchBoundsExpansion(
+ val start: Dp,
+ val top: Dp,
+ val end: Dp,
+ val bottom: Dp,
+ val isLayoutDirectionAware: Boolean
+) {
+ init {
+ requirePrecondition(start.value >= 0) { "Left must be non-negative" }
+ requirePrecondition(top.value >= 0) { "Top must be non-negative" }
+ requirePrecondition(end.value >= 0) { "Right must be non-negative" }
+ requirePrecondition(bottom.value >= 0) { "Bottom must be non-negative" }
+ }
+
+ fun roundToTouchBoundsExpansion(density: Density) =
+ with(density) {
+ TouchBoundsExpansion(
+ packedValue =
+ TouchBoundsExpansion.pack(
+ start.roundToPx(),
+ top.roundToPx(),
+ end.roundToPx(),
+ bottom.roundToPx(),
+ isLayoutDirectionAware
+ )
+ )
+ }
+
+ companion object {
+ /**
+ * Creates a [DpTouchBoundsExpansion] that's unaware of [LayoutDirection]. The `left`,
+ * `top`, `right` and `bottom` represent the distance that the touch bounds is expanded
+ * along the corresponding edge.
+ */
+ fun Absolute(
+ left: Dp = 0.dp,
+ top: Dp = 0.dp,
+ right: Dp = 0.dp,
+ bottom: Dp = 0.dp
+ ): DpTouchBoundsExpansion {
+ return DpTouchBoundsExpansion(left, top, right, bottom, false)
+ }
+ }
+}
+
+/**
+ * Creates a [TouchBoundsExpansion] that's aware of [LayoutDirection]. See
+ * [TouchBoundsExpansion.start] and [TouchBoundsExpansion.end] for more details about
+ * [LayoutDirection].
+ *
+ * The `start`, `top`, `end` and `bottom` represent the amount of pixels that the touch bounds is
+ * expanded along the corresponding edge. Each value must be in the range of 0 to 32767 (inclusive).
+ */
+fun TouchBoundsExpansion(
+ start: Int = 0,
+ top: Int = 0,
+ end: Int = 0,
+ bottom: Int = 0
+): TouchBoundsExpansion {
+ requirePrecondition(start in 0..TouchBoundsExpansion.MAX_VALUE) {
+ "Start must be in the range of 0 .. ${TouchBoundsExpansion.MAX_VALUE}"
+ }
+ requirePrecondition(top in 0..TouchBoundsExpansion.MAX_VALUE) {
+ "Top must be in the range of 0 .. ${TouchBoundsExpansion.MAX_VALUE}"
+ }
+ requirePrecondition(end in 0..TouchBoundsExpansion.MAX_VALUE) {
+ "End must be in the range of 0 .. ${TouchBoundsExpansion.MAX_VALUE}"
+ }
+ requirePrecondition(bottom in 0..TouchBoundsExpansion.MAX_VALUE) {
+ "Bottom must be in the range of 0 .. ${TouchBoundsExpansion.MAX_VALUE}"
+ }
+ return TouchBoundsExpansion(
+ packedValue = TouchBoundsExpansion.pack(start, top, end, bottom, true)
+ )
+}
+
+/**
+ * Creates a [DpTouchBoundsExpansion] that's aware of [LayoutDirection]. See
+ * [DpTouchBoundsExpansion.start] and [DpTouchBoundsExpansion.end] for more details about
+ * [LayoutDirection].
+ *
+ * The `start`, `top`, `end` and `bottom` represent the distance that the touch bounds is expanded
+ * along the corresponding edge.
+ */
+fun DpTouchBoundsExpansion(
+ start: Dp = 0.dp,
+ top: Dp = 0.dp,
+ end: Dp = 0.dp,
+ bottom: Dp = 0.dp
+): DpTouchBoundsExpansion {
+ return DpTouchBoundsExpansion(start, top, end, bottom, true)
+}
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsNode.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsNode.kt
index 4bbacd0..84ae0fc 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsNode.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsNode.kt
@@ -45,7 +45,7 @@
layoutNode.nodes.head(Nodes.Semantics)!!.node,
mergingEnabled,
layoutNode,
- layoutNode.semanticsConfiguration!!
+ layoutNode.semanticsConfiguration ?: SemanticsConfiguration()
)
internal fun SemanticsNode(
diff --git a/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/CallSessionLegacyTest.kt b/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/CallSessionLegacyTest.kt
index b1020d5..7705abd 100644
--- a/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/CallSessionLegacyTest.kt
+++ b/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/CallSessionLegacyTest.kt
@@ -19,6 +19,8 @@
import android.os.Build.VERSION_CODES
import android.os.ParcelUuid
import android.telecom.CallAudioState
+import android.telecom.CallAudioState.ROUTE_EARPIECE
+import android.telecom.CallAudioState.ROUTE_WIRED_HEADSET
import android.telecom.CallEndpoint
import androidx.annotation.RequiresApi
import androidx.core.telecom.CallEndpointCompat
@@ -54,6 +56,30 @@
}
/**
+ * Test the setter for available endpoints removes the earpiece endpoint if the wired headset
+ * endpoint is available
+ */
+ @SmallTest
+ @Test
+ fun testRemovalOfEarpieceEndpointIfWiredEndpointIsPresent() {
+ setUpBackwardsCompatTest()
+ runBlocking {
+ val callSession =
+ initCallSessionLegacy(
+ coroutineContext,
+ null,
+ )
+ val supportedRouteMask = ROUTE_EARPIECE or ROUTE_WIRED_HEADSET
+ callSession.setAvailableCallEndpoints(
+ CallAudioState(false, ROUTE_WIRED_HEADSET, supportedRouteMask)
+ )
+ val res = callSession.getAvailableCallEndpointsForSession()
+ assertEquals(1, res.size)
+ assertEquals(res[0].type, CallEndpointCompat.TYPE_WIRED_HEADSET)
+ }
+ }
+
+ /**
* Verify the [CallEndpoint]s echoed from the platform are re-mapped to the existing
* [CallEndpointCompat]s the user received with
* [androidx.core.telecom.CallsManager#getAvailableStartingCallEndpoints()]
@@ -109,7 +135,7 @@
val supportedRouteMask =
CallAudioState.ROUTE_BLUETOOTH or
- CallAudioState.ROUTE_WIRED_HEADSET or
+ ROUTE_WIRED_HEADSET or
CallAudioState.ROUTE_SPEAKER
val cas = CallAudioState(false, CallAudioState.ROUTE_BLUETOOTH, supportedRouteMask)
diff --git a/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/CallSessionTest.kt b/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/CallSessionTest.kt
index 37a61c5..9146ca1 100644
--- a/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/CallSessionTest.kt
+++ b/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/CallSessionTest.kt
@@ -23,6 +23,7 @@
import androidx.core.telecom.CallEndpointCompat
import androidx.core.telecom.internal.CallChannels
import androidx.core.telecom.internal.CallSession
+import androidx.core.telecom.internal.utils.EndpointUtils
import androidx.core.telecom.test.utils.BaseTelecomTest
import androidx.core.telecom.test.utils.TestUtils
import androidx.core.telecom.util.ExperimentalAppActions
@@ -55,6 +56,24 @@
private val mEarAndSpeakerEndpoints = listOf(mEarpieceEndpoint, mSpeakerEndpoint)
private val mEarAndSpeakerAndBtEndpoints =
listOf(mEarpieceEndpoint, mSpeakerEndpoint, mBluetoothEndpoint)
+ private val mWiredAndEarpieceEndpoints = listOf(mEarpieceEndpoint, mWiredEndpoint)
+
+ /**
+ * Test the helper method that removes the earpiece call endpoint if the wired headset endpoint
+ * is present
+ */
+ @SdkSuppress(minSdkVersion = VERSION_CODES.UPSIDE_DOWN_CAKE)
+ @SmallTest
+ @Test
+ fun testRemovalOfEarpieceEndpointIfWiredEndpointIsPresent() {
+ setUpV2Test()
+ val res =
+ EndpointUtils.maybeRemoveEarpieceIfWiredEndpointPresent(
+ mWiredAndEarpieceEndpoints.toMutableList()
+ )
+ assertEquals(1, res.size)
+ assertEquals(res[0].type, CallEndpointCompat.TYPE_WIRED_HEADSET)
+ }
/**
* verify maybeDelaySwitchToSpeaker does NOT switch to speakerphone if the bluetooth device
diff --git a/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/utils/BaseTelecomTest.kt b/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/utils/BaseTelecomTest.kt
index dd71456..751bed8 100644
--- a/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/utils/BaseTelecomTest.kt
+++ b/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/utils/BaseTelecomTest.kt
@@ -32,6 +32,7 @@
import androidx.core.telecom.CallEndpointCompat.Companion.TYPE_BLUETOOTH
import androidx.core.telecom.CallEndpointCompat.Companion.TYPE_EARPIECE
import androidx.core.telecom.CallEndpointCompat.Companion.TYPE_SPEAKER
+import androidx.core.telecom.CallEndpointCompat.Companion.TYPE_WIRED_HEADSET
import androidx.core.telecom.CallsManager
import androidx.core.telecom.internal.CallEndpointUuidTracker
import androidx.core.telecom.internal.JetpackConnectionService
@@ -75,6 +76,7 @@
val mEarpieceEndpoint = CallEndpointCompat("EARPIECE", TYPE_EARPIECE, mBaseSessionId)
val mSpeakerEndpoint = CallEndpointCompat("SPEAKER", TYPE_SPEAKER, mBaseSessionId)
val mBluetoothEndpoint = CallEndpointCompat("BLUETOOTH", TYPE_BLUETOOTH, mBaseSessionId)
+ val mWiredEndpoint = CallEndpointCompat("WIRED", TYPE_WIRED_HEADSET, mBaseSessionId)
@Before
fun setUpBase() {
diff --git a/core/core-telecom/src/main/java/androidx/core/telecom/internal/CallSession.kt b/core/core-telecom/src/main/java/androidx/core/telecom/internal/CallSession.kt
index 1487911..5e3b4f2 100644
--- a/core/core-telecom/src/main/java/androidx/core/telecom/internal/CallSession.kt
+++ b/core/core-telecom/src/main/java/androidx/core/telecom/internal/CallSession.kt
@@ -35,6 +35,7 @@
import androidx.core.telecom.internal.utils.EndpointUtils.Companion.isBluetoothAvailable
import androidx.core.telecom.internal.utils.EndpointUtils.Companion.isEarpieceEndpoint
import androidx.core.telecom.internal.utils.EndpointUtils.Companion.isWiredHeadsetOrBtEndpoint
+import androidx.core.telecom.internal.utils.EndpointUtils.Companion.maybeRemoveEarpieceIfWiredEndpointPresent
import java.util.function.Consumer
import kotlin.coroutines.CoroutineContext
import kotlinx.coroutines.CompletableDeferred
@@ -66,7 +67,7 @@
private var mPlatformInterface: CallControl? = null
// cache the latest current and available endpoints
private var mCurrentCallEndpoint: CallEndpointCompat? = null
- private var mAvailableEndpoints: List<CallEndpointCompat> = ArrayList()
+ private var mAvailableEndpoints: MutableList<CallEndpointCompat> = mutableListOf()
private var mLastClientRequestedEndpoint: CallEndpointCompat? = null
// use CompletableDeferred objects to signal when all the endpoint values have initially
// been received from the platform.
@@ -112,7 +113,7 @@
@VisibleForTesting
fun setAvailableCallEndpoints(endpoints: List<CallEndpointCompat>) {
- mAvailableEndpoints = endpoints
+ mAvailableEndpoints = endpoints.toMutableList()
}
/**
@@ -165,7 +166,9 @@
override fun onAvailableCallEndpointsChanged(endpoints: List<CallEndpoint>) {
// due to the [CallsManager#getAvailableStartingCallEndpoints] API, endpoints the client
// has can be different from the ones coming from the platform. Hence, a remapping is needed
- mAvailableEndpoints = endpoints.map { toRemappedCallEndpointCompat(it) }.sorted()
+ mAvailableEndpoints =
+ endpoints.map { toRemappedCallEndpointCompat(it) }.sorted().toMutableList()
+ maybeRemoveEarpieceIfWiredEndpointPresent(mAvailableEndpoints)
// send the current call endpoints out to the client
callChannels.availableEndpointChannel.trySend(mAvailableEndpoints).getOrThrow()
Log.i(TAG, "onAvailableCallEndpointsChanged: endpoints=[$endpoints]")
diff --git a/core/core-telecom/src/main/java/androidx/core/telecom/internal/CallSessionLegacy.kt b/core/core-telecom/src/main/java/androidx/core/telecom/internal/CallSessionLegacy.kt
index 2c6c17f..4bc7b19 100644
--- a/core/core-telecom/src/main/java/androidx/core/telecom/internal/CallSessionLegacy.kt
+++ b/core/core-telecom/src/main/java/androidx/core/telecom/internal/CallSessionLegacy.kt
@@ -41,6 +41,7 @@
import androidx.core.telecom.internal.utils.EndpointUtils.Companion.isBluetoothAvailable
import androidx.core.telecom.internal.utils.EndpointUtils.Companion.isEarpieceEndpoint
import androidx.core.telecom.internal.utils.EndpointUtils.Companion.isWiredHeadsetOrBtEndpoint
+import androidx.core.telecom.internal.utils.EndpointUtils.Companion.maybeRemoveEarpieceIfWiredEndpointPresent
import androidx.core.telecom.internal.utils.EndpointUtils.Companion.toCallEndpointCompat
import androidx.core.telecom.internal.utils.EndpointUtils.Companion.toCallEndpointsCompat
import androidx.core.telecom.internal.utils.Utils.Companion.isBuildAtLeastP
@@ -76,7 +77,7 @@
private var mAlreadyRequestedSpeaker: Boolean = false
private var mPreviousCallEndpoint: CallEndpointCompat? = null
private var mCurrentCallEndpoint: CallEndpointCompat? = null
- private var mAvailableCallEndpoints: List<CallEndpointCompat>? = null
+ private var mAvailableCallEndpoints: MutableList<CallEndpointCompat> = mutableListOf()
private var mLastClientRequestedEndpoint: CallEndpointCompat? = null
private val mCallSessionLegacyId: Int = CallEndpointUuidTracker.startSession()
private var mGlobalMuteStateReceiver: MuteStateReceiver? = null
@@ -100,6 +101,11 @@
return mCurrentCallEndpoint
}
+ @VisibleForTesting
+ internal fun getAvailableCallEndpointsForSession(): MutableList<CallEndpointCompat> {
+ return mAvailableCallEndpoints
+ }
+
companion object {
private const val WAIT_FOR_BT_TO_CONNECT_TIMEOUT: Long = 1000L
// TODO:: b/369153472 , remove delay and instead wait until onCallEndpointChanged
@@ -173,12 +179,14 @@
callChannels.currentEndpointChannel.trySend(mCurrentCallEndpoint!!).getOrThrow()
}
- private fun setAvailableCallEndpoints(state: CallAudioState) {
+ @VisibleForTesting
+ internal fun setAvailableCallEndpoints(state: CallAudioState) {
val availableEndpoints =
toCallEndpointsCompat(state, mCallSessionLegacyId)
.map { toRemappedCallEndpointCompat(it) }
.sorted()
- mAvailableCallEndpoints = availableEndpoints
+ mAvailableCallEndpoints = availableEndpoints.toMutableList()
+ maybeRemoveEarpieceIfWiredEndpointPresent(mAvailableCallEndpoints)
callChannels.availableEndpointChannel.trySend(availableEndpoints).getOrThrow()
}
@@ -193,16 +201,14 @@
// On the first call audio state change, determine if the platform started on the
// correct audio route. Otherwise, request an endpoint switch.
- if (mAvailableCallEndpoints != null) {
- switchStartingCallEndpointOnCallStart(mAvailableCallEndpoints!!)
- }
+ switchStartingCallEndpointOnCallStart(mAvailableCallEndpoints)
// In the event the users headset disconnects, they will likely want to continue the
// call via the speakerphone
- if (mCurrentCallEndpoint != null && mAvailableCallEndpoints != null) {
+ if (mCurrentCallEndpoint != null) {
maybeSwitchToSpeakerOnHeadsetDisconnect(
mCurrentCallEndpoint!!,
mPreviousCallEndpoint,
- mAvailableCallEndpoints!!,
+ mAvailableCallEndpoints,
)
}
} catch (e: Exception) {
diff --git a/core/core-telecom/src/main/java/androidx/core/telecom/internal/utils/EndpointUtils.kt b/core/core-telecom/src/main/java/androidx/core/telecom/internal/utils/EndpointUtils.kt
index f868af5..4c6f2cd 100644
--- a/core/core-telecom/src/main/java/androidx/core/telecom/internal/utils/EndpointUtils.kt
+++ b/core/core-telecom/src/main/java/androidx/core/telecom/internal/utils/EndpointUtils.kt
@@ -39,6 +39,15 @@
const val BLUETOOTH_DEVICE_DEFAULT_NAME = "Bluetooth Device"
private val TAG: String = EndpointUtils::class.java.simpleName.toString()
+ internal fun maybeRemoveEarpieceIfWiredEndpointPresent(
+ endpoints: MutableList<CallEndpointCompat>
+ ): MutableList<CallEndpointCompat> {
+ if (endpoints.any { it.type == CallEndpointCompat.TYPE_WIRED_HEADSET }) {
+ endpoints.removeIf { it.type == CallEndpointCompat.TYPE_EARPIECE }
+ }
+ return endpoints
+ }
+
/** [AudioDeviceInfo]s to [CallEndpointCompat]s */
fun getEndpointsFromAudioDeviceInfo(
c: Context,
@@ -49,10 +58,14 @@
return listOf()
}
val endpoints: MutableList<CallEndpointCompat> = mutableListOf()
+ var foundWiredHeadset = false
val omittedDevices = StringBuilder("omitting devices =[")
adiArr.toList().forEach { audioDeviceInfo ->
val endpoint = getEndpointFromAudioDeviceInfo(c, flowId, audioDeviceInfo)
if (endpoint.type != CallEndpointCompat.TYPE_UNKNOWN) {
+ if (endpoint.type == CallEndpointCompat.TYPE_WIRED_HEADSET) {
+ foundWiredHeadset = true
+ }
endpoints.add(endpoint)
} else {
omittedDevices.append(
@@ -63,6 +76,9 @@
}
omittedDevices.append("]")
Log.i(TAG, omittedDevices.toString())
+ if (foundWiredHeadset) {
+ endpoints.removeIf { it.type == CallEndpointCompat.TYPE_EARPIECE }
+ }
// Sort by endpoint type. The first element has the highest priority!
endpoints.sort()
return endpoints
diff --git a/core/core/src/androidTest/java/androidx/core/view/ViewGroupCompatTest.java b/core/core/src/androidTest/java/androidx/core/view/ViewGroupCompatTest.java
index 3665e9c..4009846 100644
--- a/core/core/src/androidTest/java/androidx/core/view/ViewGroupCompatTest.java
+++ b/core/core/src/androidTest/java/androidx/core/view/ViewGroupCompatTest.java
@@ -16,6 +16,8 @@
package androidx.core.view;
+import static androidx.core.view.WindowInsetsAnimationCompat.Callback.DISPATCH_MODE_STOP;
+
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
@@ -32,6 +34,7 @@
import android.view.WindowInsets;
import android.widget.FrameLayout;
+import androidx.annotation.NonNull;
import androidx.core.graphics.Insets;
import androidx.core.test.R;
import androidx.test.ext.junit.runners.AndroidJUnit4;
@@ -42,6 +45,8 @@
import org.junit.Test;
import org.junit.runner.RunWith;
+import java.util.List;
+
@RunWith(AndroidJUnit4.class)
@MediumTest
@@ -125,6 +130,20 @@
}
};
+ ViewGroupCompat.installCompatInsetsDispatch(mViewGroup);
+
+ // This is to test if ViewCompat#setWindowInsetsAnimationCallback would overwrite the
+ // View.OnApplyWindowInsetsListener set by ViewGroupCompat#installCompatInsetsDispatch
+ ViewCompat.setWindowInsetsAnimationCallback(mViewGroup,
+ new WindowInsetsAnimationCompat.Callback(DISPATCH_MODE_STOP) {
+ @NonNull
+ @Override
+ public WindowInsetsCompat onProgress(@NonNull WindowInsetsCompat insets,
+ @NonNull List<WindowInsetsAnimationCompat> runningAnimations) {
+ return insets;
+ }
+ });
+
// mViewGroup --+-- viewA --+-- viewA1
// | |
// | +-- viewA2
@@ -170,9 +189,7 @@
return insets;
});
- ViewGroupCompat.installCompatInsetsDispatch(mViewGroup);
-
- ViewCompat.dispatchApplyWindowInsets(mViewGroup, new InsetsObtainer(context).obtain(
+ mViewGroup.dispatchApplyWindowInsets(new InsetsObtainer(context).obtain(
10, 10, 10, 10));
// viewA consumes the insets, so its child views (viewA1 and viewA2) shouldn't receive
@@ -212,12 +229,12 @@
return insets;
}
- public WindowInsetsCompat obtain(int left, int top, int right, int bottom) {
+ public WindowInsets obtain(int left, int top, int right, int bottom) {
// Before API 28, there is no other way to create an unconsumed WindowInsets instance.
// Calling fitSystemWindows here makes the framework dispatch a WindowInsets with the
// given system window insets to this view.
fitSystemWindows(new Rect(left, top, right, bottom));
- return WindowInsetsCompat.toWindowInsetsCompat(mWindowInsets);
+ return mWindowInsets;
}
}
diff --git a/core/core/src/main/java/androidx/core/view/WindowInsetsAnimationCompat.java b/core/core/src/main/java/androidx/core/view/WindowInsetsAnimationCompat.java
index aff92af..9b57ee8 100644
--- a/core/core/src/main/java/androidx/core/view/WindowInsetsAnimationCompat.java
+++ b/core/core/src/main/java/androidx/core/view/WindowInsetsAnimationCompat.java
@@ -614,27 +614,19 @@
static void setCallback(@NonNull final View view,
@Nullable final Callback callback) {
+ final View.OnApplyWindowInsetsListener proxyListener = callback != null
+ ? createProxyListener(view, callback)
+ : null;
+ view.setTag(R.id.tag_window_insets_animation_callback, proxyListener);
- Object userListener = view.getTag(R.id.tag_on_apply_window_listener);
- if (callback == null) {
- view.setTag(R.id.tag_window_insets_animation_callback, null);
- if (userListener == null) {
- // If no user defined listener is set, that means our listener is the one set.
- // Make sure to remove it.
- view.setOnApplyWindowInsetsListener(null);
- }
- } else {
- View.OnApplyWindowInsetsListener proxyListener =
- createProxyListener(view, callback);
- view.setTag(R.id.tag_window_insets_animation_callback, proxyListener);
-
- // We rely on OnApplyWindowInsetsListener, but one might already be set by the
- // application, so we only register it on the view if none is set yet.
- // If one is set using ViewCompat.setOnApplyWindowInsetsListener,
- // this Callback will be called by the exiting listener.
- if (userListener == null) {
- view.setOnApplyWindowInsetsListener(proxyListener);
- }
+ // We rely on View.OnApplyWindowInsetsListener, but one might already be set by the
+ // library, so we only register it on the view if none is set yet.
+ // If any of them is set via ViewGroupCompat#installCompatInsetsDispatch or
+ // ViewCompat.setOnApplyWindowInsetsListener, this Callback will be called by their
+ // listener.
+ if (view.getTag(R.id.tag_compat_insets_dispatch) == null
+ && view.getTag(R.id.tag_on_apply_window_listener) == null) {
+ view.setOnApplyWindowInsetsListener(proxyListener);
}
}
diff --git a/development/validateRefactor.sh b/development/validateRefactor.sh
index 1fb6cd2..62e907e 100755
--- a/development/validateRefactor.sh
+++ b/development/validateRefactor.sh
@@ -186,5 +186,5 @@
echo
echo diffing results
# This script performs the diff, and filters out known issues and non-issues with baselines
-python development/validateRefactorHelper.py "$passThruArgs"
+python3 development/validateRefactorHelper.py "$passThruArgs"
echo end of difference
diff --git a/development/validateRefactorHelper.py b/development/validateRefactorHelper.py
index 39bb38d..ba33c5e 100644
--- a/development/validateRefactorHelper.py
+++ b/development/validateRefactorHelper.py
@@ -25,18 +25,26 @@
python validateRefactorHelper.py agpKmp
"""
import itertools
+import logging
+import queue
import re
import shutil
import subprocess
import sys
+import threading
from typing import Dict
+logger = logging.getLogger(__name__)
+logging.basicConfig(level=logging.INFO)
+
# noto-emoji-compat `bundleinside`s an externally-built with-timestamps jar.
# classes.jar is compared using `diffuse` instead of unzipping and diffing class files.
bannedJars = ["-x", "noto-emoji-compat-java.jar", "-x", "classes.jar"]
-# java and json aren"t for unzipping, but the poor exclude-everything-but-jars regex doesn't
+# java and json aren't for unzipping, but the poor exclude-everything-but-jars regex doesn't
# exclude them. Same for exclude-non-klib and .kt/.knm
-areNotZips = ["-x", r"**\.java", "-x", r"**\.json", "-x", r"**\.kt", "-x", r"**\.knm"]
+areNotZips = ["-x", r"**\.java", "-x", r"**\.json", "-x", r"**\.kt", "-x", r"**\.knm", "-x", r"**\.xml",
+ "-x", r"**\.sha1", "-x", r"**\.sha256", "-x", r"**\.sha512", "-x", r"**\.md5",
+ "-x", r"**\.module", "-x", r"**\.pom"]
# keeps making my regexes fall over :(
hasNoExtension = ["-x", "manifest", "-x", "module"]
doNotUnzip = bannedJars + areNotZips + hasNoExtension
@@ -45,36 +53,62 @@
return popenAndReturn(["diff", "-r", "../../out-old/dist/", "../../out-new/dist/"] + excludes)
def popenAndReturn(args):
+ logger.debug(" ".join(args))
return subprocess.Popen(args, stdout=subprocess.PIPE).stdout.read().decode("utf-8").split("\n")
-# Finds and unzips all files with old/new diff that _do not_ match the argument regex.
-def findFilesMatchingWithDiffAndUnzip(regexThatMatchesEverythingElse):
+# Finds and unzips all files with old/new diff that _do not_ match the argument regexes.
+# Because the `diff` command doesn't have an --include, only --exclude.
+def findFilesNotMatchingWithDiffAndUnzip(*regexesToExclude):
+ excludeArgs = list(itertools.chain.from_iterable(zip(["-x"]*9, regexesToExclude)))
# Exclude all things that are *not* the desired zip type
- # (because diff doesn"t have an --include, only --exclude).
- zipsWithDiffs = diff(["-q", "-x", regexThatMatchesEverythingElse] + doNotUnzip)
+ zipsWithDiffs = diff(["-q"] + excludeArgs + doNotUnzip)
# Take only changed files, not new/deleted ones (the diff there is obvious)
zipsWithDiffs = filter(lambda s: s.startswith("Files"), zipsWithDiffs)
zipsWithDiffs = map(lambda s: s.split()[1:4:2], zipsWithDiffs)
- zipsWithDiffs = list(itertools.chain.from_iterable(zipsWithDiffs)) # flatten
+ zipsWithDiffs = itertools.chain.from_iterable(zipsWithDiffs) # flatten
+ workQueueOfZips = queue.LifoQueue()
+ for it in zipsWithDiffs: workQueueOfZips.put(it)
# And unzip them
- for filename in zipsWithDiffs:
- print("unzipping " + filename)
- shutil.rmtree(filename+".unzipped/")
- subprocess.Popen(["unzip", "-qq", "-o", filename, "-d", filename+".unzipped/"])
+ # If we spam unzip commands without a break, the unzips start failing.
+ # if we wait after every Popen, the script runs very slowly
+ # So create a pool of 10 unzip workers to consume from zipsWithDiffs
+ numWorkers = 10
+ workers = []
+ for i in range(min(numWorkers, workQueueOfZips.qsize())):
+ w = threading.Thread(target=unzipWorker, args=(workQueueOfZips,))
+ w.start()
+ workers.append(w)
+ for w in workers: w.join()
-diffusePath = "../../prebuilts/build-tools/diffuse-0.3.0/bin/diffuse"
+def unzipWorker(workQueueOfZips):
+ while not workQueueOfZips.empty():
+ zipFilePath = workQueueOfZips.get(0)
+ try: shutil.rmtree(zipFilePath+".unzipped/")
+ except FileNotFoundError: pass
+ logger.debug("unzipping " + zipFilePath)
+ subprocess.Popen(["unzip", "-qq", "-o", zipFilePath, "-d", zipFilePath+".unzipped/"]).wait()
+diffusePath = "../../prebuilts/build-tools/diffuse/diffuse-0.3.0/bin/diffuser"
+
+diffuseIsPresent = True
def compareWithDiffuse(listOfJars):
+ global diffuseIsPresent
+ if not diffuseIsPresent: return
for jarPath in list(filter(None, listOfJars)):
- print("jarpath: " + jarPath)
+ logger.info("jarpath: " + jarPath)
newJarPath = jarPath.replace("out-old", "out-new")
- print(popenAndReturn([diffusePath, "diff", "--jar", jarPath, newJarPath]))
+ try: logger.info("\n".join(popenAndReturn([diffusePath, "diff", "--jar", jarPath, newJarPath])))
+ except FileNotFoundError:
+ logger.warning(f"https://github.com/JakeWharton/diffuse is not present on disk in expected location"
+ f" ${diffusePath}. You can install it.")
+ diffuseIsPresent = False
+ return
# We might care to know whether .sha1 or .md5 files have changed, but changes in those files will
# always be accompanied by more meaningful changes in other files, so we don"t need to show changes
# in .sha1 or .md5 files, or in .module files showing the hashes of other files, or config names.
-excludedHashes = ["-x", "*.md5*", "-x", "*.sha**", "-I", " \"md5\".*", \
- "-I", " \"sha.*", "-I", " \"size\".*", "-I", " \"name\".*"]
+excludedHashes = ["-x", "*.md5*", "-x", "*.sha**", "-I", " \"md5\".*",
+"-I", " \"sha.*", "-I", " \"size\".*", "-I", " \"name\".*"]
# Don"t care about maven-metadata files because they have timestamps in them.
# temporarily ignore knm files
# If changes to the dackka args json are meaningful, they will affect the generated docs and show diff there
@@ -168,7 +202,17 @@
re.compile(r"""[0-9]+c[0-9]+
< <artifactId>kotlinx-coroutines-core-jvm</artifactId>
---
-> <artifactId>kotlinx-coroutines-core</artifactId>""")
+> <artifactId>kotlinx-coroutines-core</artifactId>"""),
+# AGP-KMP adds a new default sourceSet, which in itself doesn't do anything
+re.compile(r"""(11,17d10|12,18d11)
+< "name": "androidRelease",
+< "dependencies": \[
+< "commonMain"
+< \],
+< "analysisPlatform": "jvm"
+< \},
+< \{
+"""),
]
baselines = []
@@ -177,12 +221,13 @@
arguments = sys.argv[1:]
if "agpKmp" in arguments:
arguments.remove("agpKmp"); baselines += ["agpKmp"]
- print("IGNORING DIFF FOR agpKmp")
+ logger.info("IGNORING DIFF FOR agpKmp")
baselinedChanges += baselinedChangesForAgpKmp
unskippableBaselinedChanges += unskippableBaselinedChangesForAgpKmp
+ excludedFiles += ["-x", r"**\.aar.unzipped/res"] # agp-kmp may add this empty
if arguments:
- print("invalid argument(s) for validateRefactorHelper: " + ", ".join(arguments))
- print("currently recognized arguments: agpKmp")
+ logger.error("invalid argument(s) for validateRefactorHelper: " + ", ".join(arguments))
+ logger.error("currently recognized arguments: agpKmp")
exit()
# interleave "-I" to tell diffutils to 'I'gnore the baselined lines
@@ -217,7 +262,7 @@
if len(keptContentLines) == 0: return ""
# return value is based on `lines` because we want to retain ordering we may have lost during processing
# We want to keep keptContentLines, and formatting lines like "---" and the header (which don't start with <>).
- return "\n".join([line for line in lines if line != "" and ((not line[0] in "<>") or line in keptContentLines)])
+ return "\n".join([line for line in lines if (line != "") and ((not line[0] in "<>") or line in keptContentLines)])
# The output of diff entails multiple files, and multiple segments per file
# This function removes baselined changes from the entire diff output
@@ -245,18 +290,24 @@
if len(result) != 0: processedPerFileDiffs += [newFilePath + "\n" + "\n".join(result)]
return "\ndiff ".join(processedPerFileDiffs)
+# We unzip multiple times in this order because e.g. zips can contain apks.
# Find all zip files with a diff, e.g. the tip-of-tree-repository file, and maybe the docs zip
-# findFilesMatchingWithDiffAndUnzip(r"**\.[^z][a-z]*")
+logger.info("UNZIPPING ZIP FILES");
+findFilesNotMatchingWithDiffAndUnzip(r"**\.[^z][a-z]*")
# Find all aar and apk files with a diff. The proper regex would be `.*\..*[^akpr]+.*`, but it
# doesn"t work in difftools exclude's very limited regex syntax.
-findFilesMatchingWithDiffAndUnzip(r"**\.[^a][a-z]*")
+logger.info("UNZIPPING AAR/APK FILES");
+findFilesNotMatchingWithDiffAndUnzip(r"**\.zip", r"**\.jar", r"**\.klib")
# Find all jars and klibs and unzip them (comes after because they could be inside aars/apks).
-findFilesMatchingWithDiffAndUnzip(r"**\.[^j][a-z]*")
-findFilesMatchingWithDiffAndUnzip(r"**\.[^k][a-z]*")
+logger.info("UNZIPPING JAR/KLIB FILES");
+findFilesNotMatchingWithDiffAndUnzip(r"**\.zip", r"**\.aar", r"**\.apk")
+
# now find all diffs in classes.jars
-classesJarsWithDiffs = popenAndReturn(["find", "../../out-old/dist/", "-name", "classes.jar"])
-print("classes.jar s: " + str(classesJarsWithDiffs))
-compareWithDiffuse(classesJarsWithDiffs)
+# TODO(375636734) Disabled because this tracks internal methods' diffs
+# classesJarsWithDiffs = popenAndReturn(["find", "../../out-old/dist/", "-name", "classes.jar"])
+logger.info("classes.jar s: " + str(classesJarsWithDiffs))
+# compareWithDiffuse(classesJarsWithDiffs)
+
# Now find all diffs in non-zipped files
finalExcludes = excludedHashes + excludedFiles + excludedZips + baselinedChangesArgs
finalDiff = "\n".join(diff(finalExcludes))
diff --git a/docs-public/build.gradle b/docs-public/build.gradle
index 4791417..5498f46 100644
--- a/docs-public/build.gradle
+++ b/docs-public/build.gradle
@@ -238,32 +238,32 @@
docs("androidx.media2:media2-widget:1.3.0")
docs("androidx.media:media:1.7.0")
// androidx.media3 is not hosted in androidx
- docsWithoutApiSince("androidx.media3:media3-cast:1.5.0-beta01")
- docsWithoutApiSince("androidx.media3:media3-common:1.5.0-beta01")
- docsWithoutApiSince("androidx.media3:media3-common-ktx:1.5.0-beta01")
- docsWithoutApiSince("androidx.media3:media3-container:1.5.0-beta01")
- docsWithoutApiSince("androidx.media3:media3-database:1.5.0-beta01")
- docsWithoutApiSince("androidx.media3:media3-datasource:1.5.0-beta01")
- docsWithoutApiSince("androidx.media3:media3-datasource-cronet:1.5.0-beta01")
- docsWithoutApiSince("androidx.media3:media3-datasource-okhttp:1.5.0-beta01")
- docsWithoutApiSince("androidx.media3:media3-datasource-rtmp:1.5.0-beta01")
- docsWithoutApiSince("androidx.media3:media3-decoder:1.5.0-beta01")
- docsWithoutApiSince("androidx.media3:media3-effect:1.5.0-beta01")
- docsWithoutApiSince("androidx.media3:media3-exoplayer:1.5.0-beta01")
- docsWithoutApiSince("androidx.media3:media3-exoplayer-dash:1.5.0-beta01")
- docsWithoutApiSince("androidx.media3:media3-exoplayer-hls:1.5.0-beta01")
- docsWithoutApiSince("androidx.media3:media3-exoplayer-ima:1.5.0-beta01")
- docsWithoutApiSince("androidx.media3:media3-exoplayer-rtsp:1.5.0-beta01")
- docsWithoutApiSince("androidx.media3:media3-exoplayer-smoothstreaming:1.5.0-beta01")
- docsWithoutApiSince("androidx.media3:media3-exoplayer-workmanager:1.5.0-beta01")
- docsWithoutApiSince("androidx.media3:media3-extractor:1.5.0-beta01")
- docsWithoutApiSince("androidx.media3:media3-muxer:1.5.0-beta01")
- docsWithoutApiSince("androidx.media3:media3-session:1.5.0-beta01")
- docsWithoutApiSince("androidx.media3:media3-test-utils:1.5.0-beta01")
- docsWithoutApiSince("androidx.media3:media3-test-utils-robolectric:1.5.0-beta01")
- docsWithoutApiSince("androidx.media3:media3-transformer:1.5.0-beta01")
- docsWithoutApiSince("androidx.media3:media3-ui:1.5.0-beta01")
- docsWithoutApiSince("androidx.media3:media3-ui-leanback:1.5.0-beta01")
+ docsWithoutApiSince("androidx.media3:media3-cast:1.5.0-rc01")
+ docsWithoutApiSince("androidx.media3:media3-common:1.5.0-rc01")
+ docsWithoutApiSince("androidx.media3:media3-common-ktx:1.5.0-rc01")
+ docsWithoutApiSince("androidx.media3:media3-container:1.5.0-rc01")
+ docsWithoutApiSince("androidx.media3:media3-database:1.5.0-rc01")
+ docsWithoutApiSince("androidx.media3:media3-datasource:1.5.0-rc01")
+ docsWithoutApiSince("androidx.media3:media3-datasource-cronet:1.5.0-rc01")
+ docsWithoutApiSince("androidx.media3:media3-datasource-okhttp:1.5.0-rc01")
+ docsWithoutApiSince("androidx.media3:media3-datasource-rtmp:1.5.0-rc01")
+ docsWithoutApiSince("androidx.media3:media3-decoder:1.5.0-rc01")
+ docsWithoutApiSince("androidx.media3:media3-effect:1.5.0-rc01")
+ docsWithoutApiSince("androidx.media3:media3-exoplayer:1.5.0-rc01")
+ docsWithoutApiSince("androidx.media3:media3-exoplayer-dash:1.5.0-rc01")
+ docsWithoutApiSince("androidx.media3:media3-exoplayer-hls:1.5.0-rc01")
+ docsWithoutApiSince("androidx.media3:media3-exoplayer-ima:1.5.0-rc01")
+ docsWithoutApiSince("androidx.media3:media3-exoplayer-rtsp:1.5.0-rc01")
+ docsWithoutApiSince("androidx.media3:media3-exoplayer-smoothstreaming:1.5.0-rc01")
+ docsWithoutApiSince("androidx.media3:media3-exoplayer-workmanager:1.5.0-rc01")
+ docsWithoutApiSince("androidx.media3:media3-extractor:1.5.0-rc01")
+ docsWithoutApiSince("androidx.media3:media3-muxer:1.5.0-rc01")
+ docsWithoutApiSince("androidx.media3:media3-session:1.5.0-rc01")
+ docsWithoutApiSince("androidx.media3:media3-test-utils:1.5.0-rc01")
+ docsWithoutApiSince("androidx.media3:media3-test-utils-robolectric:1.5.0-rc01")
+ docsWithoutApiSince("androidx.media3:media3-transformer:1.5.0-rc01")
+ docsWithoutApiSince("androidx.media3:media3-ui:1.5.0-rc01")
+ docsWithoutApiSince("androidx.media3:media3-ui-leanback:1.5.0-rc01")
docs("androidx.mediarouter:mediarouter:1.7.0")
docs("androidx.mediarouter:mediarouter-testing:1.7.0")
docs("androidx.metrics:metrics-performance:1.0.0-beta01")
diff --git a/docs-tip-of-tree/build.gradle b/docs-tip-of-tree/build.gradle
index 1ddf116..2fb1ba5 100644
--- a/docs-tip-of-tree/build.gradle
+++ b/docs-tip-of-tree/build.gradle
@@ -240,6 +240,7 @@
kmpDocs(project(":lifecycle:lifecycle-viewmodel-compose"))
docs(project(":lifecycle:lifecycle-viewmodel-ktx"))
kmpDocs(project(":lifecycle:lifecycle-viewmodel-savedstate"))
+ samples(project(":lifecycle:lifecycle-viewmodel-savedstate-samples"))
kmpDocs(project(":lifecycle:lifecycle-viewmodel-testing"))
docs(project(":loader:loader"))
docs(project(":loader:loader-ktx"))
diff --git a/exifinterface/exifinterface/src/main/java/androidx/exifinterface/media/ExifInterface.java b/exifinterface/exifinterface/src/main/java/androidx/exifinterface/media/ExifInterface.java
index f3dabe7..41cd8c1 100644
--- a/exifinterface/exifinterface/src/main/java/androidx/exifinterface/media/ExifInterface.java
+++ b/exifinterface/exifinterface/src/main/java/androidx/exifinterface/media/ExifInterface.java
@@ -23,6 +23,7 @@
import static androidx.exifinterface.media.ExifInterfaceUtils.parseSubSeconds;
import static androidx.exifinterface.media.ExifInterfaceUtils.startsWith;
+import static java.lang.annotation.ElementType.TYPE_USE;
import static java.nio.ByteOrder.BIG_ENDIAN;
import static java.nio.ByteOrder.LITTLE_ENDIAN;
@@ -65,6 +66,7 @@
import java.io.OutputStream;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.Charset;
@@ -3032,6 +3034,36 @@
@IntDef({STREAM_TYPE_FULL_IMAGE_DATA, STREAM_TYPE_EXIF_DATA_ONLY})
public @interface ExifStreamType {}
+ @Retention(RetentionPolicy.SOURCE)
+ @Target(TYPE_USE)
+ @IntDef({
+ XMP_HANDLING_TIFF_700_ONLY,
+ XMP_HANDLING_PREFER_SEPARATE,
+ XMP_HANDLING_PREFER_TIFF_700_IF_PRESENT
+ })
+ private @interface XmpHandling {}
+
+ /**
+ * The format only supports XMP data stored in the TIFF/Exif 700 tag (or it supports storing XMP
+ * in a separate segment but this should never be used).
+ */
+ private static final int XMP_HANDLING_TIFF_700_ONLY = 1;
+
+ /**
+ * The format supports XMP data stored in a separate segment, and this should always be
+ * preferred if present.
+ *
+ * <p>If XMP data is only present in the TIFF/Exif 700 tag it will be read and written from
+ * there.
+ */
+ private static final int XMP_HANDLING_PREFER_SEPARATE = 2;
+
+ /**
+ * The format supports XMP data stored in a separate segment, and this should be preferred
+ * unless XMP is also present in the TIFF/Exif 700 tag.
+ */
+ private static final int XMP_HANDLING_PREFER_TIFF_700_IF_PRESENT = 3;
+
// Maximum size for checking file type signature (see image_type_recognition_lite.cc)
private static final int SIGNATURE_CHECK_SIZE = 5000;
@@ -3097,8 +3129,8 @@
private static final int WEBP_CHUNK_TYPE_BYTE_LENGTH = 4;
private static final int WEBP_CHUNK_SIZE_BYTE_LENGTH = 4;
- private static SimpleDateFormat sFormatterPrimary;
- private static SimpleDateFormat sFormatterSecondary;
+ private static final SimpleDateFormat sFormatterPrimary;
+ private static final SimpleDateFormat sFormatterSecondary;
// See Exchangeable image file format for digital still cameras: Exif version 2.2.
// The following values are for parsing EXIF data area. There are tag groups in EXIF data area.
@@ -3132,17 +3164,16 @@
private static final int SKIP_BUFFER_SIZE = 8192;
// Names for the data formats for debugging purpose.
- static final String[] IFD_FORMAT_NAMES = new String[] {
+ private static final String[] IFD_FORMAT_NAMES = new String[] {
"", "BYTE", "STRING", "USHORT", "ULONG", "URATIONAL", "SBYTE", "UNDEFINED", "SSHORT",
"SLONG", "SRATIONAL", "SINGLE", "DOUBLE", "IFD"
};
// Sizes of the components of each IFD value format
- static final int[] IFD_FORMAT_BYTES_PER_FORMAT = new int[] {
+ private static final int[] IFD_FORMAT_BYTES_PER_FORMAT = new int[] {
0, 1, 1, 2, 4, 8, 1, 1, 2, 4, 8, 4, 8, 1
};
- @SuppressWarnings("WeakerAccess") /* synthetic access */
- static final byte[] EXIF_ASCII_PREFIX = new byte[] {
+ private static final byte[] EXIF_ASCII_PREFIX = new byte[] {
0x41, 0x53, 0x43, 0x49, 0x49, 0x0, 0x0, 0x0
};
@@ -3153,8 +3184,7 @@
public final long numerator;
public final long denominator;
- @SuppressWarnings("WeakerAccess") /* synthetic access */
- Rational(long numerator, long denominator) {
+ private Rational(long numerator, long denominator) {
// Handle erroneous case
if (denominator == 0) {
this.numerator = 0;
@@ -3220,12 +3250,10 @@
public final long bytesOffset;
public final byte[] bytes;
- @SuppressWarnings("WeakerAccess") /* synthetic access */
ExifAttribute(int format, int numberOfComponents, byte[] bytes) {
this(format, numberOfComponents, BYTES_OFFSET_UNKNOWN, bytes);
}
- @SuppressWarnings("WeakerAccess") /* synthetic access */
ExifAttribute(int format, int numberOfComponents, long bytesOffset, byte[] bytes) {
this.format = format;
this.numberOfComponents = numberOfComponents;
@@ -3327,7 +3355,6 @@
return "(" + IFD_FORMAT_NAMES[format] + ", data length:" + bytes.length + ")";
}
- @SuppressWarnings("WeakerAccess") /* synthetic access */
Object getValue(ByteOrder byteOrder) {
ByteOrderedDataInputStream inputStream = null;
try {
@@ -3575,13 +3602,12 @@
}
// A class for indicating EXIF tag.
- static class ExifTag {
+ private static class ExifTag {
public final int number;
public final String name;
public final int primaryFormat;
public final int secondaryFormat;
- @SuppressWarnings("WeakerAccess") /* synthetic access */
ExifTag(String name, int number, int format) {
this.name = name;
this.number = number;
@@ -3589,7 +3615,6 @@
this.secondaryFormat = -1;
}
- @SuppressWarnings("WeakerAccess") /* synthetic access */
ExifTag(String name, int number, int primaryFormat, int secondaryFormat) {
this.name = name;
this.number = number;
@@ -3597,7 +3622,6 @@
this.secondaryFormat = secondaryFormat;
}
- @SuppressWarnings("WeakerAccess") /* synthetic access */
boolean isFormatCompatible(int format) {
if (primaryFormat == IFD_FORMAT_UNDEFINED || format == IFD_FORMAT_UNDEFINED) {
return true;
@@ -3862,23 +3886,24 @@
IFD_TYPE_ORF_CAMERA_SETTINGS, IFD_TYPE_ORF_IMAGE_PROCESSING, IFD_TYPE_PEF})
public @interface IfdType {}
- static final int IFD_TYPE_PRIMARY = 0;
+ private static final int IFD_TYPE_PRIMARY = 0;
private static final int IFD_TYPE_EXIF = 1;
private static final int IFD_TYPE_GPS = 2;
private static final int IFD_TYPE_INTEROPERABILITY = 3;
- static final int IFD_TYPE_THUMBNAIL = 4;
- static final int IFD_TYPE_PREVIEW = 5;
+ private static final int IFD_TYPE_THUMBNAIL = 4;
+ private static final int IFD_TYPE_PREVIEW = 5;
private static final int IFD_TYPE_ORF_MAKER_NOTE = 6;
private static final int IFD_TYPE_ORF_CAMERA_SETTINGS = 7;
private static final int IFD_TYPE_ORF_IMAGE_PROCESSING = 8;
private static final int IFD_TYPE_PEF = 9;
- // List of Exif tag groups
- static final ExifTag[][] EXIF_TAGS = new ExifTag[][] {
- IFD_TIFF_TAGS, IFD_EXIF_TAGS, IFD_GPS_TAGS, IFD_INTEROPERABILITY_TAGS,
- IFD_THUMBNAIL_TAGS, IFD_TIFF_TAGS, ORF_MAKER_NOTE_TAGS, ORF_CAMERA_SETTINGS_TAGS,
- ORF_IMAGE_PROCESSING_TAGS, PEF_TAGS
- };
+ // List of Exif tag groups, indexed by the IDF_TYPE_* constants above.
+ static final ExifTag[][] EXIF_TAGS =
+ new ExifTag[][] {
+ IFD_TIFF_TAGS, IFD_EXIF_TAGS, IFD_GPS_TAGS, IFD_INTEROPERABILITY_TAGS,
+ IFD_THUMBNAIL_TAGS, IFD_TIFF_TAGS, ORF_MAKER_NOTE_TAGS, ORF_CAMERA_SETTINGS_TAGS,
+ ORF_IMAGE_PROCESSING_TAGS, PEF_TAGS
+ };
// List of tags for pointing to the other image file directory offset.
private static final ExifTag[] EXIF_POINTER_TAGS = new ExifTag[] {
new ExifTag(TAG_SUB_IFD_POINTER, 330, IFD_FORMAT_ULONG),
@@ -3928,17 +3953,16 @@
// The following values are defined for handling JPEG streams. In this implementation, we are
// not only getting information from EXIF but also from some JPEG special segments such as
// MARKER_COM for user comment and MARKER_SOFx for image width and height.
- @SuppressWarnings("WeakerAccess") /* synthetic access */
- static final Charset ASCII = Charset.forName("US-ASCII");
+ private static final Charset ASCII = Charset.forName("US-ASCII");
// Identifier for EXIF APP1 segment in JPEG
- static final byte[] IDENTIFIER_EXIF_APP1 = "Exif\0\0".getBytes(ASCII);
+ @VisibleForTesting static final byte[] IDENTIFIER_EXIF_APP1 = "Exif\0\0".getBytes(ASCII);
// Identifier for XMP APP1 segment in JPEG
private static final byte[] IDENTIFIER_XMP_APP1 =
"http://ns.adobe.com/xap/1.0/\0".getBytes(ASCII);
// JPEG segment markers, that each marker consumes two bytes beginning with 0xff and ending with
// the indicator. There is no SOF4, SOF8, SOF16 markers in JPEG and SOFx markers indicates start
// of frame(baseline DCT) and the image size info exists in its beginning part.
- static final byte MARKER = (byte) 0xff;
+ private static final byte MARKER = (byte) 0xff;
private static final byte MARKER_SOI = (byte) 0xd8;
private static final byte MARKER_SOF0 = (byte) 0xc0;
private static final byte MARKER_SOF1 = (byte) 0xc1;
@@ -3954,27 +3978,22 @@
private static final byte MARKER_SOF14 = (byte) 0xce;
private static final byte MARKER_SOF15 = (byte) 0xcf;
private static final byte MARKER_SOS = (byte) 0xda;
- static final byte MARKER_APP1 = (byte) 0xe1;
+ @VisibleForTesting static final byte MARKER_APP1 = (byte) 0xe1;
private static final byte MARKER_COM = (byte) 0xfe;
- static final byte MARKER_EOI = (byte) 0xd9;
+ private static final byte MARKER_EOI = (byte) 0xd9;
// Supported Image File Types
- static final int IMAGE_TYPE_UNKNOWN = 0;
- static final int IMAGE_TYPE_ARW = 1;
- static final int IMAGE_TYPE_CR2 = 2;
- static final int IMAGE_TYPE_DNG = 3;
- static final int IMAGE_TYPE_JPEG = 4;
- static final int IMAGE_TYPE_NEF = 5;
- static final int IMAGE_TYPE_NRW = 6;
- static final int IMAGE_TYPE_ORF = 7;
- static final int IMAGE_TYPE_PEF = 8;
- static final int IMAGE_TYPE_RAF = 9;
- static final int IMAGE_TYPE_RW2 = 10;
- static final int IMAGE_TYPE_SRW = 11;
- static final int IMAGE_TYPE_HEIC = 12;
- static final int IMAGE_TYPE_PNG = 13;
- static final int IMAGE_TYPE_WEBP = 14;
- static final int IMAGE_TYPE_AVIF = 15;
+ private static final int IMAGE_TYPE_UNKNOWN = 0;
+ private static final int IMAGE_TYPE_DNG = 3;
+ private static final int IMAGE_TYPE_JPEG = 4;
+ private static final int IMAGE_TYPE_ORF = 7;
+ private static final int IMAGE_TYPE_PEF = 8;
+ private static final int IMAGE_TYPE_RAF = 9;
+ private static final int IMAGE_TYPE_RW2 = 10;
+ private static final int IMAGE_TYPE_HEIC = 12;
+ private static final int IMAGE_TYPE_PNG = 13;
+ private static final int IMAGE_TYPE_WEBP = 14;
+ private static final int IMAGE_TYPE_AVIF = 15;
static {
sFormatterPrimary = new SimpleDateFormat("yyyy:MM:dd HH:mm:ss", Locale.US);
@@ -4024,9 +4043,15 @@
private int mOrfThumbnailOffset;
private int mOrfThumbnailLength;
private boolean mModified;
- // XMP data can be contained as either part of the EXIF data (tag number 700), or as a
- // separate data marker (a separate MARKER_APP1).
- private boolean mXmpIsFromSeparateMarker;
+
+ /**
+ * XMP data can occur as either part of the TIFF/Exif data (tag number 700), or as a separate
+ * section of the file (e.g. a separate APP1 segment in JPEG). XMP read from within the
+ * TIFF/Exif data is stored in {@link #mAttributes}, while XMP read from a separate section is
+ * here. If both are present, the disambiguation rules vary per file format, see
+ * {@link #getXmpHandlingForImageType(int)}.
+ */
+ @Nullable private ExifAttribute mXmpFromSeparateMarker;
// Pattern to check non zero timestamp
private static final Pattern NON_ZERO_TIME_PATTERN = Pattern.compile(".*[1-9].*");
@@ -4205,7 +4230,6 @@
*
* @param tag the name of the tag.
*/
- @SuppressWarnings("deprecation")
private @Nullable ExifAttribute getExifAttribute(@NonNull String tag) {
if (tag == null) {
throw new NullPointerException("tag shouldn't be null");
@@ -4218,6 +4242,11 @@
}
tag = TAG_PHOTOGRAPHIC_SENSITIVITY;
}
+ if (TAG_XMP.equals(tag)
+ && getXmpHandlingForImageType(mMimeType) == XMP_HANDLING_PREFER_SEPARATE
+ && mXmpFromSeparateMarker != null) {
+ return mXmpFromSeparateMarker;
+ }
// Retrieves all tag groups. The value from primary image tag group has a higher priority
// than the value from the thumbnail tag group if there are more than one candidates.
for (int i = 0; i < EXIF_TAGS.length; ++i) {
@@ -4226,9 +4255,40 @@
return value;
}
}
+ if (TAG_XMP.equals(tag) && mXmpFromSeparateMarker != null) {
+ return mXmpFromSeparateMarker;
+ }
return null;
}
+ private static @XmpHandling int getXmpHandlingForImageType(int imageType) {
+ switch (imageType) {
+ // ExifInterface has a documented (but spec-violating) preference for reading and
+ // writing JPEG and HEIC XMP data from Exif/TIFF tag 700 instead of a separate XMP
+ // APP1 segment (for JPEG) or a top-level XMP UUID box (for HEIC) if both are present.
+ case IMAGE_TYPE_JPEG:
+ case IMAGE_TYPE_HEIC:
+ return XMP_HANDLING_PREFER_TIFF_700_IF_PRESENT;
+ // RAF stores XMP/Exif in JPEG, but we have no documented backwards-compat obligations
+ // so we can implement the spec to store XMP in a separate APP1 segment.
+ case IMAGE_TYPE_RAF:
+ case IMAGE_TYPE_AVIF:
+ return XMP_HANDLING_PREFER_SEPARATE;
+ case IMAGE_TYPE_DNG:
+ case IMAGE_TYPE_ORF:
+ case IMAGE_TYPE_PEF:
+ case IMAGE_TYPE_RW2:
+ case IMAGE_TYPE_UNKNOWN:
+ // PNG and WebP support a separate XMP chunk (so should be
+ // XMP_HANDLING_PREFER_SEPARATE), but ExifInterface doesn't currently read or write
+ // them.
+ case IMAGE_TYPE_PNG:
+ case IMAGE_TYPE_WEBP:
+ default:
+ return XMP_HANDLING_TIFF_700_ONLY;
+ }
+ }
+
/**
* Returns the value of the specified tag or {@code null} if there
* is no such tag in the image file.
@@ -4388,15 +4448,22 @@
}
}
- for (int i = 0 ; i < EXIF_TAGS.length; ++i) {
- if (i == IFD_TYPE_THUMBNAIL && !mHasThumbnail) {
- continue;
+ if (TAG_XMP.equals(tag)) {
+ boolean containsTiff700Xmp =
+ mAttributes[IFD_TYPE_PRIMARY].containsKey(TAG_XMP)
+ || mAttributes[IFD_TYPE_PREVIEW].containsKey(TAG_XMP);
+ @XmpHandling int xmpHandling = getXmpHandlingForImageType(mMimeType);
+ if ((xmpHandling == XMP_HANDLING_PREFER_SEPARATE
+ && (mXmpFromSeparateMarker != null || !containsTiff700Xmp))
+ || (xmpHandling == XMP_HANDLING_PREFER_TIFF_700_IF_PRESENT
+ && !containsTiff700Xmp)) {
+ mXmpFromSeparateMarker = ExifAttribute.createByte(value);
+ return;
}
- if (tag.equals(TAG_XMP) && i == IFD_TYPE_PREVIEW && mXmpIsFromSeparateMarker) {
- // XMP was read from a standalone XMP APP1 segment in the source file, and only
- // stored in sExifTagMapsForWriting[IFD_TYPE_PRIMARY], so we shouldn't store the
- // updated value in sExifTagMapsForWriting[IFD_TYPE_PREVIEW] here, otherwise we risk
- // incorrectly writing the updated value twice in the resulting file.
+ }
+
+ for (int i = 0; i < EXIF_TAGS.length; ++i) {
+ if (i == IFD_TYPE_THUMBNAIL && !mHasThumbnail) {
continue;
}
final ExifTag exifTag = sExifTagMapsForWriting[i].get(tag);
@@ -5744,7 +5811,6 @@
length = 0;
if (startsWith(bytes, IDENTIFIER_EXIF_APP1)) {
- byte[] xmpBeforeReadingExif = getAttributeBytes(TAG_XMP);
final byte[] value = Arrays.copyOfRange(bytes, IDENTIFIER_EXIF_APP1.length,
bytes.length);
// Save offset to EXIF data for handling thumbnail and attribute offsets.
@@ -5754,28 +5820,13 @@
readExifSegment(value, imageType);
setThumbnailData(new ByteOrderedDataInputStream(value));
-
- if (getAttributeBytes(TAG_XMP) == null) {
- // XMP should be stored in a separate APP1 segment (see XMP spec part 3
- // section 3.3.2). If the Exif segment didn't contain XMP then we set
- // this to true to ensure any XMP data added will get written out to a
- // separate segment.
- mXmpIsFromSeparateMarker = true;
- } else if (xmpBeforeReadingExif != getAttributeBytes(TAG_XMP)) {
- mXmpIsFromSeparateMarker = false;
- }
} else if (startsWith(bytes, IDENTIFIER_XMP_APP1)) {
// See XMP Specification Part 3: Storage in Files, 1.1.3 JPEG, Table 6
final int offset = start + IDENTIFIER_XMP_APP1.length;
final byte[] value = Arrays.copyOfRange(bytes,
IDENTIFIER_XMP_APP1.length, bytes.length);
- // TODO: check if ignoring separate XMP data when tag 700 already exists is
- // valid.
- if (getAttribute(TAG_XMP) == null) {
- mAttributes[IFD_TYPE_PRIMARY].put(TAG_XMP, new ExifAttribute(
- IFD_FORMAT_BYTE, value.length, offset, value));
- mXmpIsFromSeparateMarker = true;
- }
+ mXmpFromSeparateMarker =
+ new ExifAttribute(IFD_FORMAT_BYTE, value.length, offset, value);
}
break;
}
@@ -6097,10 +6148,8 @@
in.seek(offset);
byte[] xmpBytes = new byte[length];
in.readFully(xmpBytes);
- if (getAttribute(TAG_XMP) == null) {
- mAttributes[IFD_TYPE_PRIMARY].put(TAG_XMP, new ExifAttribute(
- IFD_FORMAT_BYTE, xmpBytes.length, offset, xmpBytes));
- }
+ mXmpFromSeparateMarker =
+ new ExifAttribute(IFD_FORMAT_BYTE, xmpBytes.length, offset, xmpBytes);
}
if (DEBUG) {
@@ -6447,33 +6496,20 @@
}
dataOutputStream.writeByte(MARKER_SOI);
- // Remove XMP data if it is from a separate marker (IDENTIFIER_XMP_APP1, not
- // IDENTIFIER_EXIF_APP1)
- // Will re-add it later after the rest of the file is written
- ExifAttribute xmpAttribute = null;
- if (getAttribute(TAG_XMP) != null && mXmpIsFromSeparateMarker) {
- xmpAttribute = mAttributes[IFD_TYPE_PRIMARY].remove(TAG_XMP);
- }
-
// Write EXIF APP1 segment
dataOutputStream.writeByte(MARKER);
dataOutputStream.writeByte(MARKER_APP1);
writeExifSegment(dataOutputStream);
- if (xmpAttribute != null && mXmpIsFromSeparateMarker) {
+ if (mXmpFromSeparateMarker != null) {
// Write XMP APP1 segment. The XMP spec (part 3, section 1.1.3) recommends for this to
// directly follow the Exif APP1 segment.
dataOutputStream.write(MARKER);
dataOutputStream.writeByte(MARKER_APP1);
- int length = 2 + IDENTIFIER_XMP_APP1.length + xmpAttribute.bytes.length;
+ int length = 2 + IDENTIFIER_XMP_APP1.length + mXmpFromSeparateMarker.bytes.length;
dataOutputStream.writeUnsignedShort(length);
dataOutputStream.write(IDENTIFIER_XMP_APP1);
- dataOutputStream.write(xmpAttribute.bytes);
- }
-
- // Re-add previously removed XMP data.
- if (xmpAttribute != null) {
- mAttributes[IFD_TYPE_PRIMARY].put(TAG_XMP, xmpAttribute);
+ dataOutputStream.write(mXmpFromSeparateMarker.bytes);
}
byte[] bytes = new byte[4096];
@@ -6503,8 +6539,7 @@
if (identifier != null) {
dataInputStream.readFully(identifier);
if (startsWith(identifier, IDENTIFIER_EXIF_APP1)
- || (startsWith(identifier, IDENTIFIER_XMP_APP1)
- && mXmpIsFromSeparateMarker)) {
+ || startsWith(identifier, IDENTIFIER_XMP_APP1)) {
// Skip the original EXIF or XMP APP1 segment.
dataInputStream.skipFully(length - identifier.length);
break;
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index b535274..c6259a9 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -28,6 +28,7 @@
byteBuddy = "1.14.9"
asm = "9.7"
cmake = "3.22.1"
+composeCompilerPlugin = "2.0.10-custom-branch-7"
dagger = "2.49"
dependencyAnalysisGradlePlugin = "1.32.0"
dexmaker = "2.28.4"
diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml
index 710c4d9..9d41d22 100644
--- a/gradle/verification-metadata.xml
+++ b/gradle/verification-metadata.xml
@@ -714,6 +714,16 @@
<sha256 value="8d25d21f7f2aca27dc10638ad417bbb08a189310274bc322aa620aafe7e82c92" origin="Generated by Gradle" reason="Unsigned. Used by media3. b/372529250"/>
</artifact>
</component>
+ <component group="org.jetbrains.kotlin" name="kotlin-compose-compiler-plugin-embeddable" version="2.0.10-custom-branch-7">
+ <artifact name="kotlin-compose-compiler-plugin-embeddable-2.0.10-custom-branch-7.jar">
+ <md5 value="fdf7b7b01e609b7eb990e0b16f01cb96" origin="Generated by Gradle"/>
+ <sha256 value="8348f83c0405ce3f4335a2285b63c2821c123192a2ee4da7505432b42d669602" origin="Generated by Gradle" reason="Artifact is not signed"/>
+ </artifact>
+ <artifact name="kotlin-compose-compiler-plugin-embeddable-2.0.10-custom-branch-7.pom">
+ <md5 value="e2d4bfd6d1794c4599d7c32d8b7accec" origin="Generated by Gradle"/>
+ <sha256 value="a28b7ecf0ffa35f32482973b9cb04a7a4940e0eec53f066631c899fabe4d0951" origin="Generated by Gradle" reason="Artifact is not signed"/>
+ </artifact>
+ </component>
<component group="org.jetbrains.kotlinx" name="kotlinx-benchmark-plugin" version="0.4.11">
<artifact name="kotlinx-benchmark-plugin-0.4.11.jar">
<sha256 value="5c337e082137eb3cdf64b27b11e16b97e5dd0a7905aa9c2d7c8c323601e5c31b" origin="Generated by Gradle" reason="https://github.com/Kotlin/kotlinx-benchmark/issues/115"/>
diff --git a/libraryversions.toml b/libraryversions.toml
index c090896..dbe9d92 100644
--- a/libraryversions.toml
+++ b/libraryversions.toml
@@ -3,7 +3,7 @@
ANNOTATION = "1.9.0-rc01"
ANNOTATION_EXPERIMENTAL = "1.5.0-alpha01"
APPCOMPAT = "1.8.0-alpha01"
-APPSEARCH = "1.1.0-alpha06"
+APPSEARCH = "1.1.0-alpha07"
ARCH_CORE = "2.3.0-alpha01"
ASYNCLAYOUTINFLATER = "1.1.0-alpha02"
AUTOFILL = "1.3.0-beta01"
diff --git a/lifecycle/lifecycle-viewmodel-savedstate-samples/build.gradle b/lifecycle/lifecycle-viewmodel-savedstate-samples/build.gradle
new file mode 100644
index 0000000..f6ec15b
--- /dev/null
+++ b/lifecycle/lifecycle-viewmodel-savedstate-samples/build.gradle
@@ -0,0 +1,52 @@
+/*
+ * Copyright (C) 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
+ *
+ * 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.
+ */
+
+/**
+ * This file was created using the `create_project.py` script located in the
+ * `<AndroidX root>/development/project-creator` directory.
+ *
+ * Please use that script when creating a new project, rather than copying an existing project and
+ * modifying its settings.
+ */
+
+import androidx.build.KotlinTarget
+import androidx.build.LibraryType
+
+plugins {
+ id("AndroidXPlugin")
+ id("com.android.library")
+ id("org.jetbrains.kotlin.android")
+ alias(libs.plugins.kotlinSerialization)
+}
+
+dependencies {
+ api(libs.kotlinStdlib)
+ compileOnly(project(":annotation:annotation-sampled"))
+ implementation(project(":lifecycle:lifecycle-viewmodel"))
+ implementation(project(":lifecycle:lifecycle-viewmodel-savedstate"))
+}
+
+android {
+ namespace "androidx.lifecycle.viewmodel.savedstate.samples"
+}
+
+androidx {
+ name = "androidx.lifecycle:lifecycle-viewmodel-savedstate-samples"
+ type = LibraryType.SAMPLES
+ inceptionYear = "2024"
+ description = "Lifecycle ViewModel SavedState Samples"
+ kotlinTarget = KotlinTarget.KOTLIN_1_9
+}
diff --git a/lifecycle/lifecycle-viewmodel-savedstate-samples/src/main/java/androidx/lifecycle/LifecycleViewModelSavedStateSamples.kt b/lifecycle/lifecycle-viewmodel-savedstate-samples/src/main/java/androidx/lifecycle/LifecycleViewModelSavedStateSamples.kt
new file mode 100644
index 0000000..0c0c2d4
--- /dev/null
+++ b/lifecycle/lifecycle-viewmodel-savedstate-samples/src/main/java/androidx/lifecycle/LifecycleViewModelSavedStateSamples.kt
@@ -0,0 +1,59 @@
+/*
+ * 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
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.lifecycle
+
+import androidx.annotation.Sampled
+import kotlinx.serialization.InternalSerializationApi
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.serializer
+
+@Sampled
+fun delegate() {
+ @Serializable data class User(val id: Int, val name: String)
+ class ProfileViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {
+ val user by savedStateHandle.saved { User(123, "foo") }
+ }
+}
+
+@Sampled
+fun delegateExplicitKey() {
+ @Serializable data class User(val id: Int, val name: String)
+ class ProfileViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {
+ val user by savedStateHandle.saved(key = "bar") { User(123, "foo") }
+ }
+}
+
+@OptIn(InternalSerializationApi::class)
+@Sampled
+fun delegateExplicitSerializer() {
+ @Serializable data class User(val id: Int, val name: String)
+ class ProfileViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {
+ val user by savedStateHandle.saved(User::class.serializer()) { User(123, "foo") }
+ }
+}
+
+@OptIn(InternalSerializationApi::class)
+@Sampled
+fun delegateExplicitKeyAndSerializer() {
+ @Serializable data class User(val id: Int, val name: String)
+ class ProfileViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {
+ val user by
+ savedStateHandle.saved(key = "bar", serializer = User::class.serializer()) {
+ User(123, "foo")
+ }
+ }
+}
diff --git a/lifecycle/lifecycle-viewmodel-savedstate/api/current.txt b/lifecycle/lifecycle-viewmodel-savedstate/api/current.txt
index 58ae453..682b5ef 100644
--- a/lifecycle/lifecycle-viewmodel-savedstate/api/current.txt
+++ b/lifecycle/lifecycle-viewmodel-savedstate/api/current.txt
@@ -27,6 +27,13 @@
public static final class SavedStateHandle.Companion {
}
+ public final class SavedStateHandleDelegatesKt {
+ method public static inline <reified T> kotlin.properties.ReadWriteProperty<java.lang.Object?,T> saved(androidx.lifecycle.SavedStateHandle, String key, kotlin.jvm.functions.Function0<? extends T> init);
+ method public static <T> kotlin.properties.ReadWriteProperty<java.lang.Object?,T> saved(androidx.lifecycle.SavedStateHandle, String key, kotlinx.serialization.KSerializer<T> serializer, kotlin.jvm.functions.Function0<? extends T> init);
+ method public static inline <reified T> kotlin.properties.ReadWriteProperty<java.lang.Object?,T> saved(androidx.lifecycle.SavedStateHandle, kotlin.jvm.functions.Function0<? extends T> init);
+ method public static <T> kotlin.properties.ReadWriteProperty<java.lang.Object?,T> saved(androidx.lifecycle.SavedStateHandle, kotlinx.serialization.KSerializer<T> serializer, kotlin.jvm.functions.Function0<? extends T> init);
+ }
+
public final class SavedStateHandleSupport {
method @MainThread public static androidx.lifecycle.SavedStateHandle createSavedStateHandle(androidx.lifecycle.viewmodel.CreationExtras);
method @MainThread public static <T extends androidx.savedstate.SavedStateRegistryOwner & androidx.lifecycle.ViewModelStoreOwner> void enableSavedStateHandles(T);
diff --git a/lifecycle/lifecycle-viewmodel-savedstate/api/restricted_current.txt b/lifecycle/lifecycle-viewmodel-savedstate/api/restricted_current.txt
index 58ae453..682b5ef 100644
--- a/lifecycle/lifecycle-viewmodel-savedstate/api/restricted_current.txt
+++ b/lifecycle/lifecycle-viewmodel-savedstate/api/restricted_current.txt
@@ -27,6 +27,13 @@
public static final class SavedStateHandle.Companion {
}
+ public final class SavedStateHandleDelegatesKt {
+ method public static inline <reified T> kotlin.properties.ReadWriteProperty<java.lang.Object?,T> saved(androidx.lifecycle.SavedStateHandle, String key, kotlin.jvm.functions.Function0<? extends T> init);
+ method public static <T> kotlin.properties.ReadWriteProperty<java.lang.Object?,T> saved(androidx.lifecycle.SavedStateHandle, String key, kotlinx.serialization.KSerializer<T> serializer, kotlin.jvm.functions.Function0<? extends T> init);
+ method public static inline <reified T> kotlin.properties.ReadWriteProperty<java.lang.Object?,T> saved(androidx.lifecycle.SavedStateHandle, kotlin.jvm.functions.Function0<? extends T> init);
+ method public static <T> kotlin.properties.ReadWriteProperty<java.lang.Object?,T> saved(androidx.lifecycle.SavedStateHandle, kotlinx.serialization.KSerializer<T> serializer, kotlin.jvm.functions.Function0<? extends T> init);
+ }
+
public final class SavedStateHandleSupport {
method @MainThread public static androidx.lifecycle.SavedStateHandle createSavedStateHandle(androidx.lifecycle.viewmodel.CreationExtras);
method @MainThread public static <T extends androidx.savedstate.SavedStateRegistryOwner & androidx.lifecycle.ViewModelStoreOwner> void enableSavedStateHandles(T);
diff --git a/lifecycle/lifecycle-viewmodel-savedstate/bcv/native/current.txt b/lifecycle/lifecycle-viewmodel-savedstate/bcv/native/current.txt
index 5041c3f..c2a44e9 100644
--- a/lifecycle/lifecycle-viewmodel-savedstate/bcv/native/current.txt
+++ b/lifecycle/lifecycle-viewmodel-savedstate/bcv/native/current.txt
@@ -42,3 +42,7 @@
final fun (androidx.lifecycle.viewmodel/CreationExtras).androidx.lifecycle/createSavedStateHandle(): androidx.lifecycle/SavedStateHandle // androidx.lifecycle/createSavedStateHandle|createSavedStateHandle@androidx.lifecycle.viewmodel.CreationExtras(){}[0]
final fun <#A: androidx.lifecycle/ViewModelStoreOwner & androidx.savedstate/SavedStateRegistryOwner> (#A).androidx.lifecycle/enableSavedStateHandles() // androidx.lifecycle/enableSavedStateHandles|enableSavedStateHandles@0:0(){0§<androidx.savedstate.SavedStateRegistryOwner&androidx.lifecycle.ViewModelStoreOwner>}[0]
+final fun <#A: kotlin/Any> (androidx.lifecycle/SavedStateHandle).androidx.lifecycle/saved(kotlin/String, kotlinx.serialization/KSerializer<#A>, kotlin/Function0<#A>): kotlin.properties/ReadWriteProperty<kotlin/Any?, #A> // androidx.lifecycle/saved|[email protected](kotlin.String;kotlinx.serialization.KSerializer<0:0>;kotlin.Function0<0:0>){0§<kotlin.Any>}[0]
+final fun <#A: kotlin/Any> (androidx.lifecycle/SavedStateHandle).androidx.lifecycle/saved(kotlinx.serialization/KSerializer<#A>, kotlin/Function0<#A>): kotlin.properties/ReadWriteProperty<kotlin/Any?, #A> // androidx.lifecycle/saved|[email protected](kotlinx.serialization.KSerializer<0:0>;kotlin.Function0<0:0>){0§<kotlin.Any>}[0]
+final inline fun <#A: reified kotlin/Any> (androidx.lifecycle/SavedStateHandle).androidx.lifecycle/saved(kotlin/String, noinline kotlin/Function0<#A>): kotlin.properties/ReadWriteProperty<kotlin/Any?, #A> // androidx.lifecycle/saved|[email protected](kotlin.String;kotlin.Function0<0:0>){0§<kotlin.Any>}[0]
+final inline fun <#A: reified kotlin/Any> (androidx.lifecycle/SavedStateHandle).androidx.lifecycle/saved(noinline kotlin/Function0<#A>): kotlin.properties/ReadWriteProperty<kotlin/Any?, #A> // androidx.lifecycle/saved|[email protected](kotlin.Function0<0:0>){0§<kotlin.Any>}[0]
diff --git a/lifecycle/lifecycle-viewmodel-savedstate/build.gradle b/lifecycle/lifecycle-viewmodel-savedstate/build.gradle
index 43d5b27..b9f335c 100644
--- a/lifecycle/lifecycle-viewmodel-savedstate/build.gradle
+++ b/lifecycle/lifecycle-viewmodel-savedstate/build.gradle
@@ -30,6 +30,7 @@
plugins {
id("AndroidXPlugin")
id("com.android.library")
+ alias(libs.plugins.kotlinSerialization)
}
androidXMultiplatform {
@@ -49,6 +50,7 @@
api(project(":lifecycle:lifecycle-viewmodel"))
api(libs.kotlinStdlib)
api(libs.kotlinCoroutinesCore)
+ api(libs.kotlinSerializationCore)
}
}
@@ -86,6 +88,7 @@
implementation project(":lifecycle:lifecycle-livedata-core")
implementation ("androidx.fragment:fragment:1.3.0")
implementation project(":internal-testutils-runtime")
+ implementation(project(":lifecycle:lifecycle-viewmodel"))
implementation(libs.truth)
implementation(libs.testExtJunit)
implementation(libs.testCore)
diff --git a/lifecycle/lifecycle-viewmodel-savedstate/src/androidInstrumentedTest/AndroidManifest.xml b/lifecycle/lifecycle-viewmodel-savedstate/src/androidInstrumentedTest/AndroidManifest.xml
index 50815d9..a8f6610c 100644
--- a/lifecycle/lifecycle-viewmodel-savedstate/src/androidInstrumentedTest/AndroidManifest.xml
+++ b/lifecycle/lifecycle-viewmodel-savedstate/src/androidInstrumentedTest/AndroidManifest.xml
@@ -22,6 +22,8 @@
android:name="androidx.lifecycle.viewmodel.savedstate.FakingSavedStateActivity"/>
<activity
android:name="androidx.lifecycle.viewmodel.savedstate.SavedStateFactoryTest$MyActivity"/>
+ <activity
+ android:name="androidx.activity.ComponentActivity"/>
</application>
</manifest>
diff --git a/lifecycle/lifecycle-viewmodel-savedstate/src/androidInstrumentedTest/kotlin/androidx/lifecycle/SerializationTest.kt b/lifecycle/lifecycle-viewmodel-savedstate/src/androidInstrumentedTest/kotlin/androidx/lifecycle/SerializationTest.kt
new file mode 100644
index 0000000..0766242
--- /dev/null
+++ b/lifecycle/lifecycle-viewmodel-savedstate/src/androidInstrumentedTest/kotlin/androidx/lifecycle/SerializationTest.kt
@@ -0,0 +1,139 @@
+/*
+ * 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
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.lifecycle
+
+import androidx.activity.ComponentActivity
+import androidx.test.ext.junit.rules.ActivityScenarioRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.google.common.truth.Truth.assertThat
+import kotlinx.serialization.builtins.serializer
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class SerializationTest {
+
+ @get:Rule val activityTestRuleScenario = ActivityScenarioRule(ComponentActivity::class.java)
+
+ @Test
+ fun simpleRestore() {
+ activityTestRuleScenario.scenario.onActivity { activity ->
+ val viewModel: MyViewModel = ViewModelProvider(activity)[MyViewModel::class]
+ var value by viewModel.savedStateHandle.saved { 1 }
+ assertThat(value).isEqualTo(1)
+ value = 2
+ assertThat(value).isEqualTo(2)
+ }
+ activityTestRuleScenario.scenario.recreate()
+ activityTestRuleScenario.scenario.onActivity { activity ->
+ val viewModel: MyViewModel = ViewModelProvider(activity)[MyViewModel::class]
+ var value: Int by
+ viewModel.savedStateHandle.saved { error("Unexpected initializer call") }
+ assertThat(value).isEqualTo(2)
+ }
+ }
+
+ @Test
+ fun explicitKey() {
+ activityTestRuleScenario.scenario.onActivity { activity ->
+ val viewModel: MyViewModel = ViewModelProvider(activity)[MyViewModel::class]
+ var value by viewModel.savedStateHandle.saved(key = "foo") { 1 }
+ assertThat(value).isEqualTo(1)
+ value = 2
+ assertThat(value).isEqualTo(2)
+ }
+ activityTestRuleScenario.scenario.recreate()
+ activityTestRuleScenario.scenario.onActivity { activity ->
+ val viewModel: MyViewModel = ViewModelProvider(activity)[MyViewModel::class]
+ var value: Int by
+ viewModel.savedStateHandle.saved(key = "foo") {
+ error("Unexpected initializer call")
+ }
+ assertThat(value).isEqualTo(2)
+ }
+ }
+
+ @Test
+ fun explicitSerializer() {
+ activityTestRuleScenario.scenario.onActivity { activity ->
+ val viewModel: MyViewModel = ViewModelProvider(activity)[MyViewModel::class]
+ val value by viewModel.savedStateHandle.saved(serializer = Int.serializer()) { 1 }
+ assertThat(value).isEqualTo(1)
+ }
+ activityTestRuleScenario.scenario.recreate()
+ activityTestRuleScenario.scenario.onActivity { activity ->
+ val viewModel: MyViewModel = ViewModelProvider(activity)[MyViewModel::class]
+ val value: Int by
+ viewModel.savedStateHandle.saved(serializer = Int.serializer()) {
+ error("Unexpected initializer call")
+ }
+ assertThat(value).isEqualTo(1)
+ }
+ }
+
+ @Test
+ fun explicitKeyAndSerializer() {
+ activityTestRuleScenario.scenario.onActivity { activity ->
+ val viewModel: MyViewModel = ViewModelProvider(activity)[MyViewModel::class]
+ val value by
+ viewModel.savedStateHandle.saved(key = "foo", serializer = Int.serializer()) { 1 }
+ assertThat(value).isEqualTo(1)
+ }
+ activityTestRuleScenario.scenario.recreate()
+ activityTestRuleScenario.scenario.onActivity { activity ->
+ val viewModel: MyViewModel = ViewModelProvider(activity)[MyViewModel::class]
+ val value: Int by
+ viewModel.savedStateHandle.saved(key = "foo", serializer = Int.serializer()) {
+ error("Unexpected initializer call")
+ }
+ assertThat(value).isEqualTo(1)
+ }
+ }
+
+ @Test
+ fun duplicateKeys() {
+ activityTestRuleScenario.scenario.onActivity { activity ->
+ val viewModel: MyViewModel = ViewModelProvider(activity)[MyViewModel::class]
+ val serializable by viewModel.savedStateHandle.saved(key = "foo") { 1 }
+ assertThat(serializable).isEqualTo(1)
+ val duplicate by viewModel.savedStateHandle.saved(key = "foo") { 2 }
+ assertThat(duplicate).isEqualTo(2)
+ // The value is from the initializer.
+ assertThat(serializable).isEqualTo(1)
+ }
+ activityTestRuleScenario.scenario.recreate()
+ activityTestRuleScenario.scenario.onActivity { activity ->
+ val viewModel: MyViewModel = ViewModelProvider(activity)[MyViewModel::class]
+ val serializable: Int by
+ viewModel.savedStateHandle.saved(key = "foo") {
+ error("Unexpected initializer call")
+ }
+ assertThat(serializable).isEqualTo(2)
+ val duplicate: Int by
+ viewModel.savedStateHandle.saved(key = "foo") {
+ error("Unexpected initializer call")
+ }
+ assertThat(duplicate).isEqualTo(2)
+ assertThat(serializable).isEqualTo(2)
+ }
+ }
+}
+
+class MyViewModel(val savedStateHandle: SavedStateHandle) : ViewModel()
diff --git a/lifecycle/lifecycle-viewmodel-savedstate/src/commonMain/kotlin/androidx/lifecycle/SavedStateHandleDelegates.kt b/lifecycle/lifecycle-viewmodel-savedstate/src/commonMain/kotlin/androidx/lifecycle/SavedStateHandleDelegates.kt
new file mode 100644
index 0000000..ce7301c2
--- /dev/null
+++ b/lifecycle/lifecycle-viewmodel-savedstate/src/commonMain/kotlin/androidx/lifecycle/SavedStateHandleDelegates.kt
@@ -0,0 +1,144 @@
+/*
+ * 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
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.lifecycle
+
+import androidx.savedstate.SavedState
+import androidx.savedstate.read
+import androidx.savedstate.serialization.decodeFromSavedState
+import androidx.savedstate.serialization.encodeToSavedState
+import kotlin.properties.ReadWriteProperty
+import kotlin.reflect.KProperty
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.serializer
+
+/**
+ * Returns a property delegate that uses [SavedStateHandle] to save and restore a value of type [T]
+ * with fully qualified property or variable name as key and the default serializer.
+ *
+ * @sample androidx.lifecycle.delegate
+ * @param init The function to provide the initial value of the property.
+ * @return A property delegate that manages the saving and restoring of the value.
+ */
+inline fun <reified T : Any> SavedStateHandle.saved(
+ noinline init: () -> T,
+): ReadWriteProperty<Any?, T> {
+ return saved(serializer(), init)
+}
+
+/**
+ * Returns a property delegate that uses [SavedStateHandle] to save and restore a value of type [T]
+ * with the default serializer.
+ *
+ * @sample androidx.lifecycle.delegateExplicitKey
+ * @param key The [String] key to use for storing the value in the [SavedStateHandle].
+ * @param init The function to provide the initial value of the property.
+ * @return A property delegate that manages the saving and restoring of the value.
+ */
+inline fun <reified T : Any> SavedStateHandle.saved(
+ key: String,
+ noinline init: () -> T,
+): ReadWriteProperty<Any?, T> {
+ return saved(key, serializer(), init)
+}
+
+/**
+ * Returns a property delegate that uses [SavedStateHandle] to save and restore a value of type [T]
+ * with fully qualified property or variable name as key.
+ *
+ * @sample androidx.lifecycle.delegateExplicitSerializer
+ * @param serializer The [KSerializer] to use for serializing and deserializing the value.
+ * @param init The function to provide the initial value of the property.
+ * @return A property delegate that manages the saving and restoring of the value.
+ */
+fun <T : Any> SavedStateHandle.saved(
+ serializer: KSerializer<T>,
+ init: () -> T,
+): ReadWriteProperty<Any?, T> {
+ return SerializablePropertyDelegate(
+ savedStateHandle = this,
+ key = null,
+ serializer = serializer,
+ init = init
+ )
+}
+
+/**
+ * Returns a property delegate that uses [SavedStateHandle] to save and restore a value of type [T].
+ *
+ * @sample androidx.lifecycle.delegateExplicitKeyAndSerializer
+ * @param key The [String] key to use for storing the value in the [SavedStateHandle].
+ * @param serializer The [KSerializer] to use for serializing and deserializing the value.
+ * @param init The function to provide the initial value of the property.
+ * @return A property delegate that manages the saving and restoring of the value.
+ */
+fun <T : Any> SavedStateHandle.saved(
+ key: String,
+ serializer: KSerializer<T>,
+ init: () -> T,
+): ReadWriteProperty<Any?, T> {
+ return SerializablePropertyDelegate(
+ savedStateHandle = this,
+ key = key,
+ serializer = serializer,
+ init = init
+ )
+}
+
+private class SerializablePropertyDelegate<T : Any>(
+ private val savedStateHandle: SavedStateHandle,
+ private val key: String?,
+ private val serializer: KSerializer<T>,
+ private val init: () -> T,
+) : ReadWriteProperty<Any?, T> {
+ private lateinit var value: T
+
+ private fun lazyInit(thisRef: Any?, property: KProperty<*>) {
+ if (::value.isInitialized) return
+
+ val qualifiedKey =
+ if (key != null) {
+ key
+ } else {
+ val classNamePrefix =
+ if (thisRef != null) thisRef::class.qualifiedName + "." else ""
+ classNamePrefix + property.name
+ }
+
+ val restoredState = savedStateHandle.get<SavedState>(qualifiedKey)
+ val value =
+ if (restoredState != null && restoredState.read { !isEmpty() }) {
+ decodeFromSavedState(serializer, restoredState)
+ } else {
+ init()
+ }
+ this.value = value
+
+ savedStateHandle.setSavedStateProvider(qualifiedKey) {
+ encodeToSavedState(serializer, this.value)
+ }
+ }
+
+ override fun getValue(thisRef: Any?, property: KProperty<*>): T {
+ lazyInit(thisRef, property)
+ return value
+ }
+
+ override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) {
+ lazyInit(thisRef, property)
+ this.value = value
+ }
+}
diff --git a/mediarouter/mediarouter/src/main/res/values-ja/strings.xml b/mediarouter/mediarouter/src/main/res/values-ja/strings.xml
index e50c76e..d1589d9 100644
--- a/mediarouter/mediarouter/src/main/res/values-ja/strings.xml
+++ b/mediarouter/mediarouter/src/main/res/values-ja/strings.xml
@@ -46,7 +46,7 @@
<string name="mr_chooser_wifi_warning_description_phone" msgid="2555886884770958244">"他のデバイスがこのスマートフォンと同じ Wi-Fi ネットワークに接続していることを確認してください"</string>
<string name="mr_chooser_wifi_warning_description_tablet" msgid="6038748488793588164">"他のデバイスがこのタブレットと同じ Wi-Fi ネットワークに接続していることを確認してください"</string>
<string name="mr_chooser_wifi_warning_description_tv" msgid="5845921667085074878">"他のデバイスがこのテレビと同じ Wi-Fi ネットワークに接続していることを確認してください"</string>
- <string name="mr_chooser_wifi_warning_description_watch" msgid="5255021372884233706">"他のデバイスがこのスマートウォッチと同じ Wi-Fi ネットワークに接続していることを確認してください"</string>
+ <string name="mr_chooser_wifi_warning_description_watch" msgid="5255021372884233706">"もう一方のデバイスがこのスマートウォッチと同じ Wi-Fi ネットワークに接続していることを確認してください"</string>
<string name="mr_chooser_wifi_warning_description_car" msgid="2998902945608081567">"他のデバイスがこの車と同じ Wi-Fi ネットワークに接続していることを確認してください"</string>
<string name="mr_chooser_wifi_warning_description_unknown" msgid="3459891599800041449">"他のデバイスがこのデバイスと同じ Wi-Fi ネットワークに接続していることを確認してください"</string>
<string name="mr_chooser_wifi_learn_more" msgid="3799500840179081429"><a href="https://support.google.com/chromecast/?p=trouble-finding-devices">"詳細"</a></string>
diff --git a/mediarouter/mediarouter/src/main/res/values-zh-rTW/strings.xml b/mediarouter/mediarouter/src/main/res/values-zh-rTW/strings.xml
index f400c48..e7aa096 100644
--- a/mediarouter/mediarouter/src/main/res/values-zh-rTW/strings.xml
+++ b/mediarouter/mediarouter/src/main/res/values-zh-rTW/strings.xml
@@ -43,7 +43,7 @@
<string name="mr_dialog_transferable_header" msgid="6068257520605505468">"在群組上播放"</string>
<string name="mr_cast_dialog_title_view_placeholder" msgid="2175930138959078155">"沒有可用的資訊"</string>
<string name="mr_chooser_zero_routes_found_title" msgid="5213435473397442608">"找不到裝置"</string>
- <string name="mr_chooser_wifi_warning_description_phone" msgid="2555886884770958244">"確認其他裝置與這部手機連上同一個 Wi-Fi 網路"</string>
+ <string name="mr_chooser_wifi_warning_description_phone" msgid="2555886884770958244">"請確認另一部裝置與這支手機都連上同一個 Wi-Fi 網路"</string>
<string name="mr_chooser_wifi_warning_description_tablet" msgid="6038748488793588164">"確認其他裝置與這部平板電腦連上同一個 Wi-Fi 網路"</string>
<string name="mr_chooser_wifi_warning_description_tv" msgid="5845921667085074878">"確認其他裝置與這部電視連上同一個 Wi-Fi 網路"</string>
<string name="mr_chooser_wifi_warning_description_watch" msgid="5255021372884233706">"確認其他裝置與這支手錶連上同一個 Wi-Fi 網路"</string>
diff --git a/pdf/integration-tests/testapp/build.gradle b/pdf/integration-tests/testapp/build.gradle
index a6de465..4a6ddfe 100644
--- a/pdf/integration-tests/testapp/build.gradle
+++ b/pdf/integration-tests/testapp/build.gradle
@@ -9,9 +9,9 @@
defaultConfig {
applicationId "androidx.pdf.testapp"
- minSdk 35
+ minSdk 31
compileSdk 35
- targetSdk 35
+ targetSdk 31
}
sourceSets {
diff --git a/pdf/integration-tests/testapp/src/androidTest/kotlin/androidx/pdf/PdfViewerFragmentTestSuite.kt b/pdf/integration-tests/testapp/src/androidTest/kotlin/androidx/pdf/PdfViewerFragmentTestSuite.kt
index f910b55..9d4d1fe 100644
--- a/pdf/integration-tests/testapp/src/androidTest/kotlin/androidx/pdf/PdfViewerFragmentTestSuite.kt
+++ b/pdf/integration-tests/testapp/src/androidTest/kotlin/androidx/pdf/PdfViewerFragmentTestSuite.kt
@@ -18,8 +18,9 @@
import android.annotation.SuppressLint
import android.content.pm.ActivityInfo
+import android.os.Build
import android.view.KeyEvent
-import androidx.core.view.WindowCompat
+import androidx.annotation.RequiresExtension
import androidx.fragment.app.testing.FragmentScenario
import androidx.fragment.app.testing.launchFragmentInContainer
import androidx.lifecycle.Lifecycle
@@ -42,9 +43,9 @@
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
+import androidx.test.filters.SdkSuppress
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.After
-import org.junit.Assert
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@@ -52,6 +53,8 @@
@SuppressLint("BanThreadSleep")
@LargeTest
@RunWith(AndroidJUnit4::class)
+@SdkSuppress(minSdkVersion = 35)
+@RequiresExtension(extension = Build.VERSION_CODES.S, version = 13)
class PdfViewerFragmentTestSuite {
private lateinit var scenario: FragmentScenario<TestPdfViewerFragment>
@@ -208,7 +211,6 @@
scenario.onFragment { it.isToolboxVisible = true }
onView(withId(R.id.edit_fab)).check(matches(isDisplayed()))
onView(withId(androidx.pdf.testapp.R.id.host_Search)).check(matches(isDisplayed()))
- onView(withId(androidx.pdf.testapp.R.id.toggle_full_screen)).check(matches(isDisplayed()))
// Hide the toolbox and check visibility of buttons
scenario.onFragment { it.isToolboxVisible = false }
@@ -216,67 +218,17 @@
.check(matches(withEffectiveVisibility(ViewMatchers.Visibility.GONE)))
onView(withId(androidx.pdf.testapp.R.id.host_Search)).check(matches(isDisplayed()))
- // --- Enter immersive mode and check visibility of buttons --- //
-
- // Enter immersive mode by clicking the fullscreen button
- onView(withId(androidx.pdf.testapp.R.id.toggle_full_screen)).perform(click())
-
- scenario.onFragment { fragment ->
- val activity = fragment.requireActivity()
- val windowInsetsController =
- WindowCompat.getInsetsController(activity.window, activity.window.decorView)
-
- // Check if the system bars are hidden
- Assert.assertFalse(windowInsetsController.isAppearanceLightNavigationBars)
- Assert.assertFalse(windowInsetsController.isAppearanceLightStatusBars)
- }
-
- // Assert that the "Edit" FAB is hidden
+ // Enter immersive mode and check visibility of buttons
+ scenario.onFragment { it.onRequestImmersiveMode(true) }
onView(withId(R.id.edit_fab))
.check(matches(withEffectiveVisibility(ViewMatchers.Visibility.GONE)))
-
- // Assert that the host app's search button is visible
onView(withId(androidx.pdf.testapp.R.id.host_Search))
- .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE)))
-
- // Assert that the fullscreen toggle button is visible
- onView(withId(androidx.pdf.testapp.R.id.toggle_full_screen))
- .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE)))
-
- // --- Test search functionality in Immersive mode ---
-
- // Toggle search menu
- val searchViewAssertion = SearchViewAssertions()
- scenario.onFragment { it.isTextSearchActive = true }
- onView(withId(R.id.search_container)).check(matches(isDisplayed()))
-
- // Perform a search and assert that results are found
- onView(withId(R.id.find_query_box)).perform(typeText(SEARCH_QUERY))
- onView(withId(R.id.match_status_textview)).check(matches(isDisplayed()))
- onView(withId(R.id.match_status_textview)).check(searchViewAssertion.extractAndMatch())
-
- // Prev/next search results
- onView(withId(R.id.find_prev_btn)).perform(click())
- onView(withId(R.id.match_status_textview)).check(searchViewAssertion.matchPrevious())
- onView(withId(R.id.find_next_btn)).perform(click())
- onView(withId(R.id.match_status_textview)).check(searchViewAssertion.matchNext())
- onView(withId(R.id.find_next_btn)).perform(click())
- onView(withId(R.id.match_status_textview)).check(searchViewAssertion.matchNext())
-
- // Assert for keyboard collapse
- onView(withId(R.id.find_query_box)).perform(click())
- onView(withId(R.id.close_btn)).perform(click())
- onView(withId(R.id.find_query_box))
.check(matches(withEffectiveVisibility(ViewMatchers.Visibility.GONE)))
- // --- Test scroll functionality in immersive mode ---
- onView(withId(R.id.parent_pdf_container)).perform(swipeUp())
- onView(withId(R.id.parent_pdf_container)).perform(swipeDown())
-
- // --- Exit immersive mode and check visibility of buttons ---
-
- // Click the fullscreen button again to exit immersive mode
- onView(withId(androidx.pdf.testapp.R.id.toggle_full_screen)).perform(click())
+ // Exit immersive mode and check visibility of buttons
+ scenario.onFragment { it.onRequestImmersiveMode(false) }
+ onView(withId(R.id.edit_fab)).check(matches(isDisplayed()))
+ onView(withId(androidx.pdf.testapp.R.id.host_Search)).check(matches(isDisplayed()))
// Click the host app search button and check visibility of elements
onView(withId(androidx.pdf.testapp.R.id.host_Search)).perform(click())
@@ -284,7 +236,7 @@
onView(withId(R.id.edit_fab))
.check(matches(withEffectiveVisibility(ViewMatchers.Visibility.GONE)))
onView(withId(androidx.pdf.testapp.R.id.host_Search))
- .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE)))
+ .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.GONE)))
}
fun testPdfViewerFragment_setDocumentUri_passwordProtected_portrait() {
diff --git a/pdf/integration-tests/testapp/src/androidTest/kotlin/androidx/pdf/TestPdfViewerFragment.kt b/pdf/integration-tests/testapp/src/androidTest/kotlin/androidx/pdf/TestPdfViewerFragment.kt
index e25967c..436263d 100644
--- a/pdf/integration-tests/testapp/src/androidTest/kotlin/androidx/pdf/TestPdfViewerFragment.kt
+++ b/pdf/integration-tests/testapp/src/androidTest/kotlin/androidx/pdf/TestPdfViewerFragment.kt
@@ -16,36 +16,31 @@
package androidx.pdf
+import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
+import androidx.annotation.RequiresExtension
import androidx.annotation.RestrictTo
import androidx.core.view.ViewCompat
-import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.pdf.idlingresource.PdfIdlingResource
import androidx.pdf.testapp.R
import androidx.pdf.viewer.fragment.PdfViewerFragment
-import com.google.android.material.button.MaterialButton
import com.google.android.material.floatingactionbutton.FloatingActionButton
+@RequiresExtension(extension = Build.VERSION_CODES.S, version = 13)
@RestrictTo(RestrictTo.Scope.LIBRARY)
internal class TestPdfViewerFragment : PdfViewerFragment() {
private var hostView: FrameLayout? = null
private var search: FloatingActionButton? = null
- private var fullScreen: FloatingActionButton? = null
- private var isImmersiveModeEnabled = false
-
val pdfLoadingIdlingResource = PdfIdlingResource(PDF_LOAD_RESOURCE_NAME)
var documentLoaded = false
var documentError: Throwable? = null
- private var openPdfButton: MaterialButton? = null
- private var searchButton: MaterialButton? = null
-
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
@@ -56,7 +51,6 @@
// Inflate the custom layout for this fragment
hostView = inflater.inflate(R.layout.fragment_host, container, false) as FrameLayout
search = hostView?.findViewById(R.id.host_Search)
- fullScreen = hostView?.findViewById(R.id.toggle_full_screen)
hostView?.let { hostView -> handleInsets(hostView) }
@@ -68,41 +62,12 @@
// Set up search button click listener
search?.setOnClickListener { isTextSearchActive = true }
- fullScreen?.setOnClickListener { toggleImmersiveMode() }
return hostView
}
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- super.onViewCreated(view, savedInstanceState)
- openPdfButton = activity?.findViewById(R.id.open_pdf)
- searchButton = activity?.findViewById(R.id.search_button)
- }
-
- private fun toggleImmersiveMode() {
- isImmersiveModeEnabled = !isImmersiveModeEnabled
- updateSystemUi(!isImmersiveModeEnabled)
- updateBottomButtonsVisibility(!isImmersiveModeEnabled)
- onRequestImmersiveMode(isImmersiveModeEnabled)
- }
-
- private fun updateSystemUi(showSystemUi: Boolean) {
- val insetsController =
- WindowCompat.getInsetsController(requireActivity().window, hostView!!)
- if (showSystemUi) {
- insetsController.show(WindowInsetsCompat.Type.systemBars())
- } else {
- insetsController.hide(WindowInsetsCompat.Type.systemBars())
- }
- }
-
- private fun updateBottomButtonsVisibility(visibility: Boolean) {
- if (visibility) {
- openPdfButton?.visibility = View.VISIBLE
- searchButton?.visibility = View.VISIBLE
- } else {
- openPdfButton?.visibility = View.GONE
- searchButton?.visibility = View.GONE
- }
+ override fun onRequestImmersiveMode(enterImmersive: Boolean) {
+ super.onRequestImmersiveMode(enterImmersive)
+ if (!enterImmersive) search?.show() else search?.hide()
}
override fun onLoadDocumentSuccess() {
diff --git a/pdf/integration-tests/testapp/src/main/kotlin/androidx/pdf/testapp/ui/BasicPdfFragment.kt b/pdf/integration-tests/testapp/src/main/kotlin/androidx/pdf/testapp/ui/BasicPdfFragment.kt
index 77e9c23..6458039 100644
--- a/pdf/integration-tests/testapp/src/main/kotlin/androidx/pdf/testapp/ui/BasicPdfFragment.kt
+++ b/pdf/integration-tests/testapp/src/main/kotlin/androidx/pdf/testapp/ui/BasicPdfFragment.kt
@@ -17,13 +17,17 @@
package androidx.pdf.testapp.ui
import android.annotation.SuppressLint
+import android.content.Intent
import android.net.Uri
+import android.os.Build
import android.os.Bundle
+import android.os.ext.SdkExtensions
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts.GetContent
+import androidx.annotation.RequiresExtension
import androidx.annotation.RestrictTo
import androidx.annotation.VisibleForTesting
import androidx.fragment.app.Fragment
@@ -43,11 +47,19 @@
var filePicker: ActivityResultLauncher<String> =
registerForActivityResult(GetContent()) { uri: Uri? ->
uri?.let {
- if (!isPdfViewInitialized) {
- setPdfView()
- isPdfViewInitialized = true
+ if (SdkExtensions.getExtensionVersion(Build.VERSION_CODES.S) >= 13) {
+ if (!isPdfViewInitialized) {
+ setPdfView()
+ isPdfViewInitialized = true
+ }
+ pdfViewerFragment?.documentUri = uri
+ } else {
+ /**
+ * Send an intent to other apps who support opening PDFs in case PdfViewer
+ * library is not supported due to SdkExtension limitations.
+ */
+ sendIntentToOpenPdf(uri)
}
- pdfViewerFragment?.documentUri = uri
}
}
@@ -72,11 +84,23 @@
val searchButton: MaterialButton = pdfInteraction.searchButton
getContentButton.setOnClickListener { filePicker.launch(MIME_TYPE_PDF) }
- searchButton.setOnClickListener { setFindInFileViewVisible() }
-
+ if (SdkExtensions.getExtensionVersion(Build.VERSION_CODES.S) >= 13) {
+ searchButton.setOnClickListener { setFindInFileViewVisible() }
+ }
return pdfInteraction.root
}
+ private fun sendIntentToOpenPdf(uri: Uri) {
+ val intent =
+ Intent(Intent.ACTION_VIEW).apply {
+ setDataAndType(uri, "application/pdf")
+ flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NO_HISTORY
+ }
+ val chooser = Intent.createChooser(intent, "Open PDF")
+ startActivity(chooser)
+ }
+
+ @RequiresExtension(extension = Build.VERSION_CODES.S, version = 13)
private fun setPdfView() {
val fragmentManager: FragmentManager = childFragmentManager
@@ -92,6 +116,7 @@
fragmentManager.executePendingTransactions()
}
+ @RequiresExtension(extension = Build.VERSION_CODES.S, version = 13)
private fun setFindInFileViewVisible() {
if (pdfViewerFragment != null) {
pdfViewerFragment!!.isTextSearchActive = true
diff --git a/pdf/integration-tests/testapp/src/main/kotlin/androidx/pdf/testapp/ui/HostFragment.kt b/pdf/integration-tests/testapp/src/main/kotlin/androidx/pdf/testapp/ui/HostFragment.kt
index 0f7fb28..3f81543 100644
--- a/pdf/integration-tests/testapp/src/main/kotlin/androidx/pdf/testapp/ui/HostFragment.kt
+++ b/pdf/integration-tests/testapp/src/main/kotlin/androidx/pdf/testapp/ui/HostFragment.kt
@@ -16,17 +16,16 @@
package androidx.pdf.testapp.ui
+import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
+import androidx.annotation.RequiresExtension
import androidx.core.os.OperationCanceledException
-import androidx.core.view.WindowCompat
-import androidx.core.view.WindowInsetsCompat
import androidx.pdf.testapp.R
import androidx.pdf.viewer.fragment.PdfViewerFragment
-import com.google.android.material.button.MaterialButton
import com.google.android.material.floatingactionbutton.FloatingActionButton
/**
@@ -34,14 +33,10 @@
* adds a FloatingActionButton for search functionality and manages its visibility based on the
* immersive mode state.
*/
+@RequiresExtension(extension = Build.VERSION_CODES.S, version = 13)
class HostFragment : PdfViewerFragment() {
private var hostView: FrameLayout? = null
private var search: FloatingActionButton? = null
- private var fullScreen: FloatingActionButton? = null
- private var isImmersiveModeEnabled = false
-
- private var openPdfButton: MaterialButton? = null
- private var searchButton: MaterialButton? = null
override fun onCreateView(
inflater: LayoutInflater,
@@ -53,7 +48,6 @@
// Inflate the custom layout for this fragment.
hostView = inflater.inflate(R.layout.fragment_host, container, false) as FrameLayout
search = hostView?.findViewById(R.id.host_Search)
- fullScreen = hostView?.findViewById(R.id.toggle_full_screen)
// Add the default PDF viewer to the custom layout
hostView?.addView(view)
@@ -63,41 +57,12 @@
// Set up search button click listener
search?.setOnClickListener { isTextSearchActive = true }
- fullScreen?.setOnClickListener { toggleImmersiveMode() }
return hostView
}
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- super.onViewCreated(view, savedInstanceState)
- openPdfButton = activity?.findViewById(R.id.open_pdf)
- searchButton = activity?.findViewById(R.id.search_button)
- }
-
- private fun toggleImmersiveMode() {
- isImmersiveModeEnabled = !isImmersiveModeEnabled
- updateSystemUi(!isImmersiveModeEnabled)
- updateBottomButtonsVisibility(!isImmersiveModeEnabled)
- onRequestImmersiveMode(isImmersiveModeEnabled)
- }
-
- private fun updateSystemUi(showSystemUi: Boolean) {
- val insetsController =
- WindowCompat.getInsetsController(requireActivity().window, hostView!!)
- if (showSystemUi) {
- insetsController.show(WindowInsetsCompat.Type.systemBars())
- } else {
- insetsController.hide(WindowInsetsCompat.Type.systemBars())
- }
- }
-
- private fun updateBottomButtonsVisibility(visibility: Boolean) {
- if (visibility) {
- openPdfButton?.visibility = View.VISIBLE
- searchButton?.visibility = View.VISIBLE
- } else {
- openPdfButton?.visibility = View.GONE
- searchButton?.visibility = View.GONE
- }
+ override fun onRequestImmersiveMode(enterImmersive: Boolean) {
+ super.onRequestImmersiveMode(enterImmersive)
+ if (!enterImmersive) search?.show() else search?.hide()
}
override fun onLoadDocumentError(error: Throwable) {
diff --git a/pdf/integration-tests/testapp/src/main/res/drawable/ic_action_full_screen.xml b/pdf/integration-tests/testapp/src/main/res/drawable/ic_action_full_screen.xml
deleted file mode 100644
index 568be42..0000000
--- a/pdf/integration-tests/testapp/src/main/res/drawable/ic_action_full_screen.xml
+++ /dev/null
@@ -1,26 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?><!--
- 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
-
- 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.
- -->
-
-<vector xmlns:android="http://schemas.android.com/apk/res/android"
- android:height="24dp"
- android:tint="#000000"
- android:viewportHeight="24"
- android:viewportWidth="24"
- android:width="24dp">
- <path
- android:fillColor="@android:color/white"
- android:pathData="M7,14L5,14v5h5v-2L7,17v-3zM5,10h2L7,7h3L10,5L5,5v5zM17,17h-3v2h5v-5h-2v3zM14,5v2h3v3h2L19,5h-5z"/>
-</vector>
diff --git a/pdf/integration-tests/testapp/src/main/res/layout/fragment_host.xml b/pdf/integration-tests/testapp/src/main/res/layout/fragment_host.xml
index dd59059..e6deb4d 100644
--- a/pdf/integration-tests/testapp/src/main/res/layout/fragment_host.xml
+++ b/pdf/integration-tests/testapp/src/main/res/layout/fragment_host.xml
@@ -35,17 +35,4 @@
app:backgroundTint="?attr/colorSecondaryContainer"
app:tint="?attr/colorOnSecondaryContainer" />
- <com.google.android.material.floatingactionbutton.FloatingActionButton
- android:id="@+id/toggle_full_screen"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_marginBottom="145dp"
- android:layout_marginEnd="16dp"
- android:contentDescription="@string/toggle_full_screen"
- android:layout_gravity="bottom|end"
- android:visibility="visible"
- android:src="@drawable/ic_action_full_screen"
- app:backgroundTint="?attr/colorSecondaryContainer"
- app:tint="?attr/colorOnSecondaryContainer" />
-
</FrameLayout>
\ No newline at end of file
diff --git a/pdf/integration-tests/testapp/src/main/res/values/strings.xml b/pdf/integration-tests/testapp/src/main/res/values/strings.xml
index 972509c..514daf6 100644
--- a/pdf/integration-tests/testapp/src/main/res/values/strings.xml
+++ b/pdf/integration-tests/testapp/src/main/res/values/strings.xml
@@ -4,5 +4,4 @@
<string name="search_string">Search</string>
<string name="tab_view_string">Tab View</string>
<string name="single_pdf_string">Single Pdf</string>
- <string name="toggle_full_screen">Full Screen</string>
</resources>
\ No newline at end of file
diff --git a/pdf/pdf-viewer-fragment/api/current.txt b/pdf/pdf-viewer-fragment/api/current.txt
index 752ae07..bfd8c88 100644
--- a/pdf/pdf-viewer-fragment/api/current.txt
+++ b/pdf/pdf-viewer-fragment/api/current.txt
@@ -1,7 +1,7 @@
// Signature format: 4.0
package androidx.pdf.viewer.fragment {
- public class PdfViewerFragment extends androidx.fragment.app.Fragment {
+ @RequiresExtension(extension=android.os.Build.VERSION_CODES.S, version=13) public class PdfViewerFragment extends androidx.fragment.app.Fragment {
ctor public PdfViewerFragment();
method public final android.net.Uri? getDocumentUri();
method public final boolean isTextSearchActive();
diff --git a/pdf/pdf-viewer-fragment/api/restricted_current.txt b/pdf/pdf-viewer-fragment/api/restricted_current.txt
index 752ae07..bfd8c88 100644
--- a/pdf/pdf-viewer-fragment/api/restricted_current.txt
+++ b/pdf/pdf-viewer-fragment/api/restricted_current.txt
@@ -1,7 +1,7 @@
// Signature format: 4.0
package androidx.pdf.viewer.fragment {
- public class PdfViewerFragment extends androidx.fragment.app.Fragment {
+ @RequiresExtension(extension=android.os.Build.VERSION_CODES.S, version=13) public class PdfViewerFragment extends androidx.fragment.app.Fragment {
ctor public PdfViewerFragment();
method public final android.net.Uri? getDocumentUri();
method public final boolean isTextSearchActive();
diff --git a/pdf/pdf-viewer-fragment/build.gradle b/pdf/pdf-viewer-fragment/build.gradle
index 8ca6798..902d23f 100644
--- a/pdf/pdf-viewer-fragment/build.gradle
+++ b/pdf/pdf-viewer-fragment/build.gradle
@@ -42,9 +42,9 @@
namespace "androidx.pdf.viewer.fragment"
defaultConfig {
- minSdk 35 //TODO: Set to 31 after sdk extension 13 release
+ minSdk 31
compileSdk 35
- targetSdk 35 //TODO: Remove after sdk extension 13 release
+ targetSdk 31
}
}
diff --git a/pdf/pdf-viewer-fragment/src/main/java/androidx/pdf/viewer/fragment/PdfViewerFragment.kt b/pdf/pdf-viewer-fragment/src/main/java/androidx/pdf/viewer/fragment/PdfViewerFragment.kt
index ecabfe5..1dcd40e 100644
--- a/pdf/pdf-viewer-fragment/src/main/java/androidx/pdf/viewer/fragment/PdfViewerFragment.kt
+++ b/pdf/pdf-viewer-fragment/src/main/java/androidx/pdf/viewer/fragment/PdfViewerFragment.kt
@@ -19,12 +19,14 @@
import android.content.ContentResolver
import android.content.Context
import android.net.Uri
+import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import android.widget.FrameLayout
+import androidx.annotation.RequiresExtension
import androidx.annotation.RestrictTo
import androidx.core.os.BundleCompat
import androidx.core.view.ViewCompat
@@ -98,6 +100,7 @@
*
* @see documentUri
*/
+@RequiresExtension(extension = Build.VERSION_CODES.S, version = 13)
public open class PdfViewerFragment : Fragment() {
// ViewModel to manage PdfLoader state
diff --git a/pdf/pdf-viewer/build.gradle b/pdf/pdf-viewer/build.gradle
index 09f9303..7ed8998 100644
--- a/pdf/pdf-viewer/build.gradle
+++ b/pdf/pdf-viewer/build.gradle
@@ -32,7 +32,6 @@
implementation("androidx.annotation:annotation:1.7.0")
implementation("com.google.android.material:material:1.11.0")
implementation("com.google.errorprone:error_prone_annotations:2.30.0")
- implementation project(':tracing:tracing')
testImplementation(project(":pdf:pdf-viewer-fragment"))
testImplementation(libs.junit)
@@ -63,9 +62,9 @@
namespace "androidx.pdf"
defaultConfig {
- minSdk 35 //TODO: Set to 31 after sdk extension 13 release
+ minSdk 31
compileSdk 35
- targetSdk 35 //TODO: Remove after sdk extension 13 release
+ targetSdk 31
}
buildFeatures {
diff --git a/pdf/pdf-viewer/src/main/java/androidx/pdf/find/FindInFileView.java b/pdf/pdf-viewer/src/main/java/androidx/pdf/find/FindInFileView.java
index 9f2641e..88d0d78a 100644
--- a/pdf/pdf-viewer/src/main/java/androidx/pdf/find/FindInFileView.java
+++ b/pdf/pdf-viewer/src/main/java/androidx/pdf/find/FindInFileView.java
@@ -37,6 +37,7 @@
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RestrictTo;
+import androidx.core.os.BundleCompat;
import androidx.core.view.WindowCompat;
import androidx.core.view.WindowInsetsCompat;
import androidx.pdf.R;
@@ -291,15 +292,13 @@
@Override
protected void onRestoreInstanceState(Parcelable state) {
Bundle bundle = (Bundle) state;
- super.onRestoreInstanceState(bundle.getParcelable(KEY_SUPER, Parcelable.class));
- // TextView Focus State
- mFocus = bundle.getBoolean(KEY_FOCUS);
- // SearchModel State
+ super.onRestoreInstanceState(
+ BundleCompat.getParcelable(bundle, KEY_SUPER, Parcelable.class));
if (bundle.getBoolean(KEY_IS_SAVED)) {
mIsRestoring = true;
mSelectedMatch = bundle.getInt(KEY_SELECTED_INDEX);
mViewingPage = bundle.getInt(KEY_SELECTED_PAGE);
- mMatches = bundle.getParcelable(KEY_MATCH_RECTS, MatchRects.class);
+ mMatches = BundleCompat.getParcelable(bundle, KEY_MATCH_RECTS, MatchRects.class);
}
}
diff --git a/pdf/pdf-viewer/src/main/java/androidx/pdf/service/PdfPageAdapter.java b/pdf/pdf-viewer/src/main/java/androidx/pdf/service/PdfPageAdapter.java
index 31a36ca..067efb7 100644
--- a/pdf/pdf-viewer/src/main/java/androidx/pdf/service/PdfPageAdapter.java
+++ b/pdf/pdf-viewer/src/main/java/androidx/pdf/service/PdfPageAdapter.java
@@ -191,6 +191,7 @@
private static void checkAndExecute(@NonNull Runnable block) {
if (SdkExtensions.getExtensionVersion(Build.VERSION_CODES.S) >= 13) {
block.run();
+ return;
}
throw new UnsupportedOperationException("Operation support above S");
}
diff --git a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/loader/RemotePdfDocument.kt b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/loader/RemotePdfDocument.kt
index dde1420..7a308ac 100644
--- a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/loader/RemotePdfDocument.kt
+++ b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/loader/RemotePdfDocument.kt
@@ -22,10 +22,12 @@
import android.graphics.pdf.models.PageMatchBounds
import android.graphics.pdf.models.selection.PageSelection
import android.graphics.pdf.models.selection.SelectionBoundary
+import android.os.Build
import android.os.ParcelFileDescriptor
import android.os.RemoteException
import android.util.Size
import android.util.SparseArray
+import androidx.annotation.RequiresExtension
import androidx.annotation.RestrictTo
import androidx.pdf.loader.PdfDocument.BitmapSource
import androidx.pdf.loader.PdfDocument.PageInfo
@@ -128,6 +130,7 @@
* @return A SparseArray mapping page numbers to [PageSelection] objects representing the
* selection bounds on each page.
*/
+ @RequiresExtension(extension = Build.VERSION_CODES.S, version = 13)
override suspend fun getSelectionBounds(
pageNumber: Int,
start: PointF,
diff --git a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/service/PdfPageWrapper.kt b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/service/PdfPageWrapper.kt
index c1f600b..0f2e950 100644
--- a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/service/PdfPageWrapper.kt
+++ b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/service/PdfPageWrapper.kt
@@ -26,6 +26,8 @@
import android.graphics.pdf.models.PageMatchBounds
import android.graphics.pdf.models.selection.PageSelection
import android.graphics.pdf.models.selection.SelectionBoundary
+import android.os.Build
+import androidx.annotation.RequiresExtension
import androidx.annotation.RestrictTo
@RestrictTo(RestrictTo.Scope.LIBRARY)
@@ -64,6 +66,7 @@
}
}
+ @RequiresExtension(extension = Build.VERSION_CODES.S, version = 13)
fun getRenderParams(): RenderParams {
return RenderParams.Builder(RenderParams.RENDER_MODE_FOR_DISPLAY)
.setRenderFlags(
diff --git a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/service/PdfPageWrapperPostV.kt b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/service/PdfPageWrapperPostV.kt
index 00dd9b4..fe8a1c4 100644
--- a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/service/PdfPageWrapperPostV.kt
+++ b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/service/PdfPageWrapperPostV.kt
@@ -25,9 +25,14 @@
import android.graphics.pdf.models.PageMatchBounds
import android.graphics.pdf.models.selection.PageSelection
import android.graphics.pdf.models.selection.SelectionBoundary
+import android.os.Build
+import androidx.annotation.RequiresApi
+import androidx.annotation.RequiresExtension
import androidx.annotation.RestrictTo
@RestrictTo(RestrictTo.Scope.LIBRARY)
+@RequiresExtension(extension = Build.VERSION_CODES.S, version = 13)
+@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
internal class PdfPageWrapperPostV(private val page: PdfRenderer.Page) : PdfPageWrapper {
override val height = page.height
diff --git a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/service/PdfPageWrapperPreV.kt b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/service/PdfPageWrapperPreV.kt
index e9a578b..06429d3 100644
--- a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/service/PdfPageWrapperPreV.kt
+++ b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/service/PdfPageWrapperPreV.kt
@@ -25,9 +25,12 @@
import android.graphics.pdf.models.PageMatchBounds
import android.graphics.pdf.models.selection.PageSelection
import android.graphics.pdf.models.selection.SelectionBoundary
+import android.os.Build
+import androidx.annotation.RequiresExtension
import androidx.annotation.RestrictTo
@RestrictTo(RestrictTo.Scope.LIBRARY)
+@RequiresExtension(extension = Build.VERSION_CODES.S, version = 13)
internal class PdfPageWrapperPreV(private val page: PdfRendererPreV.Page) : PdfPageWrapper {
override val height = page.height
diff --git a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/service/PdfRendererWrapperFactory.kt b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/service/PdfRendererWrapperFactory.kt
index a840c8e..b011cbf 100644
--- a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/service/PdfRendererWrapperFactory.kt
+++ b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/service/PdfRendererWrapperFactory.kt
@@ -25,7 +25,10 @@
@Suppress("ObsoleteSdkInt")
internal object PdfRendererWrapperFactory {
fun create(pfd: ParcelFileDescriptor, password: String?): PdfRendererWrapper {
- return if (Build.VERSION.SDK_INT >= 35) {
+ return if (
+ Build.VERSION.SDK_INT >= 35 &&
+ SdkExtensions.getExtensionVersion(Build.VERSION_CODES.S) >= 13
+ ) {
PdfRendererWrapperPostV(pfd, password.orEmpty())
} else if (SdkExtensions.getExtensionVersion(Build.VERSION_CODES.S) >= 13) {
PdfRendererWrapperPreV(pfd, password.orEmpty())
diff --git a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/service/PdfRendererWrapperPostV.kt b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/service/PdfRendererWrapperPostV.kt
index 22b3784..f8ed38f 100644
--- a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/service/PdfRendererWrapperPostV.kt
+++ b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/service/PdfRendererWrapperPostV.kt
@@ -19,10 +19,15 @@
import android.graphics.pdf.LoadParams
import android.graphics.pdf.PdfRenderer
+import android.os.Build
import android.os.ParcelFileDescriptor
+import androidx.annotation.RequiresApi
+import androidx.annotation.RequiresExtension
import androidx.annotation.RestrictTo
import java.util.concurrent.ConcurrentHashMap
+@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
+@RequiresExtension(extension = Build.VERSION_CODES.S, version = 13)
@RestrictTo(RestrictTo.Scope.LIBRARY)
internal class PdfRendererWrapperPostV(
parcelFileDescriptor: ParcelFileDescriptor,
diff --git a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/service/PdfRendererWrapperPreV.kt b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/service/PdfRendererWrapperPreV.kt
index 6f67f048..ff6efb3 100644
--- a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/service/PdfRendererWrapperPreV.kt
+++ b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/service/PdfRendererWrapperPreV.kt
@@ -19,10 +19,13 @@
import android.graphics.pdf.LoadParams
import android.graphics.pdf.PdfRendererPreV
+import android.os.Build
import android.os.ParcelFileDescriptor
+import androidx.annotation.RequiresExtension
import androidx.annotation.RestrictTo
import java.util.concurrent.ConcurrentHashMap
+@RequiresExtension(extension = Build.VERSION_CODES.S, version = 13)
@RestrictTo(RestrictTo.Scope.LIBRARY)
internal class PdfRendererWrapperPreV(
parcelFileDescriptor: ParcelFileDescriptor,
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/ActivityUtils.kt b/pdf/pdf-viewer/src/test/java/androidx/pdf/ActivityUtils.kt
new file mode 100644
index 0000000..42406b8
--- /dev/null
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/ActivityUtils.kt
@@ -0,0 +1,30 @@
+/*
+ * 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
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.pdf
+
+import android.app.Activity
+import com.google.android.material.R
+import org.robolectric.Robolectric
+
+object ActivityUtils {
+ /* Provides a custom Activity with Material3 Theme, to test Views with Material 3 attributes */
+ fun getThemedActivity(): Activity {
+ val activity = Robolectric.buildActivity(Activity::class.java).create().get()
+ activity.setTheme(R.style.Theme_Material3_DayNight_NoActionBar)
+ return activity
+ }
+}
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/aidl/LinkRectsTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/aidl/LinkRectsTest.java
index d8fe68f..3c3ebdb 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/aidl/LinkRectsTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/aidl/LinkRectsTest.java
@@ -21,7 +21,6 @@
import static org.junit.Assert.assertTrue;
import android.graphics.Rect;
-import android.os.Build;
import androidx.pdf.models.LinkRects;
import androidx.test.filters.SmallTest;
@@ -29,7 +28,6 @@
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
import java.lang.reflect.Field;
import java.util.ArrayList;
@@ -38,8 +36,6 @@
@SmallTest
@RunWith(RobolectricTestRunner.class)
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
public class LinkRectsTest {
private LinkRects mLinkRects = createLinkRects(5, new Integer[]{0, 2, 3},
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/aidl/MatchRectsTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/aidl/MatchRectsTest.java
index add2079..059e0b2 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/aidl/MatchRectsTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/aidl/MatchRectsTest.java
@@ -21,7 +21,6 @@
import static org.junit.Assert.assertTrue;
import android.graphics.Rect;
-import android.os.Build;
import androidx.pdf.models.MatchRects;
import androidx.test.filters.SmallTest;
@@ -29,7 +28,6 @@
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
import java.lang.reflect.Field;
import java.util.ArrayList;
@@ -38,8 +36,6 @@
@SmallTest
@RunWith(RobolectricTestRunner.class)
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
public class MatchRectsTest {
private MatchRects mMatchRects = createMatchRects(5, 0, 2, 3);
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/aidl/SelectionBoundaryTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/aidl/SelectionBoundaryTest.java
index 12822be..c1b7171 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/aidl/SelectionBoundaryTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/aidl/SelectionBoundaryTest.java
@@ -21,7 +21,6 @@
import static org.junit.Assert.assertTrue;
import android.graphics.Point;
-import android.os.Build;
import androidx.pdf.models.SelectionBoundary;
import androidx.test.filters.SmallTest;
@@ -29,7 +28,6 @@
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
import java.lang.reflect.Field;
import java.util.ArrayList;
@@ -37,14 +35,13 @@
@SmallTest
@RunWith(RobolectricTestRunner.class)
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
public class SelectionBoundaryTest {
@Test
public void testAtIndex_selectionBoundaryCreatedWithCorrectValues() {
assertThat(SelectionBoundary.atIndex(4)).isEqualTo(new SelectionBoundary(4, -1, -1, false));
}
+
@Test
public void testAtPoint_selectionBoundaryCreatedWithCorrectValues() {
assertThat(SelectionBoundary.atPoint(new Point(3, 4))).isEqualTo(
@@ -56,6 +53,7 @@
assertThat(SelectionBoundary.atPoint(1, 2)).isEqualTo(
new SelectionBoundary(-1, 1, 2, false));
}
+
@Test
public void testClassFields() {
List<String> fields = new ArrayList<>();
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/data/ContentOpenableTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/data/ContentOpenableTest.java
index 0707999..bd49042 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/data/ContentOpenableTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/data/ContentOpenableTest.java
@@ -19,7 +19,6 @@
import static com.google.common.truth.Truth.assertThat;
import android.net.Uri;
-import android.os.Build;
import android.os.Parcel;
import androidx.pdf.models.Dimensions;
@@ -28,13 +27,10 @@
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
/** Tests for {@link ContentOpenable}. */
@SmallTest
@RunWith(RobolectricTestRunner.class)
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
public class ContentOpenableTest {
private ContentOpenable mContentOpenable;
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/data/FileOpenableTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/data/FileOpenableTest.java
index ddf3f1f..d0c961a 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/data/FileOpenableTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/data/FileOpenableTest.java
@@ -19,7 +19,6 @@
import static com.google.common.truth.Truth.assertThat;
import android.net.Uri;
-import android.os.Build;
import android.os.Parcel;
import android.os.Parcelable;
import android.os.Parcelable.Creator;
@@ -27,7 +26,6 @@
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
import java.io.File;
import java.io.FileNotFoundException;
@@ -36,8 +34,6 @@
* Tests for {@link FileOpenable}.
*/
@RunWith(RobolectricTestRunner.class)
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
public class FileOpenableTest {
private static final String PDF_MIME_TYPE = "application/pdf";
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/data/RangeTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/data/RangeTest.java
index b44eecb..e781107 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/data/RangeTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/data/RangeTest.java
@@ -18,20 +18,15 @@
import static com.google.common.truth.Truth.assertThat;
-import android.os.Build;
-
import androidx.test.filters.SmallTest;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
/** Unit tests for {@link Range}. */
@SmallTest
@RunWith(RobolectricTestRunner.class)
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
public class RangeTest {
@Test
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/find/FindInFileViewTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/find/FindInFileViewTest.java
index 1135636..65d7dfb 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/find/FindInFileViewTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/find/FindInFileViewTest.java
@@ -24,12 +24,10 @@
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.doNothing;
-import android.os.Build;
-
+import androidx.pdf.ActivityUtils;
import androidx.pdf.viewer.ImmersiveModeRequester;
import androidx.pdf.viewer.PaginatedView;
import androidx.pdf.viewer.loader.PdfLoader;
-import androidx.test.core.app.ApplicationProvider;
import androidx.test.filters.SmallTest;
import com.google.android.material.floatingactionbutton.FloatingActionButton;
@@ -43,15 +41,12 @@
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
/**
* Unit tests for {@link FindInFileView}
*/
@SmallTest
@RunWith(RobolectricTestRunner.class)
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
public class FindInFileViewTest extends TestCase {
@Mock
private PdfLoader mPdfLoader;
@@ -67,7 +62,7 @@
@Before
public void setUp() throws Exception {
mOpenMocks = MockitoAnnotations.openMocks(this);
- mFindInFileView = new FindInFileView(ApplicationProvider.getApplicationContext());
+ mFindInFileView = new FindInFileView(ActivityUtils.INSTANCE.getThemedActivity());
mFindInFileView.setPdfLoader(mPdfLoader);
mFindInFileView.setPaginatedView(mPaginatedView);
mFindInFileView.setAnnotationButton(mAnnotationButton, mImmersiveModeRequester);
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/find/MatchCountTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/find/MatchCountTest.java
index 2865be0..6b25e5f 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/find/MatchCountTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/find/MatchCountTest.java
@@ -18,16 +18,11 @@
import static com.google.common.truth.Truth.assertThat;
-import android.os.Build;
-
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
@RunWith(RobolectricTestRunner.class)
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
public class MatchCountTest {
private final MatchCount mMatchCount1 =
new MatchCount(2, 10, true);
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/select/SelectionActionModeTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/select/SelectionActionModeTest.java
index 3926e1c..e3b531f 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/select/SelectionActionModeTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/select/SelectionActionModeTest.java
@@ -22,7 +22,6 @@
import static org.mockito.Mockito.when;
import android.content.Context;
-import android.os.Build;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
@@ -37,10 +36,7 @@
import org.junit.Test;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
-import org.robolectric.annotation.Config;
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
public class SelectionActionModeTest {
@Mock
@@ -99,6 +95,7 @@
}
};
}
+
@NonNull
@Override
public String getText() {
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/util/AnnotationUtilsTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/util/AnnotationUtilsTest.java
index 8bef569..63f84b5 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/util/AnnotationUtilsTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/util/AnnotationUtilsTest.java
@@ -31,7 +31,6 @@
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.net.Uri;
-import android.os.Build;
import androidx.test.core.app.ApplicationProvider;
import androidx.test.filters.SmallTest;
@@ -40,14 +39,11 @@
import org.junit.runner.RunWith;
import org.mockito.Mockito;
import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
import java.io.File;
@SmallTest
@RunWith(RobolectricTestRunner.class)
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
public class AnnotationUtilsTest {
@Test
public void getAnnotationIntent_nonNullUri_returnsAnnotateActionIntent() {
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/util/BundleUtilsTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/util/BundleUtilsTest.java
index da30eda..196a81f 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/util/BundleUtilsTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/util/BundleUtilsTest.java
@@ -18,7 +18,6 @@
import static com.google.common.truth.Truth.assertThat;
-import android.os.Build;
import android.os.Bundle;
import androidx.test.filters.SmallTest;
@@ -26,15 +25,12 @@
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
import java.util.Map;
/** Tests for {@link BundleUtils}. */
@SmallTest
@RunWith(RobolectricTestRunner.class)
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
public class BundleUtilsTest {
private static final String KEY_1 = "Key1";
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/util/CollectUtilsTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/util/CollectUtilsTest.java
index 18fb8f8..5c44ea4 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/util/CollectUtilsTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/util/CollectUtilsTest.java
@@ -21,7 +21,6 @@
import static org.junit.Assert.fail;
-import android.os.Build;
import android.util.SparseArray;
import androidx.test.filters.SmallTest;
@@ -29,15 +28,12 @@
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
import java.util.Iterator;
import java.util.NoSuchElementException;
@SmallTest
@RunWith(RobolectricTestRunner.class)
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
public class CollectUtilsTest {
@Test
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/util/CycleRangeTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/util/CycleRangeTest.java
index c54bdee..e2210be 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/util/CycleRangeTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/util/CycleRangeTest.java
@@ -22,14 +22,11 @@
import static com.google.common.truth.Truth.assertThat;
-import android.os.Build;
-
import androidx.test.filters.SmallTest;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
import java.util.ArrayList;
import java.util.Arrays;
@@ -37,8 +34,6 @@
@SmallTest
@RunWith(RobolectricTestRunner.class)
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
public class CycleRangeTest {
@Test
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/util/EnumKeyGeneratorTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/util/EnumKeyGeneratorTest.java
index 95c71367..308fe09 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/util/EnumKeyGeneratorTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/util/EnumKeyGeneratorTest.java
@@ -18,8 +18,6 @@
import static com.google.common.truth.Truth.assertThat;
-import android.os.Build;
-
import androidx.test.filters.SmallTest;
import com.google.common.collect.Lists;
@@ -27,7 +25,6 @@
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
import java.util.ArrayList;
import java.util.List;
@@ -35,8 +32,6 @@
/** Unit tests for {@link EnumKeyGenerator}. */
@SmallTest
@RunWith(RobolectricTestRunner.class)
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
public class EnumKeyGeneratorTest {
enum Animal {
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/util/ExposedArrayTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/util/ExposedArrayTest.java
index 962539f..a96cf93 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/util/ExposedArrayTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/util/ExposedArrayTest.java
@@ -21,8 +21,6 @@
import static org.junit.Assert.fail;
-import android.os.Build;
-
import androidx.pdf.util.ObservableArray.ArrayObserver;
import androidx.pdf.util.Observables.ExposedArray;
import androidx.test.filters.SmallTest;
@@ -31,12 +29,9 @@
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
@SmallTest
@RunWith(RobolectricTestRunner.class)
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
public class ExposedArrayTest {
private ExposedArray<String> mArray;
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/util/FutureValuesTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/util/FutureValuesTest.java
index ca6a7ac..c62fa9e 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/util/FutureValuesTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/util/FutureValuesTest.java
@@ -23,8 +23,6 @@
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
-import android.os.Build;
-
import androidx.annotation.NonNull;
import androidx.pdf.data.FutureValue;
import androidx.pdf.data.FutureValue.Callback;
@@ -44,13 +42,10 @@
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
@SmallTest
@RunWith(RobolectricTestRunner.class)
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
public class FutureValuesTest {
@Mock
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/util/GestureTrackerTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/util/GestureTrackerTest.java
index f3d43dd..e0a2ce1 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/util/GestureTrackerTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/util/GestureTrackerTest.java
@@ -25,7 +25,6 @@
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
-import android.os.Build;
import android.view.MotionEvent;
import androidx.pdf.util.GestureTracker.Gesture;
@@ -41,15 +40,12 @@
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
import java.util.AbstractList;
import java.util.List;
@SmallTest
@RunWith(RobolectricTestRunner.class)
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
public class GestureTrackerTest {
private static final int TAP_TIMEOUT = 200;
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/util/IntentsTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/util/IntentsTest.java
index 7549d58..b079038 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/util/IntentsTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/util/IntentsTest.java
@@ -27,7 +27,6 @@
import android.content.Intent;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
-import android.os.Build;
import androidx.test.core.app.ApplicationProvider;
import androidx.test.filters.SmallTest;
@@ -38,14 +37,11 @@
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
/** Tests for {@link Intents}. */
@SmallTest
@RunWith(RobolectricTestRunner.class)
@SuppressWarnings("deprecation")
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
public class IntentsTest {
Context mContext;
PackageManager mPackageManager;
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/util/QuickScaleBypassDeciderTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/util/QuickScaleBypassDeciderTest.java
index 85efab1..3825104 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/util/QuickScaleBypassDeciderTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/util/QuickScaleBypassDeciderTest.java
@@ -18,7 +18,6 @@
import static com.google.common.truth.Truth.assertThat;
-import android.os.Build;
import android.view.MotionEvent;
import androidx.test.filters.SmallTest;
@@ -27,13 +26,10 @@
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
/** Unit tests for {@link QuickScaleBypassDecider}. */
@SmallTest
@RunWith(RobolectricTestRunner.class)
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
public class QuickScaleBypassDeciderTest {
private static final long FIRST_DOWN_TIME_MS = 0;
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/util/SettableFutureValueTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/util/SettableFutureValueTest.java
index 0f011f0..c8a5dae 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/util/SettableFutureValueTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/util/SettableFutureValueTest.java
@@ -21,8 +21,6 @@
import static org.junit.Assert.fail;
-import android.os.Build;
-
import androidx.annotation.NonNull;
import androidx.pdf.data.FutureValue.Callback;
import androidx.pdf.data.FutureValues;
@@ -32,12 +30,9 @@
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
@SmallTest
@RunWith(RobolectricTestRunner.class)
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
public class SettableFutureValueTest {
private static final class TestCallback implements Callback<String> {
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/util/SystemGestureExclusionHelperTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/util/SystemGestureExclusionHelperTest.java
index 4d0ddaf..21de554 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/util/SystemGestureExclusionHelperTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/util/SystemGestureExclusionHelperTest.java
@@ -26,7 +26,6 @@
import static com.google.common.truth.Truth.assertThat;
import android.graphics.Rect;
-import android.os.Build;
import androidx.test.filters.SmallTest;
@@ -34,15 +33,12 @@
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
import java.util.List;
/** Unit tests for {@link SystemGestureExclusionHelper}. */
@SmallTest
@RunWith(RobolectricTestRunner.class)
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
public class SystemGestureExclusionHelperTest {
private static final int BUFFER_DISTANCE_ZERO = 0;
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/util/TileBoardTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/util/TileBoardTest.java
index e9677c2..61e39e0 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/util/TileBoardTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/util/TileBoardTest.java
@@ -26,7 +26,6 @@
import android.graphics.Bitmap;
import android.graphics.Point;
import android.graphics.Rect;
-import android.os.Build;
import androidx.pdf.models.Dimensions;
import androidx.pdf.util.TileBoard.CancelTilesCallback;
@@ -45,7 +44,6 @@
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
import java.util.ArrayList;
import java.util.HashSet;
@@ -55,8 +53,6 @@
/** Unit tests for {@link TileBoard}. */
@SmallTest
@RunWith(RobolectricTestRunner.class)
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
public class TileBoardTest {
private static final Dimensions TILE_SIZE = new Dimensions(800, 800);
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/util/UrisTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/util/UrisTest.java
index 5dc6933..b9c980e 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/util/UrisTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/util/UrisTest.java
@@ -19,20 +19,16 @@
import static com.google.common.truth.Truth.assertThat;
import android.net.Uri;
-import android.os.Build;
import androidx.test.filters.SmallTest;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
/** Unit tests for {@link Uris}. */
@SmallTest
@RunWith(RobolectricTestRunner.class)
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
public class UrisTest {
@Test
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/util/ZoomUtilsTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/util/ZoomUtilsTest.java
index 5b0e1d2..e385bae 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/util/ZoomUtilsTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/util/ZoomUtilsTest.java
@@ -22,20 +22,15 @@
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
-import android.os.Build;
-
import androidx.test.filters.SmallTest;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
/** Tests for {@link ZoomUtils}. */
@SmallTest
@RunWith(RobolectricTestRunner.class)
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
public class ZoomUtilsTest {
@Test
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/LoadingViewTest.kt b/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/LoadingViewTest.kt
index c208944..dd4b397 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/LoadingViewTest.kt
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/LoadingViewTest.kt
@@ -15,13 +15,11 @@
*/
package androidx.pdf.viewer
-import android.content.Context
-import android.os.Build
import android.view.View
import android.widget.ProgressBar
import android.widget.TextView
+import androidx.pdf.ActivityUtils
import androidx.pdf.R
-import androidx.test.core.app.ApplicationProvider
import androidx.test.filters.SmallTest
import com.google.common.truth.Truth
import org.junit.Assert
@@ -29,21 +27,16 @@
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
-import org.robolectric.annotation.Config
/** Tests for [LoadingView]. */
@SmallTest
@RunWith(RobolectricTestRunner::class)
-
-// TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
class LoadingViewTest {
private lateinit var loadingView: LoadingView
@Before
fun setUp() {
- val context = ApplicationProvider.getApplicationContext<Context>()
- loadingView = LoadingView(context)
+ loadingView = LoadingView(ActivityUtils.getThemedActivity())
}
@Test
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/PageMosaicViewTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/PageMosaicViewTest.java
index 579b687..f0b2b622 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/PageMosaicViewTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/PageMosaicViewTest.java
@@ -26,7 +26,6 @@
import static org.mockito.Mockito.when;
import android.graphics.Rect;
-import android.os.Build;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
@@ -46,15 +45,12 @@
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
import java.util.List;
/** Unit tests for {@link PageMosaicView}. */
@SmallTest
@RunWith(RobolectricTestRunner.class)
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
public class PageMosaicViewTest {
@Mock
private MosaicView.BitmapSource mMockBitmapSource;
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/PageRangeHandlerTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/PageRangeHandlerTest.java
index 8776f49..da59d113 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/PageRangeHandlerTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/PageRangeHandlerTest.java
@@ -23,8 +23,6 @@
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
-import android.os.Build;
-
import androidx.pdf.data.Range;
import androidx.test.filters.SmallTest;
@@ -34,12 +32,9 @@
import org.mockito.invocation.InvocationOnMock;
import org.mockito.stubbing.Answer;
import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
@SmallTest
@RunWith(RobolectricTestRunner.class)
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
public class PageRangeHandlerTest {
@Test
public void getVisiblePage_nullVisiblePageRange_returnsZero() {
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/PageSelectionValueObserverTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/PageSelectionValueObserverTest.java
index 9c4b2a6..0c5ce9f 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/PageSelectionValueObserverTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/PageSelectionValueObserverTest.java
@@ -22,7 +22,6 @@
import static org.mockito.Mockito.when;
import android.content.Context;
-import android.os.Build;
import android.util.DisplayMetrics;
import androidx.pdf.data.Range;
@@ -34,13 +33,10 @@
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
@SmallTest
@RunWith(RobolectricTestRunner.class)
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
public class PageSelectionValueObserverTest {
private final PaginatedView mMockPaginatedView = mock(PaginatedView.class);
private final PaginationModel mMockPaginationModel = mock(PaginationModel.class);
@@ -71,6 +67,7 @@
new Dimensions(100, 100)).getPageView()).thenReturn(
mMockNewPageMosaicView);
when(mMockPaginatedView.getPageRangeHandler()).thenReturn(mMockPageRangeHandler);
+ when(mMockPaginatedView.getModel()).thenReturn(mMockPaginationModel);
when(mMockPageRangeHandler.getVisiblePages()).thenReturn(new Range(1, 2));
DisplayMetrics displayMetrics = new DisplayMetrics();
displayMetrics.density = 1f;
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/PageViewFactoryTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/PageViewFactoryTest.java
index 097fd6e..fcd48bd 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/PageViewFactoryTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/PageViewFactoryTest.java
@@ -25,7 +25,6 @@
import android.graphics.Color;
import android.graphics.Rect;
import android.graphics.drawable.ColorDrawable;
-import android.os.Build;
import android.view.View;
import androidx.pdf.find.FindInFileView;
@@ -42,13 +41,10 @@
import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor;
import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
@SuppressWarnings("unchecked")
@SmallTest
@RunWith(RobolectricTestRunner.class)
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
public class PageViewFactoryTest {
private final PdfLoader mMockPdfLoader = mock(PdfLoader.class);
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/PaginatedViewTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/PaginatedViewTest.java
index 311abf1..d3afdb3 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/PaginatedViewTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/PaginatedViewTest.java
@@ -19,7 +19,6 @@
import static com.google.common.truth.Truth.assertThat;
import android.content.Context;
-import android.os.Build;
import androidx.pdf.models.Dimensions;
import androidx.pdf.util.BitmapRecycler;
@@ -37,15 +36,12 @@
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
import java.util.List;
/** Tests for {@link PaginatedView}. */
@SmallTest
@RunWith(RobolectricTestRunner.class)
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
public class PaginatedViewTest {
PaginatedView mPaginatedView;
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/PaginationModelTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/PaginationModelTest.java
index a568c6f..70fb013 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/PaginationModelTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/PaginationModelTest.java
@@ -21,7 +21,6 @@
import android.content.Context;
import android.graphics.Rect;
-import android.os.Build;
import androidx.pdf.data.Range;
import androidx.pdf.models.Dimensions;
@@ -33,12 +32,9 @@
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
@SmallTest
@RunWith(RobolectricTestRunner.class)
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
public class PaginationModelTest {
private static final Dimensions ONE_HUNDRED_BY_TWO_HUNDRED = new Dimensions(100, 200);
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/SearchModelTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/SearchModelTest.java
index a1a6b6d..293eeb4 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/SearchModelTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/SearchModelTest.java
@@ -21,7 +21,6 @@
import static com.google.common.truth.Truth.assertThat;
import android.graphics.Rect;
-import android.os.Build;
import androidx.pdf.find.MatchCount;
import androidx.pdf.models.MatchRects;
@@ -34,7 +33,6 @@
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
import java.util.ArrayList;
import java.util.List;
@@ -42,8 +40,6 @@
@SmallTest
@RunWith(RobolectricTestRunner.class)
@SuppressWarnings("deprecation")
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
public class SearchModelTest {
@Mock
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/SearchQueryObserverTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/SearchQueryObserverTest.java
index 622b4b9..d726676 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/SearchQueryObserverTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/SearchQueryObserverTest.java
@@ -19,20 +19,15 @@
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
-import android.os.Build;
-
import androidx.test.filters.SmallTest;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
@SmallTest
@RunWith(RobolectricTestRunner.class)
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
public class SearchQueryObserverTest {
private final PaginatedView mMockPaginatedView = mock(PaginatedView.class);
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/SelectedMatchTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/SelectedMatchTest.java
index d77d4b1..0619d52 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/SelectedMatchTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/SelectedMatchTest.java
@@ -22,7 +22,6 @@
import static com.google.common.truth.Truth.assertThat;
import android.graphics.Rect;
-import android.os.Build;
import androidx.pdf.models.MatchRects;
import androidx.test.filters.SmallTest;
@@ -30,7 +29,6 @@
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
import java.util.ArrayList;
import java.util.Arrays;
@@ -38,8 +36,6 @@
@SmallTest
@RunWith(RobolectricTestRunner.class)
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
public class SelectedMatchTest {
@Test
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/SelectedMatchValueObserverTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/SelectedMatchValueObserverTest.java
index 1ef67d9..17fc62c 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/SelectedMatchValueObserverTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/SelectedMatchValueObserverTest.java
@@ -22,7 +22,6 @@
import android.content.Context;
import android.graphics.Rect;
-import android.os.Build;
import android.util.DisplayMetrics;
import androidx.pdf.models.Dimensions;
@@ -34,15 +33,12 @@
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
import java.util.ArrayList;
@SmallTest
@RunWith(RobolectricTestRunner.class)
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
public class SelectedMatchValueObserverTest {
private final PaginatedView mMockPaginatedView = mock(PaginatedView.class);
private final PaginationModel mMockPaginationModel = mock(PaginationModel.class);
@@ -64,6 +60,7 @@
when(mMockOldSelection.getPage()).thenReturn(1);
when(mMockNewSelection.getPage()).thenReturn(2);
when(mMockPaginationModel.getSize()).thenReturn(3);
+ when(mMockPaginatedView.getModel()).thenReturn(mMockPaginationModel);
when(mMockPaginatedView.getViewAt(1)).thenReturn(mMockOldPageView);
when(mMockPaginatedView.getViewAt(2)).thenReturn(mMockNewPageView);
when(mMockPaginationModel.getPageSize(2)).thenReturn(new Dimensions(100, 100));
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/ZoomScrollValueObserverTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/ZoomScrollValueObserverTest.java
index e9b5a39f..bbde98d 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/ZoomScrollValueObserverTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/ZoomScrollValueObserverTest.java
@@ -21,7 +21,6 @@
import static org.mockito.Mockito.when;
import android.graphics.Rect;
-import android.os.Build;
import android.os.Handler;
import android.os.Looper;
import android.view.View;
@@ -45,13 +44,10 @@
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
@SmallTest
@RunWith(RobolectricTestRunner.class)
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
public class ZoomScrollValueObserverTest {
private static final ObservableValue<ViewState>
VIEW_STATE_EXPOSED_VALUE =
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/widget/MosaicViewTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/widget/MosaicViewTest.java
index 3393c3a..585e06f 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/widget/MosaicViewTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/widget/MosaicViewTest.java
@@ -35,7 +35,6 @@
import android.graphics.ColorFilter;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
-import android.os.Build;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
@@ -60,7 +59,6 @@
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
import java.util.HashSet;
import java.util.List;
@@ -70,8 +68,6 @@
@SmallTest
@RunWith(RobolectricTestRunner.class)
@SuppressWarnings("deprecation")
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
public class MosaicViewTest {
@Mock
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/widget/PageIndicatorTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/widget/PageIndicatorTest.java
index ff48a6e..c955d8a 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/widget/PageIndicatorTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/widget/PageIndicatorTest.java
@@ -24,7 +24,6 @@
import static org.mockito.Mockito.when;
import android.content.Context;
-import android.os.Build;
import android.widget.TextView;
import androidx.pdf.data.Range;
@@ -39,13 +38,10 @@
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
/** Tests for {@link PageIndicator}. */
@SmallTest
@RunWith(RobolectricTestRunner.class)
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
public class PageIndicatorTest {
@Mock
Accessibility mAccessibility;
@@ -113,6 +109,6 @@
mPageIndicator.setRangeAndZoom(new Range(0, 0), 4.0f, false);
mPageIndicator.setRangeAndZoom(new Range(0, 0), 4.0f, true);
verify(mAccessibility).announce(mContext, mPageNumberView,
- String.format("%s\n%s", "page 1 of 10", "zoom 400 percent"));
+ String.format("%s", "zoom 400 percent"));
}
}
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/widget/ZoomViewTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/widget/ZoomViewTest.java
index 7370be1..8fb6038 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/widget/ZoomViewTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/widget/ZoomViewTest.java
@@ -24,7 +24,6 @@
import android.content.Context;
import android.graphics.PointF;
import android.graphics.Rect;
-import android.os.Build;
import android.os.Bundle;
import android.os.Parcelable;
import android.util.Pair;
@@ -50,7 +49,6 @@
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
import java.util.ArrayDeque;
import java.util.Queue;
@@ -58,8 +56,6 @@
/** Unit tests for {@link ZoomView}. */
@SmallTest
@RunWith(RobolectricTestRunner.class)
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
public class ZoomViewTest {
@Mock
diff --git a/savedstate/savedstate/src/commonMain/kotlin/androidx/savedstate/serialization/SavedStateRegistryOwnerDelegates.kt b/savedstate/savedstate/src/commonMain/kotlin/androidx/savedstate/serialization/SavedStateRegistryOwnerDelegates.kt
index 44215b9..d90d1b0 100644
--- a/savedstate/savedstate/src/commonMain/kotlin/androidx/savedstate/serialization/SavedStateRegistryOwnerDelegates.kt
+++ b/savedstate/savedstate/src/commonMain/kotlin/androidx/savedstate/serialization/SavedStateRegistryOwnerDelegates.kt
@@ -129,16 +129,18 @@
val qualifiedKey = key ?: (classNamePrefix + property.name)
val restoredState = registry.consumeRestoredStateForKey(qualifiedKey)
- val value =
+ val initialValue =
if (restoredState != null && restoredState.read { !isEmpty() }) {
decodeFromSavedState(serializer, restoredState)
} else {
init()
}
- registry.registerSavedStateProvider(qualifiedKey) { encodeToSavedState(serializer, value) }
+ registry.registerSavedStateProvider(qualifiedKey) {
+ encodeToSavedState(serializer, this.value)
+ }
- this.value = value
+ this.value = initialValue
}
override fun getValue(thisRef: Any?, property: KProperty<*>): T {
diff --git a/savedstate/savedstate/src/commonTest/kotlin/androidx/savedstate/serialization/SavedStateRegistryOwnerDelegatesTest.kt b/savedstate/savedstate/src/commonTest/kotlin/androidx/savedstate/serialization/SavedStateRegistryOwnerDelegatesTest.kt
index 7eb2e32..223653b 100644
--- a/savedstate/savedstate/src/commonTest/kotlin/androidx/savedstate/serialization/SavedStateRegistryOwnerDelegatesTest.kt
+++ b/savedstate/savedstate/src/commonTest/kotlin/androidx/savedstate/serialization/SavedStateRegistryOwnerDelegatesTest.kt
@@ -44,8 +44,9 @@
lifecycleRegistry.currentState = State.CREATED
}
- val property by owner.saved(Int.serializer()) { Int.MAX_VALUE }
- @Suppress("UNUSED_EXPRESSION") property // force access.
+ var property by owner.saved(Int.serializer()) { Int.MIN_VALUE }
+ property = Int.MAX_VALUE
+
val actualState = savedState()
owner.savedStateRegistryController.performSave(actualState)
@@ -138,8 +139,9 @@
lifecycleRegistry.currentState = State.CREATED
}
- val property by owner.saved(CUSTOM_KEY) { Int.MAX_VALUE }
- @Suppress("UNUSED_EXPRESSION") property // force access.
+ var property by owner.saved(CUSTOM_KEY) { Int.MIN_VALUE }
+ property = Int.MAX_VALUE
+
val actualState = savedState()
owner.savedStateRegistryController.performSave(actualState)
diff --git a/settings.gradle b/settings.gradle
index 1bc9dce..83b599a 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -799,6 +799,7 @@
includeProject(":lifecycle:lifecycle-viewmodel-compose-lint", [BuildType.COMPOSE])
includeProject(":lifecycle:lifecycle-viewmodel-ktx", [BuildType.MAIN, BuildType.FLAN, BuildType.COMPOSE])
includeProject(":lifecycle:lifecycle-viewmodel-savedstate", [BuildType.MAIN, BuildType.FLAN, BuildType.COMPOSE, BuildType.KMP])
+includeProject(":lifecycle:lifecycle-viewmodel-savedstate-samples", [BuildType.MAIN, BuildType.FLAN, BuildType.COMPOSE, BuildType.KMP])
includeProject(":lifecycle:lifecycle-viewmodel-testing", [BuildType.MAIN, BuildType.FLAN, BuildType.COMPOSE, BuildType.INFRAROGUE, BuildType.KMP])
includeProject(":lint:lint-gradle", [BuildType.MAIN])
includeProject(":lint-checks")
diff --git a/webkit/webkit/api/current.txt b/webkit/webkit/api/current.txt
index f5f4673..80a5e23 100644
--- a/webkit/webkit/api/current.txt
+++ b/webkit/webkit/api/current.txt
@@ -44,6 +44,7 @@
@SuppressCompatibility @RequiresFeature(name=androidx.webkit.WebViewFeature.PROFILE_URL_PREFETCH, enforcement="androidx.webkit.WebViewFeature#isFeatureSupported") @androidx.webkit.Profile.ExperimentalUrlPrefetch public final class PrefetchParameters {
method public java.util.Map<java.lang.String!,java.lang.String!> getAdditionalHeaders();
method public androidx.webkit.NoVarySearchData? getExpectedNoVarySearchData();
+ method public boolean isJavaScriptEnabled();
}
public static final class PrefetchParameters.Builder {
@@ -51,6 +52,7 @@
method @SuppressCompatibility @androidx.webkit.Profile.ExperimentalUrlPrefetch public androidx.webkit.PrefetchParameters.Builder addAdditionalHeader(String, String);
method @SuppressCompatibility @RequiresFeature(name=androidx.webkit.WebViewFeature.PROFILE_URL_PREFETCH, enforcement="androidx.webkit.WebViewFeature#isFeatureSupported") @androidx.webkit.Profile.ExperimentalUrlPrefetch public androidx.webkit.PrefetchParameters build();
method @SuppressCompatibility @androidx.webkit.Profile.ExperimentalUrlPrefetch public androidx.webkit.PrefetchParameters.Builder setExpectedNoVarySearchData(androidx.webkit.NoVarySearchData);
+ method @SuppressCompatibility @androidx.webkit.Profile.ExperimentalUrlPrefetch public androidx.webkit.PrefetchParameters.Builder setJavaScriptEnabled(boolean);
}
public class ProcessGlobalConfig {
diff --git a/webkit/webkit/api/restricted_current.txt b/webkit/webkit/api/restricted_current.txt
index f5f4673..80a5e23 100644
--- a/webkit/webkit/api/restricted_current.txt
+++ b/webkit/webkit/api/restricted_current.txt
@@ -44,6 +44,7 @@
@SuppressCompatibility @RequiresFeature(name=androidx.webkit.WebViewFeature.PROFILE_URL_PREFETCH, enforcement="androidx.webkit.WebViewFeature#isFeatureSupported") @androidx.webkit.Profile.ExperimentalUrlPrefetch public final class PrefetchParameters {
method public java.util.Map<java.lang.String!,java.lang.String!> getAdditionalHeaders();
method public androidx.webkit.NoVarySearchData? getExpectedNoVarySearchData();
+ method public boolean isJavaScriptEnabled();
}
public static final class PrefetchParameters.Builder {
@@ -51,6 +52,7 @@
method @SuppressCompatibility @androidx.webkit.Profile.ExperimentalUrlPrefetch public androidx.webkit.PrefetchParameters.Builder addAdditionalHeader(String, String);
method @SuppressCompatibility @RequiresFeature(name=androidx.webkit.WebViewFeature.PROFILE_URL_PREFETCH, enforcement="androidx.webkit.WebViewFeature#isFeatureSupported") @androidx.webkit.Profile.ExperimentalUrlPrefetch public androidx.webkit.PrefetchParameters build();
method @SuppressCompatibility @androidx.webkit.Profile.ExperimentalUrlPrefetch public androidx.webkit.PrefetchParameters.Builder setExpectedNoVarySearchData(androidx.webkit.NoVarySearchData);
+ method @SuppressCompatibility @androidx.webkit.Profile.ExperimentalUrlPrefetch public androidx.webkit.PrefetchParameters.Builder setJavaScriptEnabled(boolean);
}
public class ProcessGlobalConfig {
diff --git a/webkit/webkit/src/main/java/androidx/webkit/PrefetchParameters.java b/webkit/webkit/src/main/java/androidx/webkit/PrefetchParameters.java
index e04a866..8938a57 100644
--- a/webkit/webkit/src/main/java/androidx/webkit/PrefetchParameters.java
+++ b/webkit/webkit/src/main/java/androidx/webkit/PrefetchParameters.java
@@ -33,11 +33,13 @@
public final class PrefetchParameters {
private final @NonNull Map<String, String> mAdditionalHeaders;
private final @Nullable NoVarySearchData mExpectedNoVarySearchData;
+ private final boolean mIsJavaScriptEnabled;
private PrefetchParameters(@NonNull Map<String, String> additionalHeaders,
- @Nullable NoVarySearchData noVarySearchData) {
- this.mAdditionalHeaders = additionalHeaders;
- this.mExpectedNoVarySearchData = noVarySearchData;
+ @Nullable NoVarySearchData noVarySearchData, boolean isJavaScriptEnabled) {
+ mAdditionalHeaders = additionalHeaders;
+ mExpectedNoVarySearchData = noVarySearchData;
+ mIsJavaScriptEnabled = isJavaScriptEnabled;
}
/**
@@ -55,15 +57,24 @@
}
/**
+ * @return The javascript enabled setting built using {@link Builder}.
+ */
+ public boolean isJavaScriptEnabled() {
+ return mIsJavaScriptEnabled;
+ }
+
+ /**
* A builder class to use to construct the {@link PrefetchParameters}.
*/
public static final class Builder {
private final @NonNull Map<String, String> mAdditionalHeaders;
private @Nullable NoVarySearchData mExpectedNoVarySearchData;
+ private boolean mIsJavaScriptEnabled;
public Builder() {
mAdditionalHeaders = new HashMap<>();
mExpectedNoVarySearchData = null;
+ mIsJavaScriptEnabled = false;
}
/**
@@ -76,7 +87,8 @@
@Profile.ExperimentalUrlPrefetch
@NonNull
public PrefetchParameters build() {
- return new PrefetchParameters(mAdditionalHeaders, mExpectedNoVarySearchData);
+ return new PrefetchParameters(mAdditionalHeaders, mExpectedNoVarySearchData,
+ mIsJavaScriptEnabled);
}
/**
@@ -103,8 +115,21 @@
@NonNull
public Builder setExpectedNoVarySearchData(
@NonNull NoVarySearchData expectedNoVarySearchData) {
- this.mExpectedNoVarySearchData = expectedNoVarySearchData;
+ mExpectedNoVarySearchData = expectedNoVarySearchData;
return this;
}
+
+ /**
+ * {@code true} if the WebView's that will be loading the prefetched
+ * response will have javascript enabled. This affects whether or not
+ * client hints header is sent with the prefetch request.
+ */
+ @Profile.ExperimentalUrlPrefetch
+ @NonNull
+ public Builder setJavaScriptEnabled(boolean javaScriptEnabled) {
+ mIsJavaScriptEnabled = javaScriptEnabled;
+ return this;
+ }
+
}
}
diff --git a/webkit/webkit/src/main/java/androidx/webkit/internal/PrefetchParametersAdapter.java b/webkit/webkit/src/main/java/androidx/webkit/internal/PrefetchParametersAdapter.java
index 7d308ea..21169dc 100644
--- a/webkit/webkit/src/main/java/androidx/webkit/internal/PrefetchParametersAdapter.java
+++ b/webkit/webkit/src/main/java/androidx/webkit/internal/PrefetchParametersAdapter.java
@@ -52,4 +52,10 @@
return BoundaryInterfaceReflectionUtil.createInvocationHandlerFor(
new NoVarySearchDataAdapter(noVarySearchData));
}
+
+ @Override
+ public boolean isJavaScriptEnabled() {
+ if (mPrefetchParameters == null) return false;
+ return mPrefetchParameters.isJavaScriptEnabled();
+ }
}