Merge "[Webkit] Add executor parameter to url prerender API" into androidx-main
diff --git a/appfunctions/appfunctions-common/api/current.txt b/appfunctions/appfunctions-common/api/current.txt
index f2cf427..ba35715 100644
--- a/appfunctions/appfunctions-common/api/current.txt
+++ b/appfunctions/appfunctions-common/api/current.txt
@@ -1,6 +1,17 @@
// Signature format: 4.0
package androidx.appfunctions {
+ public abstract class AppFunctionAppException extends androidx.appfunctions.AppFunctionException {
+ }
+
+ public final class AppFunctionAppUnknownException extends androidx.appfunctions.AppFunctionAppException {
+ ctor public AppFunctionAppUnknownException(optional String? errorMessage);
+ }
+
+ public final class AppFunctionCancelledException extends androidx.appfunctions.AppFunctionSystemException {
+ ctor public AppFunctionCancelledException(optional String? errorMessage);
+ }
+
public interface AppFunctionContext {
method public String getCallingPackageName();
method public android.content.pm.SigningInfo getCallingPackageSigningInfo();
@@ -10,53 +21,69 @@
property public abstract android.content.Context context;
}
- @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.SOURCE) @kotlin.annotation.Target(allowedTargets=kotlin.annotation.AnnotationTarget.CLASS) public @interface AppFunctionEntity {
+ public final class AppFunctionDeniedException extends androidx.appfunctions.AppFunctionRequestException {
+ ctor public AppFunctionDeniedException(optional String? errorMessage);
}
- public final class AppFunctionException {
+ public final class AppFunctionDisabledException extends androidx.appfunctions.AppFunctionRequestException {
+ ctor public AppFunctionDisabledException(optional String? errorMessage);
+ }
+
+ public final class AppFunctionElementAlreadyExistsException extends androidx.appfunctions.AppFunctionRequestException {
+ ctor public AppFunctionElementAlreadyExistsException(optional String? errorMessage);
+ }
+
+ public final class AppFunctionElementNotFoundException extends androidx.appfunctions.AppFunctionRequestException {
+ ctor public AppFunctionElementNotFoundException(optional String? errorMessage);
+ }
+
+ public abstract class AppFunctionException extends java.lang.Exception {
ctor public AppFunctionException(int errorCode, optional String? errorMessage);
- method public int getErrorCategory();
- method public int getErrorCode();
- method public String? getErrorMessage();
- property public final int errorCategory;
- property public final int errorCode;
+ method public final String? getErrorMessage();
property public final String? errorMessage;
field public static final androidx.appfunctions.AppFunctionException.Companion Companion;
- field public static final int ERROR_APP_UNKNOWN_ERROR = 3000; // 0xbb8
- field public static final int ERROR_CANCELLED = 2001; // 0x7d1
- field public static final int ERROR_CATEGORY_APP = 3; // 0x3
- field public static final int ERROR_CATEGORY_REQUEST_ERROR = 1; // 0x1
- field public static final int ERROR_CATEGORY_SYSTEM = 2; // 0x2
- field public static final int ERROR_CATEGORY_UNKNOWN = 0; // 0x0
- field public static final int ERROR_DENIED = 1000; // 0x3e8
- field public static final int ERROR_DISABLED = 1002; // 0x3ea
- field public static final int ERROR_FUNCTION_NOT_FOUND = 1003; // 0x3eb
- field public static final int ERROR_INVALID_ARGUMENT = 1001; // 0x3e9
- field public static final int ERROR_LIMIT_EXCEEDED = 1501; // 0x5dd
- field public static final int ERROR_NOT_SUPPORTED = 3501; // 0xdad
- field public static final int ERROR_PERMISSION_REQUIRED = 3500; // 0xdac
- field public static final int ERROR_RESOURCE_ALREADY_EXISTS = 1502; // 0x5de
- field public static final int ERROR_RESOURCE_NOT_FOUND = 1500; // 0x5dc
- field public static final int ERROR_SYSTEM_ERROR = 2000; // 0x7d0
}
public static final class AppFunctionException.Companion {
- property public static final int ERROR_APP_UNKNOWN_ERROR;
- property public static final int ERROR_CANCELLED;
- property public static final int ERROR_CATEGORY_APP;
- property public static final int ERROR_CATEGORY_REQUEST_ERROR;
- property public static final int ERROR_CATEGORY_SYSTEM;
- property public static final int ERROR_CATEGORY_UNKNOWN;
- property public static final int ERROR_DENIED;
- property public static final int ERROR_DISABLED;
- property public static final int ERROR_FUNCTION_NOT_FOUND;
- property public static final int ERROR_INVALID_ARGUMENT;
- property public static final int ERROR_LIMIT_EXCEEDED;
- property public static final int ERROR_NOT_SUPPORTED;
- property public static final int ERROR_PERMISSION_REQUIRED;
- property public static final int ERROR_RESOURCE_ALREADY_EXISTS;
- property public static final int ERROR_RESOURCE_NOT_FOUND;
- property public static final int ERROR_SYSTEM_ERROR;
+ }
+
+ public final class AppFunctionFunctionNotFoundException extends androidx.appfunctions.AppFunctionRequestException {
+ ctor public AppFunctionFunctionNotFoundException(optional String? errorMessage);
+ }
+
+ public final class AppFunctionInvalidArgumentException extends androidx.appfunctions.AppFunctionRequestException {
+ ctor public AppFunctionInvalidArgumentException(optional String? errorMessage);
+ }
+
+ public final class AppFunctionLimitExceededException extends androidx.appfunctions.AppFunctionRequestException {
+ ctor public AppFunctionLimitExceededException(optional String? errorMessage);
+ }
+
+ public final class AppFunctionNotSupportedException extends androidx.appfunctions.AppFunctionAppException {
+ ctor public AppFunctionNotSupportedException(optional String? errorMessage);
+ }
+
+ public final class AppFunctionPermissionRequiredException extends androidx.appfunctions.AppFunctionAppException {
+ ctor public AppFunctionPermissionRequiredException(optional String? errorMessage);
+ }
+
+ public abstract class AppFunctionRequestException extends androidx.appfunctions.AppFunctionException {
+ }
+
+ @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.SOURCE) @kotlin.annotation.Target(allowedTargets=kotlin.annotation.AnnotationTarget.CLASS) public @interface AppFunctionSerializable {
+ }
+
+ public abstract class AppFunctionSystemException extends androidx.appfunctions.AppFunctionException {
+ }
+
+ public final class AppFunctionSystemUnknownException extends androidx.appfunctions.AppFunctionSystemException {
+ ctor public AppFunctionSystemUnknownException(optional String? errorMessage);
+ }
+
+ public final class AppFunctionUnknownException extends androidx.appfunctions.AppFunctionException {
+ ctor public AppFunctionUnknownException(int unknownErrorCode, optional String? errorMessage);
+ method public int getUnknownErrorCode();
+ property public final int unknownErrorCode;
}
}
diff --git a/appfunctions/appfunctions-common/api/restricted_current.txt b/appfunctions/appfunctions-common/api/restricted_current.txt
index f2cf427..ba35715 100644
--- a/appfunctions/appfunctions-common/api/restricted_current.txt
+++ b/appfunctions/appfunctions-common/api/restricted_current.txt
@@ -1,6 +1,17 @@
// Signature format: 4.0
package androidx.appfunctions {
+ public abstract class AppFunctionAppException extends androidx.appfunctions.AppFunctionException {
+ }
+
+ public final class AppFunctionAppUnknownException extends androidx.appfunctions.AppFunctionAppException {
+ ctor public AppFunctionAppUnknownException(optional String? errorMessage);
+ }
+
+ public final class AppFunctionCancelledException extends androidx.appfunctions.AppFunctionSystemException {
+ ctor public AppFunctionCancelledException(optional String? errorMessage);
+ }
+
public interface AppFunctionContext {
method public String getCallingPackageName();
method public android.content.pm.SigningInfo getCallingPackageSigningInfo();
@@ -10,53 +21,69 @@
property public abstract android.content.Context context;
}
- @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.SOURCE) @kotlin.annotation.Target(allowedTargets=kotlin.annotation.AnnotationTarget.CLASS) public @interface AppFunctionEntity {
+ public final class AppFunctionDeniedException extends androidx.appfunctions.AppFunctionRequestException {
+ ctor public AppFunctionDeniedException(optional String? errorMessage);
}
- public final class AppFunctionException {
+ public final class AppFunctionDisabledException extends androidx.appfunctions.AppFunctionRequestException {
+ ctor public AppFunctionDisabledException(optional String? errorMessage);
+ }
+
+ public final class AppFunctionElementAlreadyExistsException extends androidx.appfunctions.AppFunctionRequestException {
+ ctor public AppFunctionElementAlreadyExistsException(optional String? errorMessage);
+ }
+
+ public final class AppFunctionElementNotFoundException extends androidx.appfunctions.AppFunctionRequestException {
+ ctor public AppFunctionElementNotFoundException(optional String? errorMessage);
+ }
+
+ public abstract class AppFunctionException extends java.lang.Exception {
ctor public AppFunctionException(int errorCode, optional String? errorMessage);
- method public int getErrorCategory();
- method public int getErrorCode();
- method public String? getErrorMessage();
- property public final int errorCategory;
- property public final int errorCode;
+ method public final String? getErrorMessage();
property public final String? errorMessage;
field public static final androidx.appfunctions.AppFunctionException.Companion Companion;
- field public static final int ERROR_APP_UNKNOWN_ERROR = 3000; // 0xbb8
- field public static final int ERROR_CANCELLED = 2001; // 0x7d1
- field public static final int ERROR_CATEGORY_APP = 3; // 0x3
- field public static final int ERROR_CATEGORY_REQUEST_ERROR = 1; // 0x1
- field public static final int ERROR_CATEGORY_SYSTEM = 2; // 0x2
- field public static final int ERROR_CATEGORY_UNKNOWN = 0; // 0x0
- field public static final int ERROR_DENIED = 1000; // 0x3e8
- field public static final int ERROR_DISABLED = 1002; // 0x3ea
- field public static final int ERROR_FUNCTION_NOT_FOUND = 1003; // 0x3eb
- field public static final int ERROR_INVALID_ARGUMENT = 1001; // 0x3e9
- field public static final int ERROR_LIMIT_EXCEEDED = 1501; // 0x5dd
- field public static final int ERROR_NOT_SUPPORTED = 3501; // 0xdad
- field public static final int ERROR_PERMISSION_REQUIRED = 3500; // 0xdac
- field public static final int ERROR_RESOURCE_ALREADY_EXISTS = 1502; // 0x5de
- field public static final int ERROR_RESOURCE_NOT_FOUND = 1500; // 0x5dc
- field public static final int ERROR_SYSTEM_ERROR = 2000; // 0x7d0
}
public static final class AppFunctionException.Companion {
- property public static final int ERROR_APP_UNKNOWN_ERROR;
- property public static final int ERROR_CANCELLED;
- property public static final int ERROR_CATEGORY_APP;
- property public static final int ERROR_CATEGORY_REQUEST_ERROR;
- property public static final int ERROR_CATEGORY_SYSTEM;
- property public static final int ERROR_CATEGORY_UNKNOWN;
- property public static final int ERROR_DENIED;
- property public static final int ERROR_DISABLED;
- property public static final int ERROR_FUNCTION_NOT_FOUND;
- property public static final int ERROR_INVALID_ARGUMENT;
- property public static final int ERROR_LIMIT_EXCEEDED;
- property public static final int ERROR_NOT_SUPPORTED;
- property public static final int ERROR_PERMISSION_REQUIRED;
- property public static final int ERROR_RESOURCE_ALREADY_EXISTS;
- property public static final int ERROR_RESOURCE_NOT_FOUND;
- property public static final int ERROR_SYSTEM_ERROR;
+ }
+
+ public final class AppFunctionFunctionNotFoundException extends androidx.appfunctions.AppFunctionRequestException {
+ ctor public AppFunctionFunctionNotFoundException(optional String? errorMessage);
+ }
+
+ public final class AppFunctionInvalidArgumentException extends androidx.appfunctions.AppFunctionRequestException {
+ ctor public AppFunctionInvalidArgumentException(optional String? errorMessage);
+ }
+
+ public final class AppFunctionLimitExceededException extends androidx.appfunctions.AppFunctionRequestException {
+ ctor public AppFunctionLimitExceededException(optional String? errorMessage);
+ }
+
+ public final class AppFunctionNotSupportedException extends androidx.appfunctions.AppFunctionAppException {
+ ctor public AppFunctionNotSupportedException(optional String? errorMessage);
+ }
+
+ public final class AppFunctionPermissionRequiredException extends androidx.appfunctions.AppFunctionAppException {
+ ctor public AppFunctionPermissionRequiredException(optional String? errorMessage);
+ }
+
+ public abstract class AppFunctionRequestException extends androidx.appfunctions.AppFunctionException {
+ }
+
+ @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.SOURCE) @kotlin.annotation.Target(allowedTargets=kotlin.annotation.AnnotationTarget.CLASS) public @interface AppFunctionSerializable {
+ }
+
+ public abstract class AppFunctionSystemException extends androidx.appfunctions.AppFunctionException {
+ }
+
+ public final class AppFunctionSystemUnknownException extends androidx.appfunctions.AppFunctionSystemException {
+ ctor public AppFunctionSystemUnknownException(optional String? errorMessage);
+ }
+
+ public final class AppFunctionUnknownException extends androidx.appfunctions.AppFunctionException {
+ ctor public AppFunctionUnknownException(int unknownErrorCode, optional String? errorMessage);
+ method public int getUnknownErrorCode();
+ property public final int unknownErrorCode;
}
}
diff --git a/appfunctions/appfunctions-common/build.gradle b/appfunctions/appfunctions-common/build.gradle
index f950ea4..7d83505 100644
--- a/appfunctions/appfunctions-common/build.gradle
+++ b/appfunctions/appfunctions-common/build.gradle
@@ -36,6 +36,7 @@
// Internal dependencies
implementation("androidx.annotation:annotation:1.8.1")
+ implementation("androidx.core:core:1.1.0")
// Compile only dependencies
compileOnly(project(":appfunctions:appfunctions-stubs"))
diff --git a/appfunctions/appfunctions-common/src/androidTest/java/androidx/appfunctions/AppFunctionAppExceptionsTest.kt b/appfunctions/appfunctions-common/src/androidTest/java/androidx/appfunctions/AppFunctionAppExceptionsTest.kt
new file mode 100644
index 0000000..2172ea5
--- /dev/null
+++ b/appfunctions/appfunctions-common/src/androidTest/java/androidx/appfunctions/AppFunctionAppExceptionsTest.kt
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2025 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.appfunctions
+
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+
+class AppFunctionAppExceptionsTest {
+ @Test
+ fun testErrorCategory_AppError() {
+ assertThat(AppFunctionAppUnknownException().errorCode)
+ .isEqualTo(AppFunctionException.ERROR_APP_UNKNOWN_ERROR)
+ assertThat(AppFunctionAppUnknownException().errorCategory)
+ .isEqualTo(AppFunctionException.ERROR_CATEGORY_APP)
+
+ assertThat(AppFunctionPermissionRequiredException().errorCode)
+ .isEqualTo(AppFunctionException.ERROR_PERMISSION_REQUIRED)
+ assertThat(AppFunctionPermissionRequiredException().errorCategory)
+ .isEqualTo(AppFunctionException.ERROR_CATEGORY_APP)
+
+ assertThat(AppFunctionNotSupportedException().errorCode)
+ .isEqualTo(AppFunctionException.ERROR_NOT_SUPPORTED)
+ assertThat(AppFunctionNotSupportedException().errorCategory)
+ .isEqualTo(AppFunctionException.ERROR_CATEGORY_APP)
+ }
+}
diff --git a/appfunctions/appfunctions-common/src/androidTest/java/androidx/appfunctions/AppFunctionExceptionTest.kt b/appfunctions/appfunctions-common/src/androidTest/java/androidx/appfunctions/AppFunctionExceptionTest.kt
index 7652f91..a8cbef9 100644
--- a/appfunctions/appfunctions-common/src/androidTest/java/androidx/appfunctions/AppFunctionExceptionTest.kt
+++ b/appfunctions/appfunctions-common/src/androidTest/java/androidx/appfunctions/AppFunctionExceptionTest.kt
@@ -17,124 +17,102 @@
package androidx.appfunctions
import android.os.Bundle
-import com.google.common.truth.Truth
+import com.google.common.truth.Truth.assertThat
import org.junit.AssumptionViolatedException
import org.junit.Test
class AppFunctionExceptionTest {
@Test
- fun testConstructor_withoutMessageAndExtras() {
- val exception = AppFunctionException(AppFunctionException.ERROR_DENIED)
-
- Truth.assertThat(exception.errorCode).isEqualTo(AppFunctionException.ERROR_DENIED)
- Truth.assertThat(exception.errorMessage).isNull()
- Truth.assertThat(exception.extras).isEqualTo(Bundle.EMPTY)
- }
-
- @Test
- fun testConstructor_withoutExtras() {
- val exception = AppFunctionException(AppFunctionException.ERROR_DENIED, "testMessage")
-
- Truth.assertThat(exception.errorCode).isEqualTo(AppFunctionException.ERROR_DENIED)
- Truth.assertThat(exception.errorMessage).isEqualTo("testMessage")
- Truth.assertThat(exception.extras).isEqualTo(Bundle.EMPTY)
- }
-
- @Test
- fun testConstructor() {
- val extras = Bundle().apply { putString("testKey", "testValue") }
- val exception =
- AppFunctionException(AppFunctionException.ERROR_DENIED, "testMessage", extras)
-
- Truth.assertThat(exception.errorCode).isEqualTo(AppFunctionException.ERROR_DENIED)
- Truth.assertThat(exception.errorMessage).isEqualTo("testMessage")
- Truth.assertThat(exception.extras.getString("testKey")).isEqualTo("testValue")
- }
-
- @Test
- fun testErrorCategory_RequestError() {
- Truth.assertThat(AppFunctionException(AppFunctionException.ERROR_DENIED).errorCategory)
- .isEqualTo(AppFunctionException.ERROR_CATEGORY_REQUEST_ERROR)
- Truth.assertThat(
- AppFunctionException(AppFunctionException.ERROR_INVALID_ARGUMENT).errorCategory
- )
- .isEqualTo(AppFunctionException.ERROR_CATEGORY_REQUEST_ERROR)
- Truth.assertThat(AppFunctionException(AppFunctionException.ERROR_DISABLED).errorCategory)
- .isEqualTo(AppFunctionException.ERROR_CATEGORY_REQUEST_ERROR)
- Truth.assertThat(
- AppFunctionException(AppFunctionException.ERROR_FUNCTION_NOT_FOUND).errorCategory
- )
- .isEqualTo(AppFunctionException.ERROR_CATEGORY_REQUEST_ERROR)
- Truth.assertThat(
- AppFunctionException(AppFunctionException.ERROR_RESOURCE_NOT_FOUND).errorCategory
- )
- .isEqualTo(AppFunctionException.ERROR_CATEGORY_REQUEST_ERROR)
- Truth.assertThat(
- AppFunctionException(AppFunctionException.ERROR_LIMIT_EXCEEDED).errorCategory
- )
- .isEqualTo(AppFunctionException.ERROR_CATEGORY_REQUEST_ERROR)
- Truth.assertThat(
- AppFunctionException(AppFunctionException.ERROR_RESOURCE_ALREADY_EXISTS)
- .errorCategory
- )
- .isEqualTo(AppFunctionException.ERROR_CATEGORY_REQUEST_ERROR)
- }
-
- @Test
- fun testErrorCategory_SystemError() {
- Truth.assertThat(
- AppFunctionException(AppFunctionException.ERROR_SYSTEM_ERROR).errorCategory
- )
- .isEqualTo(AppFunctionException.ERROR_CATEGORY_SYSTEM)
- Truth.assertThat(AppFunctionException(AppFunctionException.ERROR_CANCELLED).errorCategory)
- .isEqualTo(AppFunctionException.ERROR_CATEGORY_SYSTEM)
- }
-
- @Test
- fun testErrorCategory_AppError() {
- Truth.assertThat(
- AppFunctionException(AppFunctionException.ERROR_APP_UNKNOWN_ERROR).errorCategory
- )
- .isEqualTo(AppFunctionException.ERROR_CATEGORY_APP)
- Truth.assertThat(
- AppFunctionException(AppFunctionException.ERROR_PERMISSION_REQUIRED).errorCategory
- )
- .isEqualTo(AppFunctionException.ERROR_CATEGORY_APP)
- Truth.assertThat(
- AppFunctionException(AppFunctionException.ERROR_NOT_SUPPORTED).errorCategory
- )
- .isEqualTo(AppFunctionException.ERROR_CATEGORY_APP)
- }
-
- @Test
fun testTransformToPlatformExtensionsClass() {
assumeAppFunctionExtensionLibraryAvailable()
val extras = Bundle().apply { putString("testKey", "testValue") }
- val exception =
- AppFunctionException(AppFunctionException.ERROR_DENIED, "testMessage", extras)
+ val exception = AppFunctionDeniedException("testMessage", extras)
val platformException = exception.toPlatformExtensionsClass()
- Truth.assertThat(platformException.errorCode).isEqualTo(AppFunctionException.ERROR_DENIED)
- Truth.assertThat(platformException.errorMessage).isEqualTo("testMessage")
- Truth.assertThat(platformException.extras.getString("testKey")).isEqualTo("testValue")
+ assertThat(platformException.errorCode).isEqualTo(AppFunctionException.ERROR_DENIED)
+ assertThat(platformException.errorMessage).isEqualTo("testMessage")
+ assertThat(platformException.extras.getString("testKey")).isEqualTo("testValue")
}
@Test
- fun testCreateFromPlatformExtensionsClass() {
+ fun testCreateFromPlatformExtensionsClass_knownClasses() {
+ assumeAppFunctionExtensionLibraryAvailable()
+
+ testCreateFromPlatformExtensionsClass(
+ AppFunctionException.ERROR_APP_UNKNOWN_ERROR,
+ AppFunctionAppUnknownException::class.java
+ )
+ testCreateFromPlatformExtensionsClass(
+ AppFunctionException.ERROR_PERMISSION_REQUIRED,
+ AppFunctionPermissionRequiredException::class.java
+ )
+ testCreateFromPlatformExtensionsClass(
+ AppFunctionException.ERROR_NOT_SUPPORTED,
+ AppFunctionNotSupportedException::class.java
+ )
+
+ testCreateFromPlatformExtensionsClass(
+ AppFunctionException.ERROR_DENIED,
+ AppFunctionDeniedException::class.java
+ )
+ testCreateFromPlatformExtensionsClass(
+ AppFunctionException.ERROR_INVALID_ARGUMENT,
+ AppFunctionInvalidArgumentException::class.java
+ )
+ testCreateFromPlatformExtensionsClass(
+ AppFunctionException.ERROR_DISABLED,
+ AppFunctionDisabledException::class.java
+ )
+ testCreateFromPlatformExtensionsClass(
+ AppFunctionException.ERROR_FUNCTION_NOT_FOUND,
+ AppFunctionFunctionNotFoundException::class.java
+ )
+ testCreateFromPlatformExtensionsClass(
+ AppFunctionException.ERROR_LIMIT_EXCEEDED,
+ AppFunctionLimitExceededException::class.java
+ )
+ testCreateFromPlatformExtensionsClass(
+ AppFunctionException.ERROR_RESOURCE_ALREADY_EXISTS,
+ AppFunctionElementAlreadyExistsException::class.java
+ )
+
+ testCreateFromPlatformExtensionsClass(
+ AppFunctionException.ERROR_SYSTEM_ERROR,
+ AppFunctionSystemUnknownException::class.java
+ )
+ testCreateFromPlatformExtensionsClass(
+ AppFunctionException.ERROR_CANCELLED,
+ AppFunctionCancelledException::class.java
+ )
+ }
+
+ @Test
+ fun testCreateFromPlatformExtensionsClass_unknownErrorCode() {
+ assumeAppFunctionExtensionLibraryAvailable()
+
+ testCreateFromPlatformExtensionsClass(123456, AppFunctionUnknownException::class.java)
+ }
+
+ private fun <E : AppFunctionException> testCreateFromPlatformExtensionsClass(
+ errorCode: Int,
+ exceptionClass: Class<E>
+ ) {
assumeAppFunctionExtensionLibraryAvailable()
val extras = Bundle().apply { putString("testKey", "testValue") }
val platformException =
com.android.extensions.appfunctions.AppFunctionException(
- AppFunctionException.ERROR_DENIED,
+ errorCode,
"testMessage",
extras
)
val exception = AppFunctionException.fromPlatformExtensionsClass(platformException)
- Truth.assertThat(exception.errorCode).isEqualTo(AppFunctionException.ERROR_DENIED)
- Truth.assertThat(exception.errorMessage).isEqualTo("testMessage")
- Truth.assertThat(exception.extras.getString("testKey")).isEqualTo("testValue")
+
+ assertThat(exception).isInstanceOf(exceptionClass)
+ assertThat(exception.errorCode).isEqualTo(errorCode)
+ assertThat(exception.errorMessage).isEqualTo("testMessage")
+ assertThat(exception.extras.getString("testKey")).isEqualTo("testValue")
}
private fun assumeAppFunctionExtensionLibraryAvailable(): Boolean {
diff --git a/appfunctions/appfunctions-common/src/androidTest/java/androidx/appfunctions/AppFunctionRequestExceptionsTest.kt b/appfunctions/appfunctions-common/src/androidTest/java/androidx/appfunctions/AppFunctionRequestExceptionsTest.kt
new file mode 100644
index 0000000..f98c39f
--- /dev/null
+++ b/appfunctions/appfunctions-common/src/androidTest/java/androidx/appfunctions/AppFunctionRequestExceptionsTest.kt
@@ -0,0 +1,60 @@
+/*
+ * Copyright 2025 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.appfunctions
+
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+
+class AppFunctionRequestExceptionsTest {
+ @Test
+ fun testErrorCategory_RequestError() {
+ assertThat(AppFunctionDeniedException().errorCode)
+ .isEqualTo(AppFunctionException.ERROR_DENIED)
+ assertThat(AppFunctionDeniedException().errorCategory)
+ .isEqualTo(AppFunctionException.ERROR_CATEGORY_REQUEST_ERROR)
+
+ assertThat(AppFunctionInvalidArgumentException().errorCode)
+ .isEqualTo(AppFunctionException.ERROR_INVALID_ARGUMENT)
+ assertThat(AppFunctionInvalidArgumentException().errorCategory)
+ .isEqualTo(AppFunctionException.ERROR_CATEGORY_REQUEST_ERROR)
+
+ assertThat(AppFunctionDisabledException().errorCode)
+ .isEqualTo(AppFunctionException.ERROR_DISABLED)
+ assertThat(AppFunctionDisabledException().errorCategory)
+ .isEqualTo(AppFunctionException.ERROR_CATEGORY_REQUEST_ERROR)
+
+ assertThat(AppFunctionFunctionNotFoundException().errorCode)
+ .isEqualTo(AppFunctionException.ERROR_FUNCTION_NOT_FOUND)
+ assertThat(AppFunctionFunctionNotFoundException().errorCategory)
+ .isEqualTo(AppFunctionException.ERROR_CATEGORY_REQUEST_ERROR)
+
+ assertThat(AppFunctionElementNotFoundException().errorCode)
+ .isEqualTo(AppFunctionException.ERROR_RESOURCE_NOT_FOUND)
+ assertThat(AppFunctionElementNotFoundException().errorCategory)
+ .isEqualTo(AppFunctionException.ERROR_CATEGORY_REQUEST_ERROR)
+
+ assertThat(AppFunctionLimitExceededException().errorCode)
+ .isEqualTo(AppFunctionException.ERROR_LIMIT_EXCEEDED)
+ assertThat(AppFunctionLimitExceededException().errorCategory)
+ .isEqualTo(AppFunctionException.ERROR_CATEGORY_REQUEST_ERROR)
+
+ assertThat(AppFunctionElementAlreadyExistsException().errorCode)
+ .isEqualTo(AppFunctionException.ERROR_RESOURCE_ALREADY_EXISTS)
+ assertThat(AppFunctionElementAlreadyExistsException().errorCategory)
+ .isEqualTo(AppFunctionException.ERROR_CATEGORY_REQUEST_ERROR)
+ }
+}
diff --git a/appfunctions/appfunctions-common/src/androidTest/java/androidx/appfunctions/AppFunctionSystemExceptionsTest.kt b/appfunctions/appfunctions-common/src/androidTest/java/androidx/appfunctions/AppFunctionSystemExceptionsTest.kt
new file mode 100644
index 0000000..89ccbf0
--- /dev/null
+++ b/appfunctions/appfunctions-common/src/androidTest/java/androidx/appfunctions/AppFunctionSystemExceptionsTest.kt
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2025 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.appfunctions
+
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+
+class AppFunctionSystemExceptionsTest {
+ @Test
+ fun testErrorCategory_SystemError() {
+ assertThat(AppFunctionSystemUnknownException().errorCode)
+ .isEqualTo(AppFunctionException.ERROR_SYSTEM_ERROR)
+ assertThat(AppFunctionSystemUnknownException().errorCategory)
+ .isEqualTo(AppFunctionException.ERROR_CATEGORY_SYSTEM)
+
+ assertThat(AppFunctionCancelledException().errorCode)
+ .isEqualTo(AppFunctionException.ERROR_CANCELLED)
+ assertThat(AppFunctionCancelledException().errorCategory)
+ .isEqualTo(AppFunctionException.ERROR_CATEGORY_SYSTEM)
+ }
+}
diff --git a/appfunctions/appfunctions-common/src/main/java/androidx/appfunctions/AppFunctionAppExceptions.kt b/appfunctions/appfunctions-common/src/main/java/androidx/appfunctions/AppFunctionAppExceptions.kt
new file mode 100644
index 0000000..441c6bb
--- /dev/null
+++ b/appfunctions/appfunctions-common/src/main/java/androidx/appfunctions/AppFunctionAppExceptions.kt
@@ -0,0 +1,71 @@
+/*
+ * Copyright 2025 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.appfunctions
+
+import android.os.Bundle
+
+/**
+ * Thrown when an error is caused by the app providing the function.
+ *
+ * <p>For example, the app crashed when the system is executing the request.
+ */
+public abstract class AppFunctionAppException
+internal constructor(errorCode: Int, errorMessage: String? = null, extras: Bundle) :
+ AppFunctionException(errorCode, errorMessage, extras)
+
+/**
+ * Thrown when an unknown error occurred while processing the call in the AppFunctionService.
+ *
+ * <p>This error is thrown when the service is connected in the remote application but an unexpected
+ * error is thrown from the bound application.
+ */
+public class AppFunctionAppUnknownException
+internal constructor(errorMessage: String? = null, extras: Bundle) :
+ AppFunctionAppException(ERROR_APP_UNKNOWN_ERROR, errorMessage, extras) {
+
+ public constructor(errorMessage: String? = null) : this(errorMessage, Bundle.EMPTY)
+}
+
+/**
+ * Thrown when the app lacks the necessary permission to fulfill the request.
+ *
+ * <p>This occurs when the app attempts an operation requiring user-granted permission that has not
+ * been provided. For example, creating a calendar event requires access to the calendar content. If
+ * the user hasn't granted this permission, this error should be thrown.
+ *
+ * <p> This is different from [AppFunctionDeniedException] in that the required permission is
+ * missing from the target app, as opposed to the caller.
+ */
+public class AppFunctionPermissionRequiredException
+internal constructor(errorMessage: String? = null, extras: Bundle) :
+ AppFunctionAppException(ERROR_PERMISSION_REQUIRED, errorMessage, extras) {
+
+ public constructor(errorMessage: String? = null) : this(errorMessage, Bundle.EMPTY)
+}
+
+/**
+ * Thrown when an app receives a request to perform an unsupported action.
+ *
+ * <p>For example, a clock app might support updating timer properties such as label but may not
+ * allow updating the timer's duration once the timer has already started.
+ */
+public class AppFunctionNotSupportedException
+internal constructor(errorMessage: String? = null, extras: Bundle) :
+ AppFunctionAppException(ERROR_NOT_SUPPORTED, errorMessage, extras) {
+
+ public constructor(errorMessage: String? = null) : this(errorMessage, Bundle.EMPTY)
+}
diff --git a/appfunctions/appfunctions-common/src/main/java/androidx/appfunctions/AppFunctionException.kt b/appfunctions/appfunctions-common/src/main/java/androidx/appfunctions/AppFunctionException.kt
index cf82d12..e273dd4 100644
--- a/appfunctions/appfunctions-common/src/main/java/androidx/appfunctions/AppFunctionException.kt
+++ b/appfunctions/appfunctions-common/src/main/java/androidx/appfunctions/AppFunctionException.kt
@@ -25,14 +25,14 @@
*
* This exception can be used by the app to report errors to the caller.
*/
-public class AppFunctionException
+public abstract class AppFunctionException
internal constructor(
/** The error code. */
- @ErrorCode public val errorCode: Int,
+ @ErrorCode internal val errorCode: Int,
/** The error message. */
public val errorMessage: String?,
internal val extras: Bundle
-) {
+) : Exception(errorMessage) {
/**
* Create an [AppFunctionException].
*
@@ -65,7 +65,7 @@
* error category.
*/
@ErrorCategory
- public val errorCategory: Int =
+ internal val errorCategory: Int =
when (errorCode) {
in 1000..1999 -> ERROR_CATEGORY_REQUEST_ERROR
in 2000..2999 -> ERROR_CATEGORY_SYSTEM
@@ -109,16 +109,45 @@
public fun fromPlatformExtensionsClass(
exception: com.android.extensions.appfunctions.AppFunctionException
): AppFunctionException {
- return AppFunctionException(
- exception.errorCode,
- exception.errorMessage,
- exception.extras
- )
+ return when (exception.errorCode) {
+ ERROR_DENIED -> AppFunctionDeniedException(exception.errorMessage, exception.extras)
+ ERROR_INVALID_ARGUMENT ->
+ AppFunctionInvalidArgumentException(exception.errorMessage, exception.extras)
+ ERROR_DISABLED ->
+ AppFunctionDisabledException(exception.errorMessage, exception.extras)
+ ERROR_FUNCTION_NOT_FOUND ->
+ AppFunctionFunctionNotFoundException(exception.errorMessage, exception.extras)
+ ERROR_RESOURCE_NOT_FOUND ->
+ AppFunctionElementNotFoundException(exception.errorMessage, exception.extras)
+ ERROR_LIMIT_EXCEEDED ->
+ AppFunctionLimitExceededException(exception.errorMessage, exception.extras)
+ ERROR_RESOURCE_ALREADY_EXISTS ->
+ AppFunctionElementAlreadyExistsException(
+ exception.errorMessage,
+ exception.extras
+ )
+ ERROR_SYSTEM_ERROR ->
+ AppFunctionSystemUnknownException(exception.errorMessage, exception.extras)
+ ERROR_CANCELLED ->
+ AppFunctionCancelledException(exception.errorMessage, exception.extras)
+ ERROR_APP_UNKNOWN_ERROR ->
+ AppFunctionAppUnknownException(exception.errorMessage, exception.extras)
+ ERROR_PERMISSION_REQUIRED ->
+ AppFunctionPermissionRequiredException(exception.errorMessage, exception.extras)
+ ERROR_NOT_SUPPORTED ->
+ AppFunctionNotSupportedException(exception.errorMessage, exception.extras)
+ else ->
+ AppFunctionUnknownException(
+ exception.errorCode,
+ exception.errorMessage,
+ exception.extras,
+ )
+ }
}
// Error categories
/** The error category is unknown. */
- public const val ERROR_CATEGORY_UNKNOWN: Int = 0
+ internal const val ERROR_CATEGORY_UNKNOWN: Int = 0
/**
* The error is caused by the app requesting a function execution.
@@ -128,7 +157,7 @@
*
* <p>Errors in the category fall in the range 1000-1999 inclusive.
*/
- public const val ERROR_CATEGORY_REQUEST_ERROR: Int = 1
+ internal const val ERROR_CATEGORY_REQUEST_ERROR: Int = 1
/**
* The error is caused by an issue in the system.
@@ -137,7 +166,7 @@
*
* <p>Errors in the category fall in the range 2000-2999 inclusive.
*/
- public const val ERROR_CATEGORY_SYSTEM: Int = 2
+ internal const val ERROR_CATEGORY_SYSTEM: Int = 2
/**
* The error is caused by the app providing the function.
@@ -146,15 +175,18 @@
*
* <p>Errors in the category fall in the range 3000-3999 inclusive.
*/
- public const val ERROR_CATEGORY_APP: Int = 3
+ internal const val ERROR_CATEGORY_APP: Int = 3
// Error codes
/**
* The caller does not have the permission to execute an app function.
*
+ * <p> This is different from [ERROR_PERMISSION_REQUIRED] in that the caller is missing this
+ * specific permission, as opposed to the target app missing a permission.
+ *
* <p>This error is in the [ERROR_CATEGORY_REQUEST_ERROR] category.
*/
- public const val ERROR_DENIED: Int = 1000
+ internal const val ERROR_DENIED: Int = 1000
/**
* The caller supplied invalid arguments to the execution request.
@@ -163,21 +195,21 @@
*
* <p>This error is in the [ERROR_CATEGORY_REQUEST_ERROR] category.
*/
- public const val ERROR_INVALID_ARGUMENT: Int = 1001
+ internal const val ERROR_INVALID_ARGUMENT: Int = 1001
/**
* The caller tried to execute a disabled app function.
*
* <p>This error is in the [ERROR_CATEGORY_REQUEST_ERROR] category.
*/
- public const val ERROR_DISABLED: Int = 1002
+ internal const val ERROR_DISABLED: Int = 1002
/**
* The caller tried to execute a function that does not exist.
*
* <p>This error is in the [ERROR_CATEGORY_REQUEST_ERROR] category.
*/
- public const val ERROR_FUNCTION_NOT_FOUND: Int = 1003
+ internal const val ERROR_FUNCTION_NOT_FOUND: Int = 1003
// SDK-defined error codes in the [ERROR_CATEGORY_REQUEST_ERROR] category start from 1500.
/**
@@ -185,14 +217,14 @@
*
* <p>This error is in the [ERROR_CATEGORY_REQUEST_ERROR] category.
*/
- public const val ERROR_RESOURCE_NOT_FOUND: Int = 1500
+ internal const val ERROR_RESOURCE_NOT_FOUND: Int = 1500
/**
* The caller exceeded the allowed request rate.
*
* <p>This error is in the [ERROR_CATEGORY_REQUEST_ERROR] category.
*/
- public const val ERROR_LIMIT_EXCEEDED: Int = 1501
+ internal const val ERROR_LIMIT_EXCEEDED: Int = 1501
/**
* The caller tried to create a resource/entity that already exists or has conflicts with
@@ -200,14 +232,14 @@
*
* <p>This error is in the [ERROR_CATEGORY_REQUEST_ERROR] category.
*/
- public const val ERROR_RESOURCE_ALREADY_EXISTS: Int = 1502
+ internal const val ERROR_RESOURCE_ALREADY_EXISTS: Int = 1502
/**
* An internal unexpected error coming from the system.
*
* <p>This error is in the [ERROR_CATEGORY_SYSTEM] category.
*/
- public const val ERROR_SYSTEM_ERROR: Int = 2000
+ internal const val ERROR_SYSTEM_ERROR: Int = 2000
/**
* The operation was cancelled. Use this error code to report that a cancellation is done
@@ -215,7 +247,7 @@
*
* <p>This error is in the [ERROR_CATEGORY_SYSTEM] category.
*/
- public const val ERROR_CANCELLED: Int = 2001
+ internal const val ERROR_CANCELLED: Int = 2001
/**
* An unknown error occurred while processing the call in the AppFunctionService.
@@ -225,7 +257,7 @@
*
* <p>This error is in the [ERROR_CATEGORY_APP] category.
*/
- public const val ERROR_APP_UNKNOWN_ERROR: Int = 3000
+ internal const val ERROR_APP_UNKNOWN_ERROR: Int = 3000
// SDK-defined error codes in the [ERROR_CATEGORY_APP] category start from 3500.
/**
@@ -236,9 +268,12 @@
* calendar content. If the user hasn't granted this permission, this error should be
* thrown.
*
+ * <p> This is different from [ERROR_DENIED] in that the required permission is missing from
+ * the target app, as opposed to the caller.
+ *
* <p>This error is in the [ERROR_CATEGORY_APP] category.
*/
- public const val ERROR_PERMISSION_REQUIRED: Int = 3500
+ internal const val ERROR_PERMISSION_REQUIRED: Int = 3500
/**
* Indicates the action is not supported by the app.
@@ -249,6 +284,25 @@
*
* <p>This error is in the [ERROR_CATEGORY_APP] category.
*/
- public const val ERROR_NOT_SUPPORTED: Int = 3501
+ internal const val ERROR_NOT_SUPPORTED: Int = 3501
}
}
+
+/**
+ * Thrown when an unknown error has occurred.
+ *
+ * <p> This Exception is used when the error doesn't belong to any other AppFunctionException. Note
+ * that this different from [AppFunctionAppUnknownException], in that the error wasn't necessarily
+ * caused by the app.
+ */
+public class AppFunctionUnknownException
+internal constructor(
+ public val unknownErrorCode: Int,
+ errorMessage: String? = null,
+ extras: Bundle
+) : AppFunctionException(unknownErrorCode, errorMessage, extras) {
+ public constructor(
+ unknownErrorCode: Int,
+ errorMessage: String? = null
+ ) : this(unknownErrorCode, errorMessage, Bundle.EMPTY)
+}
diff --git a/appfunctions/appfunctions-common/src/main/java/androidx/appfunctions/AppFunctionRequestExceptions.kt b/appfunctions/appfunctions-common/src/main/java/androidx/appfunctions/AppFunctionRequestExceptions.kt
new file mode 100644
index 0000000..fc16714
--- /dev/null
+++ b/appfunctions/appfunctions-common/src/main/java/androidx/appfunctions/AppFunctionRequestExceptions.kt
@@ -0,0 +1,103 @@
+/*
+ * Copyright 2025 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.appfunctions
+
+import android.os.Bundle
+
+/**
+ * Thrown when the error is caused by the app requesting a function execution.
+ *
+ * <p>For example, the caller provided invalid parameters in the execution request e.g. an invalid
+ * function ID.
+ */
+public abstract class AppFunctionRequestException
+internal constructor(errorCode: Int, errorMessage: String? = null, extras: Bundle) :
+ AppFunctionException(errorCode, errorMessage, extras)
+
+/**
+ * Thrown when the caller does not have the permission to execute an app function.
+ *
+ * <p> This is different from [AppFunctionPermissionRequiredException] in that the caller is missing
+ * this specific permission, as opposed to the target app missing a permission.
+ */
+public class AppFunctionDeniedException
+internal constructor(errorMessage: String? = null, extras: Bundle) :
+ AppFunctionRequestException(ERROR_DENIED, errorMessage, extras) {
+
+ public constructor(errorMessage: String? = null) : this(errorMessage, Bundle.EMPTY)
+}
+
+/**
+ * Thrown when the caller supplied invalid arguments to ExecuteAppFunctionRequest's parameters.
+ *
+ * <p>This error may be considered similar to [IllegalArgumentException].
+ */
+// TODO(b/389738031): add reference to ExecuteAppFunctionRequest's builder when it is added.
+public class AppFunctionInvalidArgumentException
+internal constructor(errorMessage: String? = null, extras: Bundle) :
+ AppFunctionRequestException(ERROR_INVALID_ARGUMENT, errorMessage, extras) {
+
+ public constructor(errorMessage: String? = null) : this(errorMessage, Bundle.EMPTY)
+}
+
+/**
+ * Thrown when the caller tried to execute a disabled app function. An app function can be enabled
+ * at runtime through the AppFunctionManager or by setting enabledByDefault=true in the AppFunction
+ * annotation.
+ */
+// TODO(b/389738031): add reference to setAppFunctionEnabled and @AppFunction when they are added.
+public class AppFunctionDisabledException
+internal constructor(errorMessage: String? = null, extras: Bundle) :
+ AppFunctionRequestException(ERROR_DISABLED, errorMessage, extras) {
+
+ public constructor(errorMessage: String? = null) : this(errorMessage, Bundle.EMPTY)
+}
+
+/** Thrown when the caller tries to execute a function that does not exist. */
+public class AppFunctionFunctionNotFoundException
+internal constructor(errorMessage: String? = null, extras: Bundle) :
+ AppFunctionRequestException(ERROR_FUNCTION_NOT_FOUND, errorMessage, extras) {
+
+ public constructor(errorMessage: String? = null) : this(errorMessage, Bundle.EMPTY)
+}
+
+/** Thrown when the caller tried to request a resource/entity that does not exist. */
+public class AppFunctionElementNotFoundException
+internal constructor(errorMessage: String? = null, extras: Bundle) :
+ AppFunctionRequestException(ERROR_RESOURCE_NOT_FOUND, errorMessage, extras) {
+
+ public constructor(errorMessage: String? = null) : this(errorMessage, Bundle.EMPTY)
+}
+
+/** Thrown when the caller exceeded the allowed request rate. */
+public class AppFunctionLimitExceededException
+internal constructor(errorMessage: String? = null, extras: Bundle) :
+ AppFunctionRequestException(ERROR_LIMIT_EXCEEDED, errorMessage, extras) {
+
+ public constructor(errorMessage: String? = null) : this(errorMessage, Bundle.EMPTY)
+}
+
+/**
+ * Thrown when the caller tried to create a resource/entity that already exists or has conflicts
+ * with existing resource/entity.
+ */
+public class AppFunctionElementAlreadyExistsException
+internal constructor(errorMessage: String? = null, extras: Bundle) :
+ AppFunctionRequestException(ERROR_RESOURCE_ALREADY_EXISTS, errorMessage, extras) {
+
+ public constructor(errorMessage: String? = null) : this(errorMessage, Bundle.EMPTY)
+}
diff --git a/appfunctions/appfunctions-common/src/main/java/androidx/appfunctions/AppFunctionEntity.kt b/appfunctions/appfunctions-common/src/main/java/androidx/appfunctions/AppFunctionSerializable.kt
similarity index 78%
rename from appfunctions/appfunctions-common/src/main/java/androidx/appfunctions/AppFunctionEntity.kt
rename to appfunctions/appfunctions-common/src/main/java/androidx/appfunctions/AppFunctionSerializable.kt
index 29eb0c7..4688942 100644
--- a/appfunctions/appfunctions-common/src/main/java/androidx/appfunctions/AppFunctionEntity.kt
+++ b/appfunctions/appfunctions-common/src/main/java/androidx/appfunctions/AppFunctionSerializable.kt
@@ -17,16 +17,17 @@
package androidx.appfunctions
/**
- * Annotates a class intended to represent structured input or output for an AppFunction.
+ * Annotates a class to indicate that it can be serialized and transferred between processes using
+ * AppFunction.
*
- * When a class is annotated with `@AppFunctionEntity` and is used as a parameter or return type
- * (directly or as a nested entity) in an AppFunction, the shape of the entity defined within its
- * primary constructor will be exposed to the caller as an
+ * When a class is annotated with `@AppFunctionSerializable` and is used as a parameter or return
+ * type (directly or as a nested entity) in an AppFunction, the shape of the entity defined within
+ * its primary constructor will be exposed to the caller as an
* [androidx.appfunctions.metadata.AppFunctionMetadata]. This information allows the caller to
* construct the structure input to call an AppFunction or understand what properties are provided
* in the structured output.
*
- * **Constraints for Classes Annotated with `@AppFunctionEntity`:**
+ * **Constraints for Classes Annotated with `@AppFunctionSerializable`:**
* * **Primary Constructor Parameters:** Only properties declared in the primary constructor that
* expose a getter method are eligible for inclusion in the AppFunctionMetadata. Critically, it is
* a **requirement** to place properties with a getter in primary constructor. Attempting to
@@ -47,8 +48,8 @@
* * `BooleanArray`
* * `ByteArray`
* * `List<String>`
- * * Another class annotated with `@AppFunctionEntity` (enabling nested structures) or a list of
- * a class annotated with `@AppFunctionEntity`
+ * * Another class annotated with `@AppFunctionSerializable` (enabling nested structures) or a
+ * list of a class annotated with `@AppFunctionSerializable`
* * **Public Primary Constructor:** The primary constructor of the annotated class must have public
* visibility to allow instantiation.
* * **
@@ -64,24 +65,24 @@
* **Example:**
*
* ```
- * @AppFunctionEntity
+ * @AppFunctionSerializable
* class Location(val latitude: Double, val longitude: Double)
*
- * @AppFunctionEntity
+ * @AppFunctionSerializable
* class Place(
* val name: String,
- * val location: Location, // Nested AppFunctionEntity
+ * val location: Location, // Nested AppFunctionSerializable
* // Nullable String is allowed, if missing, will be null. The default value will not be used
* // when the value is missing
* val notes: String? = "default"
* )
*
- * @AppFunctionEntity
+ * @AppFunctionSerializable
* class SearchPlaceResult(
* val places: List<Place> // If missing, will be an empty list
* )
*
- * @AppFunctionEntity
+ * @AppFunctionSerializable
* class Attachment(
* uri: String // Putting constructor parameter without getter will result in compiler error
* )
@@ -89,4 +90,4 @@
*/
@Retention(AnnotationRetention.SOURCE)
@Target(AnnotationTarget.CLASS)
-public annotation class AppFunctionEntity
+public annotation class AppFunctionSerializable
diff --git a/appfunctions/appfunctions-common/src/main/java/androidx/appfunctions/AppFunctionSystemExceptions.kt b/appfunctions/appfunctions-common/src/main/java/androidx/appfunctions/AppFunctionSystemExceptions.kt
new file mode 100644
index 0000000..66596b4
--- /dev/null
+++ b/appfunctions/appfunctions-common/src/main/java/androidx/appfunctions/AppFunctionSystemExceptions.kt
@@ -0,0 +1,48 @@
+/*
+ * Copyright 2025 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.appfunctions
+
+import android.os.Bundle
+
+/**
+ * Thrown when an internal unexpected error comes from the system.
+ *
+ * <p>For example, the AppFunctionService implementation is not found by the system.
+ */
+public abstract class AppFunctionSystemException
+internal constructor(errorCode: Int, errorMessage: String? = null, extras: Bundle) :
+ AppFunctionException(errorCode, errorMessage, extras)
+
+/**
+ * Thrown when an internal unexpected error comes from the system.
+ *
+ * <p>For example, the AppFunctionService implementation is not found by the system.
+ */
+public class AppFunctionSystemUnknownException
+internal constructor(errorMessage: String? = null, extras: Bundle) :
+ AppFunctionSystemException(ERROR_SYSTEM_ERROR, errorMessage, extras) {
+
+ public constructor(errorMessage: String? = null) : this(errorMessage, Bundle.EMPTY)
+}
+
+/** Thrown when an operation was cancelled. */
+public class AppFunctionCancelledException
+internal constructor(errorMessage: String? = null, extras: Bundle) :
+ AppFunctionSystemException(ERROR_CANCELLED, errorMessage, extras) {
+
+ public constructor(errorMessage: String? = null) : this(errorMessage, Bundle.EMPTY)
+}
diff --git a/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/AppSearchImplTest.java b/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/AppSearchImplTest.java
index 898e680..724fb0b 100644
--- a/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/AppSearchImplTest.java
+++ b/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/AppSearchImplTest.java
@@ -110,6 +110,7 @@
import org.junit.rules.TemporaryFolder;
import java.io.File;
+import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
@@ -2300,6 +2301,42 @@
@Test
@RequiresFlagsEnabled(Flags.FLAG_ENABLE_BLOB_STORE)
+ public void testWriteAfterCommit_notAllowed() throws Exception {
+ mAppSearchImpl = AppSearchImpl.create(
+ mAppSearchDir,
+ new AppSearchConfigImpl(new UnlimitedLimitConfig(),
+ new LocalStorageIcingOptionsConfig()),
+ /*initStatsBuilder=*/ null,
+ /*visibilityChecker=*/ null,
+ new JetpackRevocableFileDescriptorStore(mUnlimitedConfig),
+ ALWAYS_OPTIMIZE);
+ byte[] data = generateRandomBytes(20 * 1024); // 20 KiB
+ byte[] digest = calculateDigest(data);
+ AppSearchBlobHandle handle = AppSearchBlobHandle.createWithSha256(
+ digest, "package", "db1", "ns");
+ // Open a pfd for write, write the blob data without close the pfd.
+ ParcelFileDescriptor writePfd = mAppSearchImpl.openWriteBlob("package", "db1", handle);
+ try (FileOutputStream outputStream = new FileOutputStream(writePfd.getFileDescriptor())) {
+ outputStream.write(data);
+ outputStream.flush();
+ }
+
+ // Commit the blob.
+ mAppSearchImpl.commitBlob("package", "db1", handle);
+
+ // Keep writing to the pfd for write.
+ assertThrows(IOException.class,
+ () -> {
+ try (FileOutputStream outputStream =
+ new FileOutputStream(writePfd.getFileDescriptor())) {
+ outputStream.write(data);
+ outputStream.flush();
+ }
+ });
+ }
+
+ @Test
+ @RequiresFlagsEnabled(Flags.FLAG_ENABLE_BLOB_STORE)
public void testRemovePendingBlob() throws Exception {
mAppSearchImpl = AppSearchImpl.create(
mAppSearchDir,
@@ -2470,6 +2507,66 @@
@Test
@RequiresFlagsEnabled(Flags.FLAG_ENABLE_BLOB_STORE)
+ public void testOpenMultipleBlobForWrite() throws Exception {
+ mAppSearchImpl = AppSearchImpl.create(
+ mAppSearchDir,
+ new AppSearchConfigImpl(new UnlimitedLimitConfig(),
+ new LocalStorageIcingOptionsConfig()),
+ /*initStatsBuilder=*/ null,
+ /*visibilityChecker=*/ null,
+ new JetpackRevocableFileDescriptorStore(mUnlimitedConfig),
+ ALWAYS_OPTIMIZE);
+ byte[] data = generateRandomBytes(20); // 20 Bytes
+ byte[] digest = calculateDigest(data);
+ AppSearchBlobHandle handle = AppSearchBlobHandle.createWithSha256(
+ digest, "package", "db1", "ns");
+
+ // only allow open 1 fd for writing.
+ try (ParcelFileDescriptor writePfd1 =
+ mAppSearchImpl.openWriteBlob("package", "db1", handle);
+ ParcelFileDescriptor writePfd2 =
+ mAppSearchImpl.openWriteBlob("package", "db1", handle)) {
+ assertThat(writePfd1).isEqualTo(writePfd2);
+ }
+ }
+
+ @Test
+ @RequiresFlagsEnabled(Flags.FLAG_ENABLE_BLOB_STORE)
+ public void testOpenMultipleBlobForRead() throws Exception {
+ mAppSearchImpl = AppSearchImpl.create(
+ mAppSearchDir,
+ new AppSearchConfigImpl(new UnlimitedLimitConfig(),
+ new LocalStorageIcingOptionsConfig()),
+ /*initStatsBuilder=*/ null,
+ /*visibilityChecker=*/ null,
+ new JetpackRevocableFileDescriptorStore(mUnlimitedConfig),
+ ALWAYS_OPTIMIZE);
+ byte[] data = generateRandomBytes(20); // 20 Bytes
+ byte[] digest = calculateDigest(data);
+ AppSearchBlobHandle handle = AppSearchBlobHandle.createWithSha256(
+ digest, "package", "db1", "ns");
+
+ // write a blob first.
+ try (ParcelFileDescriptor writePfd = mAppSearchImpl.openWriteBlob("package", "db1", handle);
+ OutputStream outputStream = new ParcelFileDescriptor
+ .AutoCloseOutputStream(writePfd)) {
+ outputStream.write(data);
+ outputStream.flush();
+ }
+ // commit the change and read the blob.
+ mAppSearchImpl.commitBlob("package", "db1", handle);
+
+ // allow open multiple fd for reading.
+ try (ParcelFileDescriptor readPfd1 =
+ mAppSearchImpl.openReadBlob("package", "db1", handle);
+ ParcelFileDescriptor readPfd2 =
+ mAppSearchImpl.openReadBlob("package", "db1", handle)) {
+ assertThat(readPfd1).isNotEqualTo(readPfd2);
+ }
+ }
+
+ @Test
+ @RequiresFlagsEnabled(Flags.FLAG_ENABLE_BLOB_STORE)
public void testOptimizeBlob() throws Exception {
// Create a new AppSearchImpl with lower orphan blob time to live.
mAppSearchImpl.close();
@@ -6110,6 +6207,19 @@
+ "descriptors. Some file descriptors must be closed to open additional "
+ "ones.");
+ // Open new fd for write will also fail since read and write share the same limit.
+ byte[] data2 = generateRandomBytes(20 * 1024); // 20 KiB
+ byte[] digest2 = calculateDigest(data2);
+ AppSearchBlobHandle handle2 = AppSearchBlobHandle.createWithSha256(
+ digest2, mContext.getPackageName(), "db1", "ns");
+ e = assertThrows(AppSearchException.class,
+ () -> mAppSearchImpl.openWriteBlob(mContext.getPackageName(), "db1", handle2));
+ assertThat(e.getResultCode()).isEqualTo(RESULT_OUT_OF_SPACE);
+ assertThat(e).hasMessageThat().contains(
+ "Package \"" + mContext.getPackageName() + "\" exceeded limit of 2 opened file "
+ + "descriptors. Some file descriptors must be closed to open additional "
+ + "ones.");
+
// Close 1st fd and open 3rd fd will success
reader1.close();
ParcelFileDescriptor reader3 =
diff --git a/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/AppSearchImpl.java b/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/AppSearchImpl.java
index 1dfb53a..4dbbaf5 100644
--- a/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/AppSearchImpl.java
+++ b/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/AppSearchImpl.java
@@ -31,6 +31,7 @@
import android.util.Log;
import androidx.annotation.GuardedBy;
+import androidx.annotation.OptIn;
import androidx.annotation.RestrictTo;
import androidx.annotation.VisibleForTesting;
import androidx.annotation.WorkerThread;
@@ -248,6 +249,7 @@
@GuardedBy("mReadWriteLock")
private int mOptimizeIntervalCountLocked = 0;
+ @ExperimentalAppSearchApi
private final @Nullable RevocableFileDescriptorStore mRevocableFileDescriptorStore;
/** Whether this instance has been closed, and therefore unusable. */
@@ -270,6 +272,7 @@
* access to aa specific schema. Pass null will lost that ability and
* global querier could only get their own data.
*/
+ @OptIn(markerClass = ExperimentalAppSearchApi.class)
public static @NonNull AppSearchImpl create(
@NonNull File icingDir,
@NonNull AppSearchConfig config,
@@ -285,6 +288,7 @@
/**
* @param initStatsBuilder collects stats for initialization if provided.
*/
+ @OptIn(markerClass = ExperimentalAppSearchApi.class)
private AppSearchImpl(
@NonNull File icingDir,
@NonNull AppSearchConfig config,
@@ -481,6 +485,7 @@
* create a new, usable instance.
*/
@Override
+ @OptIn(markerClass = ExperimentalAppSearchApi.class)
public void close() {
mReadWriteLock.writeLock().lock();
try {
@@ -1145,6 +1150,10 @@
* Gets the {@link ParcelFileDescriptor} for write purpose of the given
* {@link AppSearchBlobHandle}.
*
+ * <p> Only one opened {@link ParcelFileDescriptor} is allowed for each
+ * {@link AppSearchBlobHandle}. The same {@link ParcelFileDescriptor} will be returned if it is
+ * not closed by caller.
+ *
* @param packageName The package name that owns this blob.
* @param databaseName The databaseName this blob resides in.
* @param handle The {@link AppSearchBlobHandle} represent the blob.
@@ -1163,19 +1172,26 @@
try {
throwIfClosedLocked();
verifyCallingBlobHandle(packageName, databaseName, handle);
+ ParcelFileDescriptor pfd = mRevocableFileDescriptorStore
+ .getOpenedRevocableFileDescriptorForWrite(packageName, handle);
+ if (pfd != null) {
+ // There is already an opened pfd for write with same blob handle, just return the
+ // already opened one.
+ return pfd;
+ }
mRevocableFileDescriptorStore.checkBlobStoreLimit(packageName);
PropertyProto.BlobHandleProto blobHandleProto =
BlobHandleToProtoConverter.toBlobHandleProto(handle);
BlobProto result = mIcingSearchEngineLocked.openWriteBlob(blobHandleProto);
checkSuccess(result.getStatus());
- ParcelFileDescriptor pfd = ParcelFileDescriptor.adoptFd(result.getFileDescriptor());
+ pfd = ParcelFileDescriptor.adoptFd(result.getFileDescriptor());
mNamespaceCacheLocked.addToBlobNamespaceMap(createPrefix(packageName, databaseName),
blobHandleProto.getNamespace());
- return mRevocableFileDescriptorStore
- .wrapToRevocableFileDescriptor(handle.getPackageName(), pfd);
+ return mRevocableFileDescriptorStore.wrapToRevocableFileDescriptor(
+ packageName, handle, pfd, ParcelFileDescriptor.MODE_READ_WRITE);
} finally {
mReadWriteLock.writeLock().unlock();
}
@@ -1211,6 +1227,7 @@
BlobHandleToProtoConverter.toBlobHandleProto(handle));
checkSuccess(result.getStatus());
+ mRevocableFileDescriptorStore.revokeFdForWrite(packageName, handle);
} finally {
mReadWriteLock.writeLock().unlock();
}
@@ -1230,7 +1247,7 @@
public void commitBlob(
@NonNull String packageName,
@NonNull String databaseName,
- @NonNull AppSearchBlobHandle handle) throws AppSearchException {
+ @NonNull AppSearchBlobHandle handle) throws AppSearchException, IOException {
if (mRevocableFileDescriptorStore == null) {
throw new UnsupportedOperationException(
"BLOB_STORAGE is not available on this AppSearch implementation.");
@@ -1243,6 +1260,8 @@
BlobHandleToProtoConverter.toBlobHandleProto(handle));
checkSuccess(result.getStatus());
+ // The blob is committed and sealed, revoke the sent pfd for writing.
+ mRevocableFileDescriptorStore.revokeFdForWrite(packageName, handle);
} finally {
mReadWriteLock.writeLock().unlock();
}
@@ -1280,7 +1299,10 @@
checkSuccess(result.getStatus());
ParcelFileDescriptor pfd = ParcelFileDescriptor.fromFd(result.getFileDescriptor());
- return mRevocableFileDescriptorStore.wrapToRevocableFileDescriptor(packageName, pfd);
+ // We do NOT need to look up the revocable file descriptor for read, skip passing the
+ // blob handle key.
+ return mRevocableFileDescriptorStore.wrapToRevocableFileDescriptor(
+ packageName, /*blobHandle=*/null, pfd, ParcelFileDescriptor.MODE_READ_ONLY);
} finally {
mReadWriteLock.readLock().unlock();
}
@@ -1330,8 +1352,13 @@
checkSuccess(result.getStatus());
ParcelFileDescriptor pfd = ParcelFileDescriptor.fromFd(result.getFileDescriptor());
- return mRevocableFileDescriptorStore
- .wrapToRevocableFileDescriptor(access.getCallingPackageName(), pfd);
+ // We do NOT need to look up the revocable file descriptor for read, skip passing the
+ // blob handle key.
+ return mRevocableFileDescriptorStore.wrapToRevocableFileDescriptor(
+ access.getCallingPackageName(),
+ /*blobHandle=*/null,
+ pfd,
+ ParcelFileDescriptor.MODE_READ_ONLY);
} finally {
mReadWriteLock.readLock().unlock();
}
@@ -2580,6 +2607,7 @@
* @param packageName The name of package to be removed.
* @throws AppSearchException if we cannot remove the data.
*/
+ @OptIn(markerClass = ExperimentalAppSearchApi.class)
public void clearPackageData(@NonNull String packageName) throws AppSearchException,
IOException {
mReadWriteLock.writeLock().lock();
diff --git a/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/AppSearchRevocableFileDescriptor.java b/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/AppSearchRevocableFileDescriptor.java
new file mode 100644
index 0000000..bf0f134
--- /dev/null
+++ b/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/AppSearchRevocableFileDescriptor.java
@@ -0,0 +1,68 @@
+/*
+ * 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.localstorage;
+
+import android.os.ParcelFileDescriptor;
+import android.os.ParcelFileDescriptor.OnCloseListener;
+
+import androidx.annotation.RestrictTo;
+
+import org.jspecify.annotations.NonNull;
+
+import java.io.IOException;
+
+/**
+ * A custom {@link ParcelFileDescriptor} that provides an additional mechanism to register
+ * a {@link ParcelFileDescriptor.OnCloseListener} which will be invoked when the file
+ * descriptor is closed.
+ * @exportToFramework:hide
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+public interface AppSearchRevocableFileDescriptor {
+
+ /**
+ * Gets the mode of this {@link AppSearchRevocableFileDescriptor}, It should be
+ * {@link ParcelFileDescriptor#MODE_READ_ONLY} or {@link ParcelFileDescriptor#MODE_READ_WRITE}.
+ */
+ int getMode();
+
+ /**
+ * Gets the revocable {@link ParcelFileDescriptor} that could be sent to an untrusted caller.
+ *
+ * <p> AppSearch will retain control of this {@link ParcelFileDescriptor}'s access to the file.
+ *
+ * <p> Call {@link #revoke()} to invoke the sent {@link ParcelFileDescriptor}.
+ */
+ @NonNull
+ ParcelFileDescriptor getRevocableFileDescriptor();
+
+ /**
+ * Revoke the sent {@link ParcelFileDescriptor} returned by
+ * {@link #getRevocableFileDescriptor()}.
+ *
+ * <p>After calling this method, any access to the file descriptors will fail.
+ *
+ * @throws IOException If an I/O error occurs while revoking file descriptors.
+ */
+ void revoke() throws IOException;
+
+ /**
+ * Callback for indicating that the {@link ParcelFileDescriptor} returned by
+ * {@link #getRevocableFileDescriptor()} has been closed.
+ */
+ void setOnCloseListener(@NonNull OnCloseListener onCloseListener);
+}
diff --git a/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/JetpackAppSearchRevocableFileDescriptor.java b/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/JetpackAppSearchRevocableFileDescriptor.java
new file mode 100644
index 0000000..c28a5d0
--- /dev/null
+++ b/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/JetpackAppSearchRevocableFileDescriptor.java
@@ -0,0 +1,96 @@
+/*
+ * 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:copyToPath(../../../cts/tests/appsearch/testutils/src/android/app/appsearch/testutil/external/JetpackAppSearchRevocableFileDescriptor.java)
+
+package androidx.appsearch.localstorage;
+
+import android.os.ParcelFileDescriptor;
+
+import androidx.annotation.RestrictTo;
+import androidx.core.util.Preconditions;
+
+import org.jspecify.annotations.NonNull;
+
+import java.io.IOException;
+
+/**
+ * The local storage implementation of {@link AppSearchRevocableFileDescriptor}.
+ *
+ * <p> Since the {@link ParcelFileDescriptor} sent to the client side from the local storage
+ * won't cross the binder, we could revoke the {@link ParcelFileDescriptor} in the client side
+ * by directly close the one in AppSearch side. This class just adding close listener to the
+ * inner {@link ParcelFileDescriptor}.
+ * @exportToFramework:hide
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+public class JetpackAppSearchRevocableFileDescriptor extends ParcelFileDescriptor
+ implements AppSearchRevocableFileDescriptor {
+ private ParcelFileDescriptor.OnCloseListener mOnCloseListener;
+ private int mMode;
+
+ /**
+ * Create a new ParcelFileDescriptor wrapped around another descriptor. By
+ * default all method calls are delegated to the wrapped descriptor.
+ */
+ JetpackAppSearchRevocableFileDescriptor(@NonNull ParcelFileDescriptor parcelFileDescriptor,
+ int mode) {
+ super(parcelFileDescriptor);
+ mMode = mode;
+ }
+
+ @Override
+ public int getMode() {
+ return mMode;
+ }
+
+ @Override
+ @NonNull
+ public ParcelFileDescriptor getRevocableFileDescriptor() {
+ return this;
+ }
+
+ @Override
+ public void setOnCloseListener(@NonNull OnCloseListener onCloseListener) {
+ if (mOnCloseListener != null) {
+ throw new IllegalStateException("The close listener has already been set.");
+ }
+ mOnCloseListener = Preconditions.checkNotNull(onCloseListener);
+ }
+
+ @Override
+ public void revoke() throws IOException {
+ // In jetpack, we already remove this revokeFd from cached map, we could directly close the
+ // super ParcelFileDescriptor without invoke close listener.
+ super.close();
+ }
+
+ @Override
+ public void close() throws IOException {
+ try {
+ super.close();
+ if (mOnCloseListener != null) {
+ // Success closed and invoke call back without exception.
+ mOnCloseListener.onClose(null);
+ }
+ } catch (IOException e) {
+ if (mOnCloseListener != null) {
+ mOnCloseListener.onClose(e);
+ }
+ throw e;
+ }
+ }
+}
diff --git a/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/JetpackRevocableFileDescriptorStore.java b/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/JetpackRevocableFileDescriptorStore.java
index 84013ac..b454028 100644
--- a/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/JetpackRevocableFileDescriptorStore.java
+++ b/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/JetpackRevocableFileDescriptorStore.java
@@ -19,20 +19,11 @@
import android.os.ParcelFileDescriptor;
-import androidx.annotation.GuardedBy;
import androidx.annotation.RestrictTo;
-import androidx.appsearch.app.AppSearchResult;
-import androidx.appsearch.exceptions.AppSearchException;
-import androidx.collection.ArrayMap;
-import androidx.core.util.Preconditions;
+import androidx.appsearch.app.ExperimentalAppSearchApi;
import org.jspecify.annotations.NonNull;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Map;
-
/**
* The local storage implementation of {@link RevocableFileDescriptorStore}.
*
@@ -46,149 +37,18 @@
* @exportToFramework:hide
*/
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-public class JetpackRevocableFileDescriptorStore implements
+@ExperimentalAppSearchApi
+public class JetpackRevocableFileDescriptorStore extends
RevocableFileDescriptorStore {
- private final Object mLock = new Object();
- private final AppSearchConfig mConfig;
-
public JetpackRevocableFileDescriptorStore(@NonNull AppSearchConfig config) {
- mConfig = Preconditions.checkNotNull(config);
- }
-
- @GuardedBy("mLock")
- // <package, List<sent rfds> map to tracking all sent rfds.
- private final Map<String, List<JetpackRevocableFileDescriptor>>
- mSentAppSearchParcelFileDescriptorsLocked = new ArrayMap<>();
-
- @Override
- public @NonNull ParcelFileDescriptor wrapToRevocableFileDescriptor(@NonNull String packageName,
- @NonNull ParcelFileDescriptor parcelFileDescriptor) {
- JetpackRevocableFileDescriptor revocableFileDescriptor =
- new JetpackRevocableFileDescriptor(parcelFileDescriptor);
- setCloseListenerToFd(revocableFileDescriptor, packageName);
- addToSentAppSearchParcelFileDescriptorMap(revocableFileDescriptor, packageName);
- return revocableFileDescriptor;
+ super(config);
}
@Override
- public void revokeAll() throws IOException {
- synchronized (mLock) {
- for (String packageName : mSentAppSearchParcelFileDescriptorsLocked.keySet()) {
- revokeForPackage(packageName);
- }
- }
- }
-
- @Override
- public void revokeForPackage(@NonNull String packageName) throws IOException {
- synchronized (mLock) {
- List<JetpackRevocableFileDescriptor> rfds =
- mSentAppSearchParcelFileDescriptorsLocked.remove(packageName);
- if (rfds != null) {
- for (int i = rfds.size() - 1; i >= 0; i--) {
- rfds.get(i).closeSuperDirectly();
- }
- }
- }
- }
-
- @Override
- public void checkBlobStoreLimit(@NonNull String packageName) throws AppSearchException {
- synchronized (mLock) {
- List<JetpackRevocableFileDescriptor> rfdsForPackage =
- mSentAppSearchParcelFileDescriptorsLocked.get(packageName);
- if (rfdsForPackage == null) {
- return;
- }
- if (rfdsForPackage.size() >= mConfig.getMaxOpenBlobCount()) {
- throw new AppSearchException(AppSearchResult.RESULT_OUT_OF_SPACE,
- "Package \"" + packageName + "\" exceeded limit of "
- + mConfig.getMaxOpenBlobCount()
- + " opened file descriptors. Some file descriptors "
- + "must be closed to open additional ones.");
- }
- }
- }
-
- private void setCloseListenerToFd(
- @NonNull JetpackRevocableFileDescriptor revocableFileDescriptor,
- @NonNull String packageName) {
- revocableFileDescriptor.setCloseListener(e -> {
- synchronized (mLock) {
- List<JetpackRevocableFileDescriptor> fdsForPackage =
- mSentAppSearchParcelFileDescriptorsLocked.get(packageName);
- if (fdsForPackage != null) {
- fdsForPackage.remove(revocableFileDescriptor);
- if (fdsForPackage.isEmpty()) {
- mSentAppSearchParcelFileDescriptorsLocked.remove(packageName);
- }
- }
- }
- });
- }
-
- private void addToSentAppSearchParcelFileDescriptorMap(
- @NonNull JetpackRevocableFileDescriptor revocableFileDescriptor,
- @NonNull String packageName) {
- synchronized (mLock) {
- List<JetpackRevocableFileDescriptor> rfdsForPackage =
- mSentAppSearchParcelFileDescriptorsLocked.get(packageName);
- if (rfdsForPackage == null) {
- rfdsForPackage = new ArrayList<>();
- mSentAppSearchParcelFileDescriptorsLocked.put(packageName, rfdsForPackage);
- }
- rfdsForPackage.add(revocableFileDescriptor);
- }
- }
-
- /**
- * A custom {@link ParcelFileDescriptor} that provides an additional mechanism to register
- * a {@link ParcelFileDescriptor.OnCloseListener} which will be invoked when the file
- * descriptor is closed.
- *
- * <p> Since the {@link ParcelFileDescriptor} sent to the client side from the local storage
- * won't cross the binder, we could revoke the {@link ParcelFileDescriptor} in the client side
- * by directly close the one in AppSearch side. This class just adding close listener to the
- * inner {@link ParcelFileDescriptor}.
- */
- static class JetpackRevocableFileDescriptor extends ParcelFileDescriptor {
- private ParcelFileDescriptor.OnCloseListener mOnCloseListener;
-
- /**
- * Create a new ParcelFileDescriptor wrapped around another descriptor. By
- * default all method calls are delegated to the wrapped descriptor.
- */
- JetpackRevocableFileDescriptor(@NonNull ParcelFileDescriptor parcelFileDescriptor) {
- super(parcelFileDescriptor);
- }
-
- void setCloseListener(
- ParcelFileDescriptor.@NonNull OnCloseListener onCloseListener) {
- if (mOnCloseListener != null) {
- throw new IllegalStateException("The close listener has already been set.");
- }
- mOnCloseListener = Preconditions.checkNotNull(onCloseListener);
- }
-
- /** Close the super {@link ParcelFileDescriptor} without invoke close listener. */
- void closeSuperDirectly() throws IOException {
- super.close();
- }
-
- @Override
- public void close() throws IOException {
- try {
- super.close();
- if (mOnCloseListener != null) {
- mOnCloseListener.onClose(null);
- }
- } catch (IOException e) {
- if (mOnCloseListener != null) {
- mOnCloseListener.onClose(e);
- }
- throw e;
- }
- }
+ protected @NonNull AppSearchRevocableFileDescriptor wrapToRevocableFileDescriptor(
+ @NonNull ParcelFileDescriptor parcelFileDescriptor,
+ int mode) {
+ return new JetpackAppSearchRevocableFileDescriptor(parcelFileDescriptor, mode);
}
}
diff --git a/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/LocalStorage.java b/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/LocalStorage.java
index d3377ee..d7d38b3 100644
--- a/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/LocalStorage.java
+++ b/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/LocalStorage.java
@@ -20,12 +20,14 @@
import android.os.SystemClock;
import android.util.Log;
+import androidx.annotation.OptIn;
import androidx.annotation.RestrictTo;
import androidx.annotation.VisibleForTesting;
import androidx.annotation.WorkerThread;
import androidx.appsearch.annotation.Document;
import androidx.appsearch.app.AppSearchEnvironmentFactory;
import androidx.appsearch.app.AppSearchSession;
+import androidx.appsearch.app.ExperimentalAppSearchApi;
import androidx.appsearch.app.GlobalSearchSession;
import androidx.appsearch.exceptions.AppSearchException;
import androidx.appsearch.flags.Flags;
@@ -359,6 +361,7 @@
}
@WorkerThread
+ @OptIn(markerClass = ExperimentalAppSearchApi.class)
private LocalStorage(
@NonNull Context context,
@NonNull Executor executor,
diff --git a/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/RevocableFileDescriptorStore.java b/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/RevocableFileDescriptorStore.java
index 200b5b7..12ecb4b 100644
--- a/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/RevocableFileDescriptorStore.java
+++ b/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/RevocableFileDescriptorStore.java
@@ -18,39 +18,135 @@
import android.os.ParcelFileDescriptor;
+
+import androidx.annotation.GuardedBy;
import androidx.annotation.RestrictTo;
+import androidx.appsearch.app.AppSearchBlobHandle;
+import androidx.appsearch.app.AppSearchResult;
+import androidx.appsearch.app.ExperimentalAppSearchApi;
import androidx.appsearch.exceptions.AppSearchException;
+import androidx.collection.ArrayMap;
+import androidx.collection.ArraySet;
+import androidx.core.util.Preconditions;
import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
/**
- * Interface for revocable file descriptors storage.
+ * The base class for revocable file descriptors storage.
*
* <p>This store allows wrapping {@link ParcelFileDescriptor} instances into revocable file
* descriptors, enabling the ability to close and revoke it's access to the file even if the
* {@link ParcelFileDescriptor} has been sent to the client side.
*
- * <p> Implementations of this interface can provide controlled access to resources by associating
- * each file descriptor with a package and allowing them to be individually revoked by package
- * or revoked all at once.
+ * <p> This class can provide controlled access to resources by associating each file descriptor
+ * with a package and allowing them to be individually revoked by package or revoked all at once.
+ *
+ * <p> The sub-class must define how to wrap a {@link ParcelFileDescriptor} to a
+ * {@link AppSearchRevocableFileDescriptor}.
+ *
+ * <p> This class stores {@link AppSearchBlobHandle} and returned
+ * {@link AppSearchRevocableFileDescriptor} for writing in key-value pairs map. Only one opened
+ * {@link AppSearchRevocableFileDescriptor} for writing will be allowed for each
+ * {@link AppSearchBlobHandle}.
+ *
+ * <p> This class stores {@link AppSearchRevocableFileDescriptor} for reading in a list. There is no
+ * use case to look up and invoke a single {@link AppSearchRevocableFileDescriptor} for reading.
*
* @exportToFramework:hide
*/
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-public interface RevocableFileDescriptorStore {
+@ExperimentalAppSearchApi
+public abstract class RevocableFileDescriptorStore {
+
+ private final Object mLock = new Object();
+ private final AppSearchConfig mConfig;
+
+ public RevocableFileDescriptorStore(@NonNull AppSearchConfig config) {
+ mConfig = Preconditions.checkNotNull(config);
+ }
+
+ @GuardedBy("mLock")
+ // Map<package, Map<blob handle, sent rfds>> map to track all sent rfds for writing. We only
+ // allow user to open 1 pfd for write for same file.
+ private final Map<String, Map<AppSearchBlobHandle, AppSearchRevocableFileDescriptor>>
+ mSentRevocableFileDescriptorsForWriteLocked = new ArrayMap<>();
+
+ @GuardedBy("mLock")
+ // <package, List<sent rfds> map to track all sent rfds for reading. We allow opening
+ // multiple pfds for read for the same file.
+ private final Map<String, List<AppSearchRevocableFileDescriptor>>
+ mSentRevocableFileDescriptorsForReadLocked = new ArrayMap<>();
/**
- * Wraps the provided ParcelFileDescriptor into a revocable file descriptor.
- * This allows for controlled access to the file descriptor, making it revocable by the store.
+ * Wraps the provided {@link ParcelFileDescriptor} into a revocable file descriptor.
+ *
+ * <p>This allows for controlled access to the file descriptor, making it revocable by the
+ * store.
*
* @param packageName The package name requesting the revocable file descriptor.
- * @param parcelFileDescriptor The original ParcelFileDescriptor to be wrapped.
- * @return A ParcelFileDescriptor that can be revoked by the store.
+ * @param blobHandle The blob handle associated with the file descriptor. It cannot be null if
+ * the mode is READ_WRITE.
+ * @param parcelFileDescriptor The original {@link ParcelFileDescriptor} to be wrapped.
+ * @param mode The mode of the given {@link ParcelFileDescriptor}. It should be
+ * {@link ParcelFileDescriptor#MODE_READ_ONLY} or {@link ParcelFileDescriptor#MODE_READ_WRITE}.
+ * @return A {@link ParcelFileDescriptor} that can be revoked by the store.
+ * @throws IOException if an I/O error occurs while creating the revocable file descriptor.
*/
- @NonNull ParcelFileDescriptor wrapToRevocableFileDescriptor(@NonNull String packageName,
- @NonNull ParcelFileDescriptor parcelFileDescriptor) throws IOException;
+ public @NonNull ParcelFileDescriptor wrapToRevocableFileDescriptor(
+ @NonNull String packageName,
+ @Nullable AppSearchBlobHandle blobHandle,
+ @NonNull ParcelFileDescriptor parcelFileDescriptor,
+ int mode) throws IOException {
+ AppSearchRevocableFileDescriptor revocableFileDescriptor =
+ wrapToRevocableFileDescriptor(parcelFileDescriptor, mode);
+ setCloseListenerToFd(packageName, blobHandle, revocableFileDescriptor);
+ addToSentRevocableFileDescriptorMap(packageName, blobHandle,
+ revocableFileDescriptor);
+ return revocableFileDescriptor.getRevocableFileDescriptor();
+ }
+
+ /**
+ * Wraps the provided {@link ParcelFileDescriptor} into a specific type of
+ * {@link AppSearchRevocableFileDescriptor}.
+ */
+ protected abstract @NonNull AppSearchRevocableFileDescriptor wrapToRevocableFileDescriptor(
+ @NonNull ParcelFileDescriptor parcelFileDescriptor,
+ int mode) throws IOException;
+
+ /**
+ * Gets the opened revocable file descriptor for write associated with the given
+ * {@link AppSearchBlobHandle}.
+ *
+ * @param packageName The package name associated with the file descriptor.
+ * @param blobHandle The blob handle associated with the file descriptor.
+ * @return The opened revocable file descriptor, or {@code null} if not found.
+ */
+ public @Nullable ParcelFileDescriptor getOpenedRevocableFileDescriptorForWrite(
+ @NonNull String packageName,
+ @NonNull AppSearchBlobHandle blobHandle) {
+ synchronized (mLock) {
+ Map<AppSearchBlobHandle, AppSearchRevocableFileDescriptor> rfdsForPackage =
+ mSentRevocableFileDescriptorsForWriteLocked.get(packageName);
+ if (rfdsForPackage == null) {
+ return null;
+ }
+ AppSearchRevocableFileDescriptor revocableFileDescriptor =
+ rfdsForPackage.get(blobHandle);
+ if (revocableFileDescriptor == null) {
+ return null;
+ }
+ // The revocableFileDescriptor should never be revoked, otherwise it should be removed
+ // from the map.
+ return revocableFileDescriptor.getRevocableFileDescriptor();
+ }
+ }
/**
* Revokes all revocable file descriptors previously issued by the store.
@@ -58,7 +154,16 @@
*
* @throws IOException If an I/O error occurs while revoking file descriptors.
*/
- void revokeAll() throws IOException;
+ public void revokeAll() throws IOException {
+ synchronized (mLock) {
+ Set<String> packageNames =
+ new ArraySet<>(mSentRevocableFileDescriptorsForReadLocked.keySet());
+ packageNames.addAll(mSentRevocableFileDescriptorsForWriteLocked.keySet());
+ for (String packageName : packageNames) {
+ revokeForPackage(packageName);
+ }
+ }
+ }
/**
* Revokes all revocable file descriptors for a specified package.
@@ -67,8 +172,184 @@
* @param packageName The package name whose file descriptors should be revoked.
* @throws IOException If an I/O error occurs while revoking file descriptors.
*/
- void revokeForPackage(@NonNull String packageName) throws IOException;
+ public void revokeForPackage(@NonNull String packageName) throws IOException {
+ synchronized (mLock) {
+ List<AppSearchRevocableFileDescriptor> rfdsForRead =
+ mSentRevocableFileDescriptorsForReadLocked.remove(packageName);
+ if (rfdsForRead != null) {
+ for (int i = rfdsForRead.size() - 1; i >= 0; i--) {
+ rfdsForRead.get(i).revoke();
+ }
+ }
+
+ Map<AppSearchBlobHandle, AppSearchRevocableFileDescriptor> rfdsForWrite =
+ mSentRevocableFileDescriptorsForWriteLocked.remove(packageName);
+ if (rfdsForWrite != null) {
+ for (AppSearchRevocableFileDescriptor rfdForWrite : rfdsForWrite.values()) {
+ rfdForWrite.revoke();
+ }
+ }
+ }
+ }
+
+ /**
+ * Revokes the revocable file descriptors for write associated with the given
+ * {@link AppSearchBlobHandle}.
+ *
+ * <p> Once a blob is sealed, we should call this method to revoke the sent file descriptor for
+ * write. Otherwise, the user could keep writing to the committed file.
+ *
+ * @param packageName The package name whose file descriptors should be revoked.
+ * @param blobHandle The blob handle associated with the file descriptors.
+ * @throws IOException If an I/O error occurs while revoking file descriptors.
+ */
+ public void revokeFdForWrite(@NonNull String packageName,
+ @NonNull AppSearchBlobHandle blobHandle) throws IOException {
+ synchronized (mLock) {
+ Map<AppSearchBlobHandle, AppSearchRevocableFileDescriptor> rfdsForWrite =
+ mSentRevocableFileDescriptorsForWriteLocked.remove(packageName);
+ if (rfdsForWrite == null) {
+ return;
+ }
+ AppSearchRevocableFileDescriptor revocableFileDescriptor =
+ rfdsForWrite.remove(blobHandle);
+ if (revocableFileDescriptor == null) {
+ return;
+ }
+ revocableFileDescriptor.revoke();
+ if (rfdsForWrite.isEmpty()) {
+ mSentRevocableFileDescriptorsForWriteLocked.remove(packageName);
+ }
+ }
+ }
/** Checks if the specified package has reached its blob storage limit. */
- void checkBlobStoreLimit(@NonNull String packageName) throws AppSearchException;
+ public void checkBlobStoreLimit(@NonNull String packageName) throws AppSearchException {
+ synchronized (mLock) {
+ int totalOpenFdSize = 0;
+ List<AppSearchRevocableFileDescriptor> rfdsForRead =
+ mSentRevocableFileDescriptorsForReadLocked.get(packageName);
+ if (rfdsForRead != null) {
+ totalOpenFdSize += rfdsForRead.size();
+ }
+ Map<AppSearchBlobHandle, AppSearchRevocableFileDescriptor> rfdsForWrite =
+ mSentRevocableFileDescriptorsForWriteLocked.get(packageName);
+ if (rfdsForWrite != null) {
+ totalOpenFdSize += rfdsForWrite.size();
+ }
+ if (totalOpenFdSize >= mConfig.getMaxOpenBlobCount()) {
+ throw new AppSearchException(AppSearchResult.RESULT_OUT_OF_SPACE,
+ "Package \"" + packageName + "\" exceeded limit of "
+ + mConfig.getMaxOpenBlobCount()
+ + " opened file descriptors. Some file descriptors "
+ + "must be closed to open additional ones.");
+ }
+ }
+ }
+
+ /**
+ * Sets a close listener to the revocable file descriptor for write.
+ *
+ * <p>The listener will be invoked when the file descriptor is closed.
+ *
+ * @param packageName The package name associated with the file descriptor.
+ * @param blobHandle The blob handle associated with the file descriptor. It cannot be null if
+ * the mode is READ_WRITE.
+ * @param revocableFileDescriptor The revocable file descriptor to set the listener to.
+ */
+ private void setCloseListenerToFd(
+ @NonNull String packageName,
+ @Nullable AppSearchBlobHandle blobHandle,
+ @NonNull AppSearchRevocableFileDescriptor revocableFileDescriptor) {
+ ParcelFileDescriptor.OnCloseListener closeListener;
+ switch (revocableFileDescriptor.getMode()) {
+ case ParcelFileDescriptor.MODE_READ_ONLY:
+ closeListener = e -> {
+ synchronized (mLock) {
+ List<AppSearchRevocableFileDescriptor> fdsForPackage =
+ mSentRevocableFileDescriptorsForReadLocked.get(packageName);
+ if (fdsForPackage != null) {
+ fdsForPackage.remove(revocableFileDescriptor);
+ if (fdsForPackage.isEmpty()) {
+ mSentRevocableFileDescriptorsForReadLocked.remove(packageName);
+ }
+ }
+ }
+ };
+ break;
+ case ParcelFileDescriptor.MODE_READ_WRITE:
+ Preconditions.checkNotNull(blobHandle);
+ closeListener = e -> {
+ synchronized (mLock) {
+ Map<AppSearchBlobHandle, AppSearchRevocableFileDescriptor>
+ rfdsForPackage = mSentRevocableFileDescriptorsForWriteLocked
+ .get(packageName);
+ if (rfdsForPackage != null) {
+ AppSearchRevocableFileDescriptor rfd =
+ rfdsForPackage.remove(blobHandle);
+ if (rfd != null) {
+ try {
+ rfd.revoke();
+ } catch (IOException ioException) {
+ // ignore, the sent RevocableFileDescriptor should already
+ // be closed.
+ }
+ }
+ if (rfdsForPackage.isEmpty()) {
+ mSentRevocableFileDescriptorsForWriteLocked.remove(packageName);
+ }
+ }
+ }
+ };
+ break;
+ default:
+ throw new UnsupportedOperationException(
+ "Cannot support the AppSearchRevocableFileDescriptor mode: "
+ + revocableFileDescriptor.getMode());
+ }
+ revocableFileDescriptor.setOnCloseListener(closeListener);
+ }
+
+ /**
+ * Adds a revocable file descriptor to the sent revocable file descriptor map.
+ *
+ * @param packageName The package name associated with the file descriptor.
+ * @param blobHandle The blob handle associated with the file descriptor. It cannot be null if
+ * the mode is READ_WRITE.
+ * @param revocableFileDescriptor The revocable file descriptor to add.
+ */
+ private void addToSentRevocableFileDescriptorMap(
+ @NonNull String packageName,
+ @Nullable AppSearchBlobHandle blobHandle,
+ @NonNull AppSearchRevocableFileDescriptor revocableFileDescriptor) {
+ synchronized (mLock) {
+ switch (revocableFileDescriptor.getMode()) {
+ case ParcelFileDescriptor.MODE_READ_ONLY:
+ List<AppSearchRevocableFileDescriptor> rfdListForPackage =
+ mSentRevocableFileDescriptorsForReadLocked.get(packageName);
+ if (rfdListForPackage == null) {
+ rfdListForPackage = new ArrayList<>();
+ mSentRevocableFileDescriptorsForReadLocked.put(packageName,
+ rfdListForPackage);
+ }
+ rfdListForPackage.add(revocableFileDescriptor);
+ break;
+ case ParcelFileDescriptor.MODE_READ_WRITE:
+ Preconditions.checkNotNull(blobHandle);
+ Map<AppSearchBlobHandle, AppSearchRevocableFileDescriptor> rfdMapForPackage =
+ mSentRevocableFileDescriptorsForWriteLocked.get(packageName);
+ if (rfdMapForPackage == null) {
+ rfdMapForPackage = new ArrayMap<>();
+ mSentRevocableFileDescriptorsForWriteLocked.put(packageName,
+ rfdMapForPackage);
+ }
+ rfdMapForPackage.put(blobHandle, revocableFileDescriptor);
+ break;
+ default:
+ throw new UnsupportedOperationException(
+ "Cannot support the AppSearchRevocableFileDescriptor mode: "
+ + revocableFileDescriptor.getMode());
+ }
+ }
+ }
}
diff --git a/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/app/AppSearchSessionBlobCtsTestBase.java b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/app/AppSearchSessionBlobCtsTestBase.java
index fd4cb7a..17621bf 100644
--- a/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/app/AppSearchSessionBlobCtsTestBase.java
+++ b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/app/AppSearchSessionBlobCtsTestBase.java
@@ -62,6 +62,7 @@
import org.junit.Test;
import org.junit.rules.RuleChain;
+import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
@@ -173,6 +174,38 @@
@Test
@RequiresFlagsEnabled(Flags.FLAG_ENABLE_BLOB_STORE)
+ public void testWriteAfterCommit() throws Exception {
+ assumeTrue(mDb1.getFeatures().isFeatureSupported(Features.BLOB_STORAGE));
+
+ OpenBlobForWriteResponse writeResponse =
+ mDb1.openBlobForWriteAsync(ImmutableSet.of(mHandle1)).get();
+ AppSearchBatchResult<AppSearchBlobHandle, ParcelFileDescriptor> writeResult =
+ writeResponse.getResult();
+ assertTrue(writeResult.isSuccess());
+
+ // Write data without close the pfd for write
+ ParcelFileDescriptor writePfd = writeResult.getSuccesses().get(mHandle1);
+ try (FileOutputStream outputStream = new FileOutputStream(writePfd.getFileDescriptor())) {
+ outputStream.write(mData1);
+ outputStream.flush();
+ }
+
+ // Commit the blob will revoke the pfd for write.
+ assertTrue(mDb1.commitBlobAsync(ImmutableSet.of(mHandle1)).get().getResult()
+ .isSuccess());
+
+ // Cannot keep writing to the blob after commit.
+ assertThrows(IOException.class, () -> {
+ try (FileOutputStream outputStream =
+ new FileOutputStream(writePfd.getFileDescriptor())) {
+ outputStream.write(mData1);
+ outputStream.flush();
+ }
+ });
+ }
+
+ @Test
+ @RequiresFlagsEnabled(Flags.FLAG_ENABLE_BLOB_STORE)
public void testRemovePendingBlob() throws Exception {
assumeTrue(mDb1.getFeatures().isFeatureSupported(Features.BLOB_STORAGE));
diff --git a/benchmark/benchmark-common/api/current.txt b/benchmark/benchmark-common/api/current.txt
index f1f1bfe..44c9777 100644
--- a/benchmark/benchmark-common/api/current.txt
+++ b/benchmark/benchmark-common/api/current.txt
@@ -2,20 +2,13 @@
package androidx.benchmark {
public final class BenchmarkState {
- ctor @SuppressCompatibility @androidx.benchmark.ExperimentalBenchmarkStateApi public BenchmarkState(optional Integer? warmupCount, optional Integer? repeatCount);
- method @SuppressCompatibility @androidx.benchmark.ExperimentalBenchmarkStateApi public java.util.List<java.lang.Double> getMeasurementTimeNs();
method public boolean keepRunning();
method public void pauseTiming();
- method @SuppressCompatibility @androidx.benchmark.BenchmarkState.Companion.ExperimentalExternalReport public static void reportData(String className, String testName, @IntRange(from=0L) long totalRunTimeNs, java.util.List<java.lang.Long> dataNs, @IntRange(from=0L) int warmupIterations, @IntRange(from=0L) long thermalThrottleSleepSeconds, @IntRange(from=1L) int repeatIterations);
method public void resumeTiming();
field public static final androidx.benchmark.BenchmarkState.Companion Companion;
}
public static final class BenchmarkState.Companion {
- method @SuppressCompatibility @androidx.benchmark.BenchmarkState.Companion.ExperimentalExternalReport public void reportData(String className, String testName, @IntRange(from=0L) long totalRunTimeNs, java.util.List<java.lang.Long> dataNs, @IntRange(from=0L) int warmupIterations, @IntRange(from=0L) long thermalThrottleSleepSeconds, @IntRange(from=1L) int repeatIterations);
- }
-
- @SuppressCompatibility @kotlin.RequiresOptIn @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.BINARY) @kotlin.annotation.Target(allowedTargets=kotlin.annotation.AnnotationTarget.FUNCTION) public static @interface BenchmarkState.Companion.ExperimentalExternalReport {
}
@SuppressCompatibility @androidx.benchmark.ExperimentalBlackHoleApi public final class BlackHole {
@@ -61,15 +54,19 @@
@SuppressCompatibility @androidx.benchmark.ExperimentalBenchmarkConfigApi public final class MicrobenchmarkConfig {
ctor public MicrobenchmarkConfig();
- ctor public MicrobenchmarkConfig(optional java.util.List<? extends androidx.benchmark.MetricCapture> metrics, optional boolean traceAppTagEnabled, optional boolean perfettoSdkTracingEnabled, optional androidx.benchmark.ProfilerConfig? profiler);
+ ctor public MicrobenchmarkConfig(optional java.util.List<? extends androidx.benchmark.MetricCapture> metrics, optional boolean traceAppTagEnabled, optional boolean perfettoSdkTracingEnabled, optional androidx.benchmark.ProfilerConfig? profiler, optional Integer? warmupCount, optional Integer? measurementCount);
+ method public Integer? getMeasurementCount();
method public java.util.List<androidx.benchmark.MetricCapture> getMetrics();
method public androidx.benchmark.ProfilerConfig? getProfiler();
+ method public Integer? getWarmupCount();
method public boolean isPerfettoSdkTracingEnabled();
method public boolean isTraceAppTagEnabled();
+ property public final Integer? measurementCount;
property public final java.util.List<androidx.benchmark.MetricCapture> metrics;
property public final boolean perfettoSdkTracingEnabled;
property public final androidx.benchmark.ProfilerConfig? profiler;
property public final boolean traceAppTagEnabled;
+ property public final Integer? warmupCount;
}
@SuppressCompatibility @androidx.benchmark.ExperimentalBenchmarkConfigApi public abstract sealed class ProfilerConfig {
diff --git a/benchmark/benchmark-common/api/restricted_current.txt b/benchmark/benchmark-common/api/restricted_current.txt
index 7271b61..f25ea8e 100644
--- a/benchmark/benchmark-common/api/restricted_current.txt
+++ b/benchmark/benchmark-common/api/restricted_current.txt
@@ -2,12 +2,9 @@
package androidx.benchmark {
public final class BenchmarkState {
- ctor @SuppressCompatibility @androidx.benchmark.ExperimentalBenchmarkStateApi public BenchmarkState(optional Integer? warmupCount, optional Integer? repeatCount);
- method @SuppressCompatibility @androidx.benchmark.ExperimentalBenchmarkStateApi public java.util.List<java.lang.Double> getMeasurementTimeNs();
method public boolean keepRunning();
method @kotlin.PublishedApi internal boolean keepRunningInternal();
method public void pauseTiming();
- method @SuppressCompatibility @androidx.benchmark.BenchmarkState.Companion.ExperimentalExternalReport public static void reportData(String className, String testName, @IntRange(from=0L) long totalRunTimeNs, java.util.List<java.lang.Long> dataNs, @IntRange(from=0L) int warmupIterations, @IntRange(from=0L) long thermalThrottleSleepSeconds, @IntRange(from=1L) int repeatIterations);
method public void resumeTiming();
property @kotlin.PublishedApi internal final int iterationsRemaining;
field public static final androidx.benchmark.BenchmarkState.Companion Companion;
@@ -15,10 +12,6 @@
}
public static final class BenchmarkState.Companion {
- method @SuppressCompatibility @androidx.benchmark.BenchmarkState.Companion.ExperimentalExternalReport public void reportData(String className, String testName, @IntRange(from=0L) long totalRunTimeNs, java.util.List<java.lang.Long> dataNs, @IntRange(from=0L) int warmupIterations, @IntRange(from=0L) long thermalThrottleSleepSeconds, @IntRange(from=1L) int repeatIterations);
- }
-
- @SuppressCompatibility @kotlin.RequiresOptIn @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.BINARY) @kotlin.annotation.Target(allowedTargets=kotlin.annotation.AnnotationTarget.FUNCTION) public static @interface BenchmarkState.Companion.ExperimentalExternalReport {
}
@SuppressCompatibility @androidx.benchmark.ExperimentalBlackHoleApi public final class BlackHole {
@@ -64,15 +57,19 @@
@SuppressCompatibility @androidx.benchmark.ExperimentalBenchmarkConfigApi public final class MicrobenchmarkConfig {
ctor public MicrobenchmarkConfig();
- ctor public MicrobenchmarkConfig(optional java.util.List<? extends androidx.benchmark.MetricCapture> metrics, optional boolean traceAppTagEnabled, optional boolean perfettoSdkTracingEnabled, optional androidx.benchmark.ProfilerConfig? profiler);
+ ctor public MicrobenchmarkConfig(optional java.util.List<? extends androidx.benchmark.MetricCapture> metrics, optional boolean traceAppTagEnabled, optional boolean perfettoSdkTracingEnabled, optional androidx.benchmark.ProfilerConfig? profiler, optional Integer? warmupCount, optional Integer? measurementCount);
+ method public Integer? getMeasurementCount();
method public java.util.List<androidx.benchmark.MetricCapture> getMetrics();
method public androidx.benchmark.ProfilerConfig? getProfiler();
+ method public Integer? getWarmupCount();
method public boolean isPerfettoSdkTracingEnabled();
method public boolean isTraceAppTagEnabled();
+ property public final Integer? measurementCount;
property public final java.util.List<androidx.benchmark.MetricCapture> metrics;
property public final boolean perfettoSdkTracingEnabled;
property public final androidx.benchmark.ProfilerConfig? profiler;
property public final boolean traceAppTagEnabled;
+ property public final Integer? warmupCount;
}
@SuppressCompatibility @androidx.benchmark.ExperimentalBenchmarkConfigApi public abstract sealed class ProfilerConfig {
diff --git a/benchmark/benchmark-common/build.gradle b/benchmark/benchmark-common/build.gradle
index d1537e3..e22a1e6 100644
--- a/benchmark/benchmark-common/build.gradle
+++ b/benchmark/benchmark-common/build.gradle
@@ -85,6 +85,7 @@
implementation("androidx.test:monitor:1.6.1")
implementation(libs.wireRuntime)
implementation(libs.moshi)
+ implementation(libs.kotlinCoroutinesAndroid)
ksp(libs.moshiCodeGen)
androidTestImplementation(libs.testRules)
diff --git a/benchmark/benchmark-common/src/androidTest/java/androidx/benchmark/BenchmarkStateConfigTest.kt b/benchmark/benchmark-common/src/androidTest/java/androidx/benchmark/BenchmarkStateLegacyConfigTest.kt
similarity index 98%
rename from benchmark/benchmark-common/src/androidTest/java/androidx/benchmark/BenchmarkStateConfigTest.kt
rename to benchmark/benchmark-common/src/androidTest/java/androidx/benchmark/BenchmarkStateLegacyConfigTest.kt
index bc0ea41..0fa1ce6 100644
--- a/benchmark/benchmark-common/src/androidTest/java/androidx/benchmark/BenchmarkStateConfigTest.kt
+++ b/benchmark/benchmark-common/src/androidTest/java/androidx/benchmark/BenchmarkStateLegacyConfigTest.kt
@@ -27,7 +27,7 @@
@SmallTest
@RunWith(AndroidJUnit4::class)
-class BenchmarkStateConfigTest {
+class BenchmarkStateLegacyConfigTest {
private fun validateConfig(
config: MicrobenchmarkPhase.Config,
expectedWarmups: Int?,
@@ -36,7 +36,7 @@
expectedUsesProfiler: Boolean = false,
expectedProfilerIterations: Int = 0
) {
- val state = BenchmarkState(config)
+ val state = BenchmarkStateLegacy(config)
var count = 0
while (state.keepRunning()) {
// This spin loop works around an issue where nanoTime is only precise to 30us on some
diff --git a/benchmark/benchmark-common/src/androidTest/java/androidx/benchmark/BenchmarkStateTest.kt b/benchmark/benchmark-common/src/androidTest/java/androidx/benchmark/BenchmarkStateLegacyTest.kt
similarity index 85%
rename from benchmark/benchmark-common/src/androidTest/java/androidx/benchmark/BenchmarkStateTest.kt
rename to benchmark/benchmark-common/src/androidTest/java/androidx/benchmark/BenchmarkStateLegacyTest.kt
index fc41186..616b446 100644
--- a/benchmark/benchmark-common/src/androidTest/java/androidx/benchmark/BenchmarkStateTest.kt
+++ b/benchmark/benchmark-common/src/androidTest/java/androidx/benchmark/BenchmarkStateLegacyTest.kt
@@ -18,8 +18,6 @@
import android.Manifest
import androidx.annotation.RequiresApi
-import androidx.benchmark.BenchmarkState.Companion.ExperimentalExternalReport
-import androidx.benchmark.json.BenchmarkData
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.FlakyTest
import androidx.test.filters.LargeTest
@@ -39,7 +37,7 @@
@LargeTest
@RunWith(AndroidJUnit4::class)
-class BenchmarkStateTest {
+class BenchmarkStateLegacyTest {
private fun us2ns(ms: Long): Long = TimeUnit.MICROSECONDS.toNanos(ms)
@get:Rule
@@ -63,7 +61,7 @@
@Test
@FlakyTest(bugId = 187711141)
fun validateMetrics() {
- val state = BenchmarkState()
+ val state = BenchmarkStateLegacy()
while (state.keepRunning()) {
runAndSpin(durationUs = 300) {
// note, important here to not do too much work - this test may run on an
@@ -88,7 +86,7 @@
@Test
fun keepRunningMissingResume() {
- val state = BenchmarkState()
+ val state = BenchmarkStateLegacy()
assertEquals(true, state.keepRunning())
state.pauseTiming()
@@ -97,7 +95,7 @@
@Test
fun pauseCalledTwice() {
- val state = BenchmarkState()
+ val state = BenchmarkStateLegacy()
assertEquals(true, state.keepRunning())
state.pauseTiming()
@@ -114,7 +112,7 @@
)
// verify priority is only bumped during loop (NOTE: lower number means higher priority)
- val state = BenchmarkState()
+ val state = BenchmarkStateLegacy()
while (state.keepRunning()) {
val currentJitPriority = ThreadPriority.getJit()
assertTrue(
@@ -136,7 +134,7 @@
)
// verify priority is only bumped during loop (NOTE: lower number means higher priority)
- val state = BenchmarkState()
+ val state = BenchmarkStateLegacy()
while (state.keepRunning()) {
val currentPriority = ThreadPriority.get()
assertTrue(
@@ -150,7 +148,7 @@
private fun iterationCheck(simplifiedTimingOnlyMode: Boolean) {
// disable thermal throttle checks, since it can cause loops to be thrown out
// note that this bypasses allocation count
- val state = BenchmarkState(simplifiedTimingOnlyMode = simplifiedTimingOnlyMode)
+ val state = BenchmarkStateLegacy(simplifiedTimingOnlyMode = simplifiedTimingOnlyMode)
var total = 0
while (state.keepRunning()) {
total++
@@ -161,7 +159,7 @@
// '50' assumes we're not running in a special mode
// that affects repeat count (dry run)
val expectedRepeatCount =
- 50 + if (simplifiedTimingOnlyMode) 0 else BenchmarkState.REPEAT_COUNT_ALLOCATION
+ 50 + if (simplifiedTimingOnlyMode) 0 else BenchmarkStateLegacy.REPEAT_COUNT_ALLOCATION
val expectedCount =
testResult.warmupIterations!! +
testResult.repeatIterations!! * expectedRepeatCount +
@@ -198,7 +196,7 @@
@Suppress("DEPRECATION")
fun bundle() {
val bundle =
- BenchmarkState()
+ BenchmarkStateLegacy()
.apply {
while (keepRunning()) {
// nothing, we're ignoring numbers
@@ -236,7 +234,7 @@
fun notStarted() {
val initialPriority = ThreadPriority.get()
try {
- BenchmarkState().peekTestResult().metrics["timeNs"]!!.median
+ BenchmarkStateLegacy().peekTestResult().metrics["timeNs"]!!.median
fail("expected exception")
} catch (e: IllegalStateException) {
assertEquals(initialPriority, ThreadPriority.get())
@@ -248,7 +246,7 @@
fun notFinished() {
val initialPriority = ThreadPriority.get()
try {
- BenchmarkState().run {
+ BenchmarkStateLegacy().run {
keepRunning()
peekTestResult().metrics["timeNs"]!!.median
}
@@ -260,48 +258,25 @@
}
}
- @OptIn(ExperimentalExternalReport::class)
- @Test
- fun reportResult() {
- BenchmarkState.reportData(
- className = "className",
- testName = "testName",
- totalRunTimeNs = 900000000,
- dataNs = listOf(100L, 200L, 300L),
- warmupIterations = 1,
- thermalThrottleSleepSeconds = 0,
- repeatIterations = 1
- )
- val expectedReport =
- BenchmarkData.TestResult(
- className = "className",
- name = "testName",
- totalRunTimeNs = 900000000,
- metrics = listOf(MetricResult(name = "timeNs", data = listOf(100.0, 200.0, 300.0))),
- repeatIterations = 1,
- thermalThrottleSleepSeconds = 0,
- warmupIterations = 1,
- profilerOutputs = null,
- )
- assertEquals(expectedReport, ResultWriter.reports.last())
- }
-
@RequiresApi(22) // 21 profiler has flaky platform crashes, see b/353716346
private fun validateProfilerUsage(simplifiedTimingOnlyMode: Boolean?) {
val config = MicrobenchmarkConfig(profiler = ProfilerConfig.StackSamplingLegacy())
- val benchmarkState =
+ val benchmarkStateLegacy =
if (simplifiedTimingOnlyMode != null) {
- BenchmarkState(config = config, simplifiedTimingOnlyMode = simplifiedTimingOnlyMode)
+ BenchmarkStateLegacy(
+ config = config,
+ simplifiedTimingOnlyMode = simplifiedTimingOnlyMode
+ )
} else {
- BenchmarkState(config)
+ BenchmarkStateLegacy(config)
}
// count iters with profiler enabled vs disabled
var profilerDisabledIterations = 0
var profilerEnabledIterations = 0
var profilerAllocationIterations = 0
- while (benchmarkState.keepRunning()) {
+ while (benchmarkStateLegacy.keepRunning()) {
if (StackSamplingLegacy.isRunning) {
profilerEnabledIterations++
} else {
@@ -345,10 +320,10 @@
@Test
fun experimentalConstructor() {
// min values that don't fail
- BenchmarkState(warmupCount = null, measurementCount = 1)
+ BenchmarkStateLegacy(warmupCount = null, measurementCount = 1)
// test failures
- assertFailsWith<IllegalArgumentException> { BenchmarkState(warmupCount = 0) }
- assertFailsWith<IllegalArgumentException> { BenchmarkState(measurementCount = 0) }
+ assertFailsWith<IllegalArgumentException> { BenchmarkStateLegacy(warmupCount = 0) }
+ assertFailsWith<IllegalArgumentException> { BenchmarkStateLegacy(measurementCount = 0) }
}
}
diff --git a/benchmark/benchmark-common/src/androidTest/java/androidx/benchmark/BenchmarkStateConfigTest.kt b/benchmark/benchmark-common/src/androidTest/java/androidx/benchmark/MicrobenchmarkPhaseConfigTest.kt
similarity index 74%
copy from benchmark/benchmark-common/src/androidTest/java/androidx/benchmark/BenchmarkStateConfigTest.kt
copy to benchmark/benchmark-common/src/androidTest/java/androidx/benchmark/MicrobenchmarkPhaseConfigTest.kt
index bc0ea41..5fea55f 100644
--- a/benchmark/benchmark-common/src/androidTest/java/androidx/benchmark/BenchmarkStateConfigTest.kt
+++ b/benchmark/benchmark-common/src/androidTest/java/androidx/benchmark/MicrobenchmarkPhaseConfigTest.kt
@@ -22,12 +22,13 @@
import androidx.test.filters.SmallTest
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
+import kotlinx.coroutines.runBlocking
import org.junit.Test
import org.junit.runner.RunWith
@SmallTest
@RunWith(AndroidJUnit4::class)
-class BenchmarkStateConfigTest {
+class MicrobenchmarkPhaseConfigTest {
private fun validateConfig(
config: MicrobenchmarkPhase.Config,
expectedWarmups: Int?,
@@ -36,18 +37,39 @@
expectedUsesProfiler: Boolean = false,
expectedProfilerIterations: Int = 0
) {
- val state = BenchmarkState(config)
var count = 0
- while (state.keepRunning()) {
- // This spin loop works around an issue where nanoTime is only precise to 30us on some
- // devices. This was reproduced on api 17 and emulators api 33. (b/331226761)
- val start = System.nanoTime()
- @Suppress("ControlFlowWithEmptyBody") while (System.nanoTime() == start) {}
- count++
+ val output = runBlocking {
+ val microbenchmark =
+ Microbenchmark(
+ TestDefinition(
+ "MicrobenchmarkPhaseConfigTest",
+ "MicrobenchmarkPhaseConfigTest",
+ "methodName"
+ ),
+ phaseConfig = config,
+ yieldThreadPeriodically = false,
+ scopeFactory = { state: MicrobenchmarkRunningState ->
+ MicrobenchmarkScope(state)
+ },
+ loopedMeasurementBlock = { _, loops ->
+ repeat(loops) {
+ // This spin loop works around an issue where nanoTime is only precise
+ // to 30us on some
+ // devices. This was reproduced on api 17 and emulators api 33.
+ // (b/331226761)
+ val start = System.nanoTime()
+ @Suppress("ControlFlowWithEmptyBody")
+ while (System.nanoTime() == start) {}
+ count++
+ }
+ }
+ )
+ microbenchmark.executePhases()
+ microbenchmark.output(null)
}
val calculatedIterations =
- state.warmupRepeats + expectedMeasurements * state.iterationsPerRepeat
+ output.warmupIterations + expectedMeasurements * output.repeatIterations
val usesProfiler = config.generatePhases().any { it.profiler != null }
@@ -63,9 +85,11 @@
assertEquals(expectedIterations, count)
}
if (expectedWarmups != null) {
- assertEquals(expectedWarmups, state.warmupRepeats)
+ assertEquals(expectedWarmups, output.warmupIterations)
}
- assertNotEquals(0.0, state.getMinTimeNanos()) // just verify some value is set
+
+ val minNanos = output.metricResults.single { it.name == "timeNs" }.min
+ assertNotEquals(0.0, minNanos) // just verify some value is set
}
@Test
@@ -141,6 +165,24 @@
expectedIterations = null, // iterations are dynamic
)
+ @Test
+ fun trivial() =
+ validateConfig(
+ MicrobenchmarkPhase.Config(
+ dryRunMode = false,
+ startupMode = false,
+ simplifiedTimingOnlyMode = false,
+ profiler = null,
+ profilerPerfCompareMode = false,
+ warmupCount = 3,
+ measurementCount = 2,
+ metrics = arrayOf(TimeCapture()),
+ ),
+ expectedWarmups = 3,
+ expectedMeasurements = 7, // includes allocations
+ expectedIterations = null, // iterations are dynamic
+ )
+
@SdkSuppress(minSdkVersion = 22) // See b/300658578
@Test
fun profilerMethodTracing() =
diff --git a/benchmark/benchmark-common/src/main/java/androidx/benchmark/Arguments.kt b/benchmark/benchmark-common/src/main/java/androidx/benchmark/Arguments.kt
index c352a21..ad3e72c 100644
--- a/benchmark/benchmark-common/src/main/java/androidx/benchmark/Arguments.kt
+++ b/benchmark/benchmark-common/src/main/java/androidx/benchmark/Arguments.kt
@@ -28,8 +28,9 @@
@get:RestrictTo(RestrictTo.Scope.LIBRARY)
@set:RestrictTo(RestrictTo.Scope.LIBRARY)
@VisibleForTesting
-public var argumentSource: Bundle? = null
+var argumentSource: Bundle? = null
+@Suppress("NullableBooleanElvis") // suggestion makes boolean argument defaults less clear
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
object Arguments {
// public properties are shared by micro + macro benchmarks
diff --git a/benchmark/benchmark-common/src/main/java/androidx/benchmark/BenchmarkState.kt b/benchmark/benchmark-common/src/main/java/androidx/benchmark/BenchmarkState.kt
index 3c0e979..8114e29 100644
--- a/benchmark/benchmark-common/src/main/java/androidx/benchmark/BenchmarkState.kt
+++ b/benchmark/benchmark-common/src/main/java/androidx/benchmark/BenchmarkState.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2016 The Android Open Source Project
+ * 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.
@@ -16,25 +16,93 @@
package androidx.benchmark
-import android.annotation.SuppressLint
-import android.os.Bundle
-import android.os.Looper
-import android.util.Log
-import androidx.annotation.IntRange
import androidx.annotation.RestrictTo
-import androidx.annotation.VisibleForTesting
-import androidx.benchmark.Errors.PREFIX
-import androidx.benchmark.InstrumentationResults.instrumentationReport
-import androidx.benchmark.InstrumentationResults.reportBundle
-import androidx.benchmark.json.BenchmarkData
import java.util.concurrent.TimeUnit
+import kotlin.coroutines.Continuation
+import kotlin.coroutines.CoroutineContext
+import kotlin.coroutines.EmptyCoroutineContext
+import kotlin.coroutines.intrinsics.COROUTINE_SUSPENDED
+import kotlin.coroutines.intrinsics.createCoroutineUnintercepted
+import kotlin.coroutines.intrinsics.suspendCoroutineUninterceptedOrReturn
+import kotlin.coroutines.resume
/**
- * Control object for benchmarking in the code in Java.
+ * This function is used to allow BenchmarkState to provide its non-suspending keepRunning(), but
+ * underneath incrementally progress the underlying coroutine microbenchmark API as needed.
+ *
+ * It is optimized to allow the underlying coroutine API to be incrementally upgraded without any
+ * changes to BenchmarkState.
+ *
+ * This is modeled after the suspending iterator {} sequence builder in kotlin, but optimized for:
+ * - minimal allocation
+ * - keeping yields:resumes at 1:1 (for simplicity)
+ * - minimal virtual functions
+ *
+ * @see kotlin.sequences.iterator
+ */
+private fun createSuspendedLoop(
+ block: suspend SuspendedLoopTrigger.() -> Unit
+): SuspendedLoopTrigger {
+ val suspendedLoopTrigger = SuspendedLoopTrigger()
+ suspendedLoopTrigger.nextStep =
+ block.createCoroutineUnintercepted(
+ receiver = suspendedLoopTrigger,
+ completion = suspendedLoopTrigger
+ )
+ return suspendedLoopTrigger
+}
+
+/**
+ * SuspendedLoopTrigger functions as the bridge between the new coroutine measureRepeated
+ * implementation and the (soon to be) legacy Java API.
+ *
+ * It allows the vast majority of the benchmark library to be written in coroutines (with very
+ * deliberate suspend calls, generally just for yielding the main thread) and still function within
+ * a runBlocking block inside of `benchmarkState.keepRunning()`
+ *
+ * Eventually, the BenchmarkState api will be deprecated in favor of a Java-friendly variant of
+ * measureRepeated, but this code will remain (ideally without significant change) to support the
+ * BenchmarkState API in the long term.
+ */
+private class SuspendedLoopTrigger : Continuation<Unit> {
+ @JvmField var nextStep: Continuation<Unit>? = null
+ private var next: Int = -1
+ private var done: Boolean = false
+
+ /**
+ * Schedule the loop manager Yields a value of loops to be run by the user of the
+ * SuspendedLoopTrigger.
+ */
+ suspend fun awaitLoops(loopCount: Int) {
+ next = loopCount
+ suspendCoroutineUninterceptedOrReturn { c ->
+ nextStep = c
+ COROUTINE_SUSPENDED
+ }
+ }
+
+ /** Gets the number of loops to run before calling [getNextLoopCount] again */
+ fun getNextLoopCount(): Int {
+ if (done) return 0
+ nextStep!!.resume(Unit)
+ return next
+ }
+
+ override val context: CoroutineContext
+ get() = EmptyCoroutineContext
+
+ override fun resumeWith(result: Result<Unit>) {
+ result.getOrThrow() // just rethrow exception if it is there
+ done = true
+ }
+}
+
+/**
+ * Control object for microbenchmarking in Java.
*
* Query a state object with [androidx.benchmark.junit4.BenchmarkRule.getState], and use it to
- * measure a block of Java with [BenchmarkState.keepRunning]:
- * ```java
+ * measure a block of Java with [BenchmarkStateLegacy.keepRunning]:
+ * ```
* @Rule
* public BenchmarkRule benchmarkRule = new BenchmarkRule();
*
@@ -50,589 +118,72 @@
* }
* ```
*
- * @see androidx.benchmark.junit4.BenchmarkRule.getState()
+ * Note that BenchmarkState does not give access to Perfetto traces.
*/
-class BenchmarkState internal constructor(phaseConfig: MicrobenchmarkPhase.Config) {
-
- /**
- * Create a BenchmarkState for custom measurement behavior.
- *
- * @param warmupCount Number of non-measured warmup iterations to perform, leave null to
- * determine automatically
- * @param repeatCount Number of measurements to perform, leave null for default behavior
- */
- @ExperimentalBenchmarkStateApi
- constructor(
- @SuppressWarnings("AutoBoxing") // allocations for tests not relevant, not in critical path
- warmupCount: Int? = null,
- @SuppressWarnings("AutoBoxing") // allocations for tests not relevant, not in critical path
- repeatCount: Int? = null
- ) : this(
- warmupCount = warmupCount,
- measurementCount = repeatCount,
- simplifiedTimingOnlyMode = false
- )
-
- /** Constructor used for standard uses of BenchmarkState, e.g. in BenchmarkRule */
+class BenchmarkState
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+constructor(testDefinition: TestDefinition, private val config: MicrobenchmarkConfig) {
+ // Secondary explicit constructor allows for internal usage without experimental config opt in
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
- constructor(
- config: MicrobenchmarkConfig? = null
- ) : this(warmupCount = null, simplifiedTimingOnlyMode = false, config = config)
+ constructor(testDefinition: TestDefinition) : this(testDefinition, MicrobenchmarkConfig())
- internal constructor(
- warmupCount: Int? = null,
- measurementCount: Int? = null,
- simplifiedTimingOnlyMode: Boolean = false,
- config: MicrobenchmarkConfig? = null
- ) : this(
- MicrobenchmarkPhase.Config(
- dryRunMode = Arguments.dryRunMode,
- startupMode = Arguments.startupMode,
- profiler = config?.profiler?.profiler ?: Arguments.profiler,
- profilerPerfCompareMode = Arguments.profilerPerfCompareEnable,
- warmupCount = warmupCount,
- measurementCount = Arguments.iterations ?: measurementCount,
- simplifiedTimingOnlyMode = simplifiedTimingOnlyMode,
- metrics =
- config?.metrics?.toTypedArray()
- ?: if (Arguments.cpuEventCounterMask != 0) {
- arrayOf(
- TimeCapture(),
- CpuEventCounterCapture(
- MicrobenchmarkPhase.cpuEventCounter,
- Arguments.cpuEventCounterMask
- )
- )
- } else {
- arrayOf(TimeCapture())
- }
+ @JvmField
+ @PublishedApi // Previously used by [BenchmarkState.keepRunningInline()]
+ internal var iterationsRemaining = 0
+
+ /** Ideally we'd call into the top level function, but it's non-suspending */
+ private var internalIter = createSuspendedLoop {
+ // Theoretically we'd ideally call into the top level measureRepeated function, but
+ // that function isn't suspending. Making it suspend would allow this call to perform
+ // tracing, but would significantly complicate the thread management of outer layers (e.g.
+ // carefully scheduling where trace capture start/end happens). As this is compat code, we
+ // don't bother.
+ measureRepeatedImplNoTracing(
+ testDefinition,
+ config = config,
+ loopedMeasurementBlock = { microbenchScope, loops ->
+ scope = microbenchScope
+ awaitLoops(loops)
+ }
)
- )
-
- /**
- * Set this to true to run a simplified timing loop - no allocation tracking, and no global
- * state set/reset (such as thread priorities)
- *
- * This var is used in one of two cases, either set to true by [ThrottleDetector.measureWorkNs]
- * when device performance testing for thermal throttling in between benchmarks, or in
- * correctness tests of this library.
- *
- * When set to true, indicates that this BenchmarkState **should not**:
- * - touch thread priorities
- * - perform allocation counting (only timing results matter)
- * - call [ThrottleDetector], since it would infinitely recurse
- */
- private val simplifiedTimingOnlyMode = phaseConfig.simplifiedTimingOnlyMode
-
- @get:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
- @set:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
- var traceUniqueName: String = "benchmark"
-
- internal var warmupRepeats = 0 // number of warmup repeats that occurred
-
- /**
- * Decreasing iteration count used when running a multi-iteration measurement phase Used to
- * determine when a main measurement stage finishes.
- */
- @JvmField // Used by [BenchmarkState.keepRunningInline()]
- @PublishedApi
- internal var iterationsRemaining: Int = -1
-
- @Suppress("NOTHING_TO_INLINE")
- @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
- inline fun getIterationsRemaining() = iterationsRemaining
-
- /**
- * Number of iterations in a repeat.
- *
- * This value is defined in the json, but is written as maximum iterationsPerRepeat across
- * phases, since nowadays there can be an arbitrary number of phases.
- *
- * This is fully compatible for now since e.g. timing and allocation measurement use the same
- * value, but we should consider tracking and reporting this differently in the json if this
- * changes.
- */
- @VisibleForTesting internal var iterationsPerRepeat = 1
-
- private val warmupManager = phaseConfig.warmupManager
-
- private var paused = false
-
- /** The total duration of sleep due to thermal throttling. */
- private var thermalThrottleSleepSeconds: Long = 0
- private var totalRunTimeStartNs: Long = 0 // System.nanoTime() at start of benchmark.
- private var totalRunTimeNs: Long = 0 // Total run time of a benchmark.
-
- private var warmupEstimatedIterationTimeNs: Long = -1L
-
- private val metricResults = mutableListOf<MetricResult>()
- private var profilerResult: Profiler.ResultFile? = null
- private val phases = phaseConfig.generatePhases()
-
- // tracking current phase state
- private var phaseIndex = -1
- private var currentPhase: MicrobenchmarkPhase = phases[0]
- private var currentMetrics: MetricsContainer = phases[0].metricsContainer
- private var currentMeasurement = 0
- private var currentLoopsPerMeasurement = 0
-
- @SuppressLint("MethodNameUnits")
- @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
- fun getMinTimeNanos(): Double {
- checkFinished()
- return metricResults.first { it.name == "timeNs" }.min
}
- private fun checkFinished() {
- check(phaseIndex >= 0) { "Attempting to interact with a benchmark that wasn't started!" }
- check(phaseIndex >= phases.size) {
- "The benchmark hasn't finished! In Java, use " +
- "while(BenchmarkState.keepRunning()) to ensure keepRunning() returns " +
- "false before ending your test. In Kotlin, just use " +
- "benchmarkRule.measureRepeated {} to avoid the problem."
- }
- }
+ @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) @JvmField var scope: MicrobenchmarkScope? = null
- /**
- * Stops the benchmark timer.
- *
- * This method can be called only when the timer is running.
- *
- * ```
- * @Test
- * public void bitmapProcessing() {
- * final BenchmarkState state = mBenchmarkRule.getState();
- * while (state.keepRunning()) {
- * state.pauseTiming();
- * // disable timing while constructing test input
- * Bitmap input = constructTestBitmap();
- * state.resumeTiming();
- *
- * processBitmap(input);
- * }
- * }
- * ```
- *
- * @throws [IllegalStateException] if the benchmark is already paused.
- * @see resumeTiming
- */
fun pauseTiming() {
- check(!paused) { "Unable to pause the benchmark. The benchmark has already paused." }
- currentMetrics.capturePaused()
- paused = true
+ scope!!.pauseMeasurement()
}
- /**
- * Resumes the benchmark timer.
- *
- * This method can be called only when the timer is stopped.
- *
- * ```
- * @Test
- * public void bitmapProcessing() {
- * final BenchmarkState state = mBenchmarkRule.getState();
- * while (state.keepRunning()) {
- * state.pauseTiming();
- * // disable timing while constructing test input
- * Bitmap input = constructTestBitmap();
- * state.resumeTiming();
- *
- * processBitmap(input);
- * }
- * }
- * ```
- *
- * @throws [IllegalStateException] if the benchmark is already running.
- * @see pauseTiming
- */
fun resumeTiming() {
- check(paused) { "Unable to resume the benchmark. The benchmark is already running." }
- currentMetrics.captureResumed()
- paused = false
+ scope!!.resumeMeasurement()
}
- private fun startNextPhase(): Boolean {
- check(phaseIndex < phases.size)
-
- if (phaseIndex >= 0) {
- currentPhase.profiler?.run { inMemoryTrace("profiler.stop()") { stop() } }
- InMemoryTracing.endSection() // end phase
- thermalThrottleSleepSeconds += currentPhase.thermalThrottleSleepSeconds
- if (currentPhase.loopMode.warmupManager == null) {
- // Save captured metrics except during warmup, where we intentionally discard
- metricResults.addAll(
- currentMetrics.captureFinished(maxIterations = currentLoopsPerMeasurement)
- )
- }
- }
- phaseIndex++
- if (phaseIndex == phases.size) {
- afterBenchmark()
- return false
- }
- currentPhase = phases[phaseIndex]
- currentMetrics = currentPhase.metricsContainer
- currentMeasurement = 0
-
- currentMetrics.captureInit()
- if (currentPhase.gcBeforePhase) {
- // Run GC to avoid memory pressure from previous run from affecting this one.
- // Note, we don't use System.gc() because it doesn't always have consistent behavior
- Runtime.getRuntime().gc()
- }
-
- currentLoopsPerMeasurement =
- currentPhase.loopMode.getIterations(warmupEstimatedIterationTimeNs)
-
- iterationsPerRepeat = iterationsPerRepeat.coerceAtLeast(currentLoopsPerMeasurement)
-
- InMemoryTracing.beginSection(currentPhase.label)
- val phaseProfilerResult =
- currentPhase.profiler?.run {
- val estimatedMethodTraceDurNs =
- warmupEstimatedIterationTimeNs * METHOD_TRACING_ESTIMATED_SLOWDOWN_FACTOR
- if (
- this == MethodTracing &&
- Looper.myLooper() == Looper.getMainLooper() &&
- estimatedMethodTraceDurNs > METHOD_TRACING_MAX_DURATION_NS &&
- Arguments.profilerSkipWhenDurationRisksAnr
- ) {
- val expectedDurSec = estimatedMethodTraceDurNs / 1_000_000_000.0
- InstrumentationResults.scheduleIdeWarningOnNextReport(
- """
- Skipping method trace of estimated duration $expectedDurSec sec to avoid ANR
-
- To disable this behavior, set instrumentation arg:
- androidx.benchmark.profiling.skipWhenDurationRisksAnr = false
- """
- .trimIndent()
- )
- null
- } else {
- inMemoryTrace("start profiling") { start(traceUniqueName) }
- }
- }
- if (phaseProfilerResult != null) {
- require(profilerResult == null) {
- "ProfileResult already set, only support one profiling phase"
- }
- profilerResult = phaseProfilerResult
- }
-
- // Warm up the metrics data structure to reduce the impact on the first measurement.
- currentMetrics.captureStart()
- currentMetrics.captureStop()
- currentMetrics.captureInit()
-
- currentMetrics.captureStart()
- return true
- }
-
- /** @return true if the benchmark should still keep running */
- private fun onMeasurementComplete(): Boolean {
- currentMetrics.captureStop()
- throwIfPaused()
- currentMeasurement++
-
- val tryStartNextPhase =
- currentPhase.loopMode.let {
- if (it.warmupManager != null) {
- // warmup phase
- currentMetrics.captureInit()
- // Note that warmup is based on repeat time, *not* the timeNs metric, since we
- // want
- // to account for paused time during warmup (paused work should stabilize too)
- val lastMeasuredWarmupValue = currentMetrics.peekSingleRepeatTime()
- if (it.warmupManager.onNextIteration(lastMeasuredWarmupValue)) {
- warmupEstimatedIterationTimeNs = lastMeasuredWarmupValue
- warmupRepeats = currentMeasurement
- true
- } else {
- false
- }
- } else {
- currentMeasurement == currentPhase.measurementCount
- }
- }
- return if (tryStartNextPhase) {
- if (currentPhase.tryEnd()) {
- startNextPhase()
- } else {
- // failed capture (due to thermal throttling), restart profiler and metrics
- currentPhase.profiler?.apply {
- stop()
- profilerResult = inMemoryTrace("start profiling") { start(traceUniqueName) }
- }
- currentMetrics.captureInit()
- currentMeasurement = 0
- true
- }
- } else {
- currentMetrics.captureStart()
- true
- }
- }
-
- /**
- * Inline fast-path function for inner benchmark loop.
- *
- * Kotlin users should use `BenchmarkRule.measureRepeated`
- *
- * This code path uses exclusively @JvmField/const members, so there are no method calls at all
- * in the inlined loop. On recent Android Platform versions, ART inlines these accessors anyway,
- * but we want to be sure it's as simple as possible.
- */
- @Suppress("NOTHING_TO_INLINE")
- @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
- inline fun keepRunningInline(): Boolean {
- if (iterationsRemaining > 1) {
- iterationsRemaining--
- return true
- }
- return keepRunningInternal()
- }
-
- /**
- * Returns true if the benchmark needs more samples - use this as the condition of a while loop.
- *
- * ```
- * while (state.keepRunning()) {
- * int[] dest = new int[src.length];
- * System.arraycopy(src, 0, dest, 0, src.length);
- * }
- * ```
- */
- fun keepRunning(): Boolean {
- if (iterationsRemaining > 1) {
- iterationsRemaining--
- return true
- }
- return keepRunningInternal()
- }
-
- /**
- * Reimplementation of Kotlin check, which also resets thread priority, since we don't want to
- * leave a thread with bumped thread priority
- */
- private inline fun check(value: Boolean, lazyMessage: () -> String) {
- if (!value) {
- cleanupBeforeThrow()
- throw IllegalStateException(lazyMessage())
- }
- }
-
- /**
- * Ideally this would only be called when an exception is observed in measureRepeated, but to
- * account for java callers, we explicitly trigger before throwing as well.
- */
- @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
- fun cleanupBeforeThrow() {
- if (phaseIndex >= 0 && phaseIndex <= phases.size) {
- Log.d(TAG, "aborting and cancelling benchmark")
- // current phase cancelled, complete current phase cleanup (trace event and profiling)
- InMemoryTracing.endSection()
- currentPhase.profiler?.run { inMemoryTrace("profiling stop") { stop() } }
-
- // for safety, set other state to done and do broader cleanup
- phaseIndex = phases.size
- afterBenchmark()
- }
- }
-
- /**
- * Internal loop control for benchmarks - will return true as long as there are more
- * measurements to perform.
- *
- * Actual benchmarks should always go through [keepRunning] or [keepRunningInline], since they
- * optimize the *Iteration* step to have extremely minimal logic performed.
- *
- * The looping behavior is functionally multiple nested loops, e.g.:
- * - Stage - RUNNING_WARMUP vs RUNNING_TIME
- * - Measurement - how many times iterations are measured
- * - Iteration - how many iterations/loops are run between each measurement
- *
- * This has the effect of a 3 layer nesting loop structure, but all condensed to a single method
- * returning true/false to simplify the entry point.
- *
- * @return whether the benchmarking system has anything left to do
- */
@PublishedApi
internal fun keepRunningInternal(): Boolean {
- val shouldKeepRunning =
- if (phaseIndex == -1) {
- // Initialize
- beforeBenchmark()
- startNextPhase()
- } else {
- // Trigger another repeat within current phase
- onMeasurementComplete()
- }
-
- iterationsRemaining = currentLoopsPerMeasurement
- return shouldKeepRunning
+ iterationsRemaining = internalIter.getNextLoopCount()
+ if (iterationsRemaining > 0) {
+ iterationsRemaining--
+ return true
+ }
+ return false
}
- private fun beforeBenchmark() {
- Errors.throwIfError()
- if (!firstBenchmark && Arguments.startupMode) {
- throw AssertionError(
- "Error - multiple benchmarks in startup mode. Only one " +
- "benchmark may be run per 'am instrument' call, to ensure result " +
- "isolation."
- )
+ fun keepRunning(): Boolean {
+ if (iterationsRemaining > 0) {
+ iterationsRemaining--
+ return true
}
- check(DeviceInfo.artMainlineVersion != DeviceInfo.ART_MAINLINE_VERSION_UNDETECTED_ERROR) {
- "Unable to detect ART mainline module version to check for interference from method" +
- " tracing, please see logcat for details, and/or file a bug with logcat."
- }
- check(
- !enableMethodTracingAffectsMeasurementError ||
- !DeviceInfo.methodTracingAffectsMeasurements ||
- !MethodTracing.hasBeenUsed
- ) {
- "Measurement prevented by method trace - Running on a device/configuration where " +
- "method tracing affects measurements, and a method trace has been captured " +
- "- no additional benchmarks can be run without restarting the test suite. Use " +
- "ProfilerConfig.MethodTracing.affectsMeasurementOnThisDevice to detect affected " +
- "devices, see its documentation for more info."
- }
-
- thermalThrottleSleepSeconds = 0
-
- if (!simplifiedTimingOnlyMode) {
- ThrottleDetector.computeThrottleBaselineIfNeeded()
- ThreadPriority.bumpCurrentThreadPriority()
- }
-
- totalRunTimeStartNs = System.nanoTime() // Record this time to find total duration
+ return keepRunningInternal()
}
- private fun afterBenchmark() {
- totalRunTimeNs = System.nanoTime() - totalRunTimeStartNs
-
- if (!simplifiedTimingOnlyMode) {
- // Don't modify thread priority when checking for thermal throttling, since 'outer'
- // BenchmarkState owns thread priority
- ThreadPriority.resetBumpedThread()
- }
- warmupManager.logInfo()
- }
-
- private fun throwIfPaused() =
- check(!paused) {
- "Benchmark loop finished in paused state." +
- " Call BenchmarkState.resumeTiming() before BenchmarkState.keepRunning()."
- }
-
- private fun getTestResult(testName: String, className: String, perfettoTracePath: String?) =
- BenchmarkData.TestResult(
- name = testName,
- className = className,
- totalRunTimeNs = totalRunTimeNs,
- metrics = metricResults,
- warmupIterations = warmupRepeats,
- repeatIterations = iterationsPerRepeat,
- thermalThrottleSleepSeconds = thermalThrottleSleepSeconds,
- profilerOutputs =
- listOfNotNull(
- perfettoTracePath?.let {
- BenchmarkData.TestResult.ProfilerOutput(
- Profiler.ResultFile.ofPerfettoTrace(
- label = "Trace",
- absolutePath = perfettoTracePath
- )
- )
- },
- profilerResult?.let { BenchmarkData.TestResult.ProfilerOutput(it) }
- )
- )
-
- @ExperimentalBenchmarkStateApi
- fun getMeasurementTimeNs(): List<Double> = metricResults.first { it.name == "timeNs" }.data
-
- internal fun peekTestResult() =
- checkFinished().run {
- getTestResult(testName = "", className = "", perfettoTracePath = null)
- }
-
- /**
- * Acquires a status report bundle
- *
- * @param key Run identifier, prepended to bundle properties.
- * @param reportMetrics True if stats should be included in the output bundle.
- */
- internal fun getFullStatusReport(
- key: String,
- reportMetrics: Boolean,
- tracePath: String?
- ): Bundle {
- Log.i(TAG, key + metricResults.map { it.getSummary() } + "count=$iterationsPerRepeat")
- val status = Bundle()
- if (reportMetrics) {
- // these 'legacy' CI output metrics are considered output
- metricResults.forEach { it.putInBundle(status, PREFIX) }
- }
- InstrumentationResultScope(status)
- .reportSummaryToIde(
- testName = key,
- measurements =
- Measurements(singleMetrics = metricResults, sampledMetrics = emptyList()),
- profilerResults =
- listOfNotNull(
- tracePath?.let {
- Profiler.ResultFile.ofPerfettoTrace(
- label = "Trace",
- absolutePath = tracePath
- )
- },
- profilerResult
- )
- )
- return status
- }
-
- @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
- fun report(
- fullClassName: String,
- simpleClassName: String,
- methodName: String,
- perfettoTracePath: String?
- ) {
- if (phaseIndex == -1) {
- return // nothing to report, BenchmarkState wasn't used
- }
-
- profilerResult?.convertBeforeSync?.invoke()
- if (perfettoTracePath != null) {
- profilerResult?.embedInPerfettoTrace(perfettoTracePath)
- }
-
- checkFinished() // this method is triggered externally
- val fullTestName = "$PREFIX$simpleClassName.$methodName"
- val bundle =
- getFullStatusReport(
- key = fullTestName,
- reportMetrics = !Arguments.dryRunMode,
- tracePath = perfettoTracePath
- )
- reportBundle(bundle)
- ResultWriter.appendTestResult(
- getTestResult(
- testName = PREFIX + methodName,
- className = fullClassName,
- perfettoTracePath = perfettoTracePath
- )
- )
- }
-
+ // Note: Constants left here to avoid churn, but should eventually be moved out to more
+ // appropriate locations
companion object {
internal const val TAG = "Benchmark"
- internal const val REPEAT_COUNT_ALLOCATION = 5
-
/**
- * Conservative estimate for how much method tracing slows down runtime how much longer will
- * `methodTrace {x()}` be than `x()`
+ * Conservative estimate for how much method tracing slows down runtime - how much longer
+ * will `methodTrace {x()}` be than `x()` for nontrivial workloads.
*
* This is a conservative estimate, better version of this would account for OS/Art version
*
@@ -649,11 +200,10 @@
internal const val METHOD_TRACING_MAX_DURATION_NS = 4_000_000_000
internal val DEFAULT_MEASUREMENT_DURATION_NS = TimeUnit.MILLISECONDS.toNanos(100)
+
internal val SAMPLED_PROFILER_DURATION_NS =
TimeUnit.SECONDS.toNanos(Arguments.profilerSampleDurationSeconds)
- private var firstBenchmark = true
-
/**
* Used to disable error to enable internal correctness tests, which need to use method
* tracing and can safely ignore measurement accuracy
@@ -662,67 +212,5 @@
* error functionality doesn't handle changing error states dynamically
*/
internal var enableMethodTracingAffectsMeasurementError = true
-
- @RequiresOptIn
- @Retention(AnnotationRetention.BINARY)
- @Target(AnnotationTarget.FUNCTION)
- annotation class ExperimentalExternalReport
-
- /**
- * Hooks for benchmarks not using [androidx.benchmark.junit4.BenchmarkRule] to register
- * results.
- *
- * Results are printed to Studio console, and added to the output JSON file.
- *
- * @param className Name of class the benchmark runs in
- * @param testName Name of the benchmark
- * @param totalRunTimeNs The total run time of the benchmark
- * @param dataNs List of all measured timing results, in nanoseconds
- * @param warmupIterations Number of iterations of warmup before measurements started.
- * Should be no less than 0.
- * @param thermalThrottleSleepSeconds Number of seconds benchmark was paused during thermal
- * throttling.
- * @param repeatIterations Number of iterations in between each measurement. Should be no
- * less than 1.
- */
- @JvmStatic
- @ExperimentalExternalReport
- fun reportData(
- className: String,
- testName: String,
- @IntRange(from = 0) totalRunTimeNs: Long,
- dataNs: List<Long>,
- @IntRange(from = 0) warmupIterations: Int,
- @IntRange(from = 0) thermalThrottleSleepSeconds: Long,
- @IntRange(from = 1) repeatIterations: Int
- ) {
- val metricsContainer = MetricsContainer(repeatCount = dataNs.size)
- dataNs.forEachIndexed { index, value -> metricsContainer.data[index][0] = value }
- val metrics = metricsContainer.captureFinished(maxIterations = 1)
- val report =
- BenchmarkData.TestResult(
- className = className,
- name = testName,
- totalRunTimeNs = totalRunTimeNs,
- metrics = metrics,
- repeatIterations = repeatIterations,
- thermalThrottleSleepSeconds = thermalThrottleSleepSeconds,
- warmupIterations = warmupIterations,
- profilerOutputs = null,
- )
- // Report value to Studio console
- val fullTestName =
- PREFIX + if (className.isNotEmpty()) "$className.$testName" else testName
-
- instrumentationReport {
- reportSummaryToIde(
- testName = fullTestName,
- measurements = Measurements(metrics, emptyList()),
- )
- }
-
- // Report values to file output
- ResultWriter.appendTestResult(report)
- }
}
}
diff --git a/benchmark/benchmark-common/src/main/java/androidx/benchmark/BenchmarkStateLegacy.kt b/benchmark/benchmark-common/src/main/java/androidx/benchmark/BenchmarkStateLegacy.kt
new file mode 100644
index 0000000..54021db
--- /dev/null
+++ b/benchmark/benchmark-common/src/main/java/androidx/benchmark/BenchmarkStateLegacy.kt
@@ -0,0 +1,722 @@
+/*
+ * Copyright (C) 2016 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.benchmark
+
+import android.annotation.SuppressLint
+import android.os.Bundle
+import android.os.Looper
+import android.util.Log
+import androidx.annotation.IntRange
+import androidx.annotation.RestrictTo
+import androidx.annotation.VisibleForTesting
+import androidx.benchmark.Errors.PREFIX
+import androidx.benchmark.InstrumentationResults.instrumentationReport
+import androidx.benchmark.InstrumentationResults.reportBundle
+import androidx.benchmark.json.BenchmarkData
+import java.util.concurrent.TimeUnit
+
+/**
+ * Control object for benchmarking in the code in Java.
+ *
+ * Query a state object with [androidx.benchmark.junit4.BenchmarkRule.getState], and use it to
+ * measure a block of Java with [BenchmarkStateLegacy.keepRunning]:
+ * ```java
+ * @Rule
+ * public BenchmarkRule benchmarkRule = new BenchmarkRule();
+ *
+ * @Test
+ * public void sampleMethod() {
+ * BenchmarkState state = benchmarkRule.getState();
+ *
+ * int[] src = new int[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
+ * while (state.keepRunning()) {
+ * int[] dest = new int[src.length];
+ * System.arraycopy(src, 0, dest, 0, src.length);
+ * }
+ * }
+ * ```
+ *
+ * @see androidx.benchmark.junit4.BenchmarkRule.getState()
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) // temporarily left in place
+class BenchmarkStateLegacy internal constructor(phaseConfig: MicrobenchmarkPhase.Config) {
+
+ /**
+ * Create a BenchmarkState for custom measurement behavior.
+ *
+ * @param warmupCount Number of non-measured warmup iterations to perform, leave null to
+ * determine automatically
+ * @param repeatCount Number of measurements to perform, leave null for default behavior
+ */
+ @ExperimentalBenchmarkStateApi
+ constructor(
+ @SuppressWarnings("AutoBoxing") // allocations for tests not relevant, not in critical path
+ warmupCount: Int? = null,
+ @SuppressWarnings("AutoBoxing") // allocations for tests not relevant, not in critical path
+ repeatCount: Int? = null
+ ) : this(
+ warmupCount = warmupCount,
+ measurementCount = repeatCount,
+ simplifiedTimingOnlyMode = false
+ )
+
+ /** Constructor used for standard uses of BenchmarkState, e.g. in BenchmarkRule */
+ @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+ constructor(
+ config: MicrobenchmarkConfig? = null
+ ) : this(warmupCount = null, simplifiedTimingOnlyMode = false, config = config)
+
+ internal constructor(
+ warmupCount: Int? = null,
+ measurementCount: Int? = null,
+ simplifiedTimingOnlyMode: Boolean = false,
+ config: MicrobenchmarkConfig? = null
+ ) : this(
+ MicrobenchmarkPhase.Config(
+ dryRunMode = Arguments.dryRunMode,
+ startupMode = Arguments.startupMode,
+ profiler = config?.profiler?.profiler ?: Arguments.profiler,
+ profilerPerfCompareMode = Arguments.profilerPerfCompareEnable,
+ warmupCount = warmupCount,
+ measurementCount = Arguments.iterations ?: measurementCount,
+ simplifiedTimingOnlyMode = simplifiedTimingOnlyMode,
+ metrics =
+ config?.metrics?.toTypedArray()
+ ?: if (Arguments.cpuEventCounterMask != 0) {
+ arrayOf(
+ TimeCapture(),
+ CpuEventCounterCapture(
+ MicrobenchmarkPhase.cpuEventCounter,
+ Arguments.cpuEventCounterMask
+ )
+ )
+ } else {
+ arrayOf(TimeCapture())
+ }
+ )
+ )
+
+ /**
+ * Set this to true to run a simplified timing loop - no allocation tracking, and no global
+ * state set/reset (such as thread priorities)
+ *
+ * This var is used in one of two cases, either set to true by [ThrottleDetector.measureWorkNs]
+ * when device performance testing for thermal throttling in between benchmarks, or in
+ * correctness tests of this library.
+ *
+ * When set to true, indicates that this BenchmarkState **should not**:
+ * - touch thread priorities
+ * - perform allocation counting (only timing results matter)
+ * - call [ThrottleDetector], since it would infinitely recurse
+ */
+ private val simplifiedTimingOnlyMode = phaseConfig.simplifiedTimingOnlyMode
+
+ @get:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+ @set:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+ var traceUniqueName: String = "benchmark"
+
+ internal var warmupRepeats = 0 // number of warmup repeats that occurred
+
+ /**
+ * Decreasing iteration count used when running a multi-iteration measurement phase Used to
+ * determine when a main measurement stage finishes.
+ */
+ @Suppress("ShowingMemberInHiddenClass")
+ @JvmField
+ @PublishedApi // previously used by [BenchmarkState.keepRunningInline()]
+ internal var iterationsRemaining: Int = -1
+
+ @Suppress("NOTHING_TO_INLINE")
+ @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+ inline fun getIterationsRemaining() = iterationsRemaining
+
+ /**
+ * Number of iterations in a repeat.
+ *
+ * This value is defined in the json, but is written as maximum iterationsPerRepeat across
+ * phases, since nowadays there can be an arbitrary number of phases.
+ *
+ * This is fully compatible for now since e.g. timing and allocation measurement use the same
+ * value, but we should consider tracking and reporting this differently in the json if this
+ * changes.
+ */
+ @VisibleForTesting internal var iterationsPerRepeat = 1
+
+ private val warmupManager = phaseConfig.warmupManager
+
+ private var paused = false
+
+ /** The total duration of sleep due to thermal throttling. */
+ private var thermalThrottleSleepSeconds: Long = 0
+ private var totalRunTimeStartNs: Long = 0 // System.nanoTime() at start of benchmark.
+ private var totalRunTimeNs: Long = 0 // Total run time of a benchmark.
+
+ private var warmupEstimatedIterationTimeNs: Long = -1L
+
+ private val metricResults = mutableListOf<MetricResult>()
+ private var profilerResult: Profiler.ResultFile? = null
+ private val phases = phaseConfig.generatePhases()
+
+ // tracking current phase state
+ private var phaseIndex = -1
+ private var currentPhase: MicrobenchmarkPhase = phases[0]
+ private var currentMetrics: MetricsContainer = phases[0].metricsContainer
+ private var currentMeasurement = 0
+ private var currentLoopsPerMeasurement = 0
+
+ @SuppressLint("MethodNameUnits")
+ @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+ fun getMinTimeNanos(): Double {
+ checkFinished()
+ return metricResults.first { it.name == "timeNs" }.min
+ }
+
+ private fun checkFinished() {
+ check(phaseIndex >= 0) { "Attempting to interact with a benchmark that wasn't started!" }
+ check(phaseIndex >= phases.size) {
+ "The benchmark hasn't finished! In Java, use " +
+ "while(BenchmarkState.keepRunning()) to ensure keepRunning() returns " +
+ "false before ending your test. In Kotlin, just use " +
+ "benchmarkRule.measureRepeated {} to avoid the problem."
+ }
+ }
+
+ /**
+ * Stops the benchmark timer.
+ *
+ * This method can be called only when the timer is running.
+ *
+ * ```
+ * @Test
+ * public void bitmapProcessing() {
+ * final BenchmarkState state = mBenchmarkRule.getState();
+ * while (state.keepRunning()) {
+ * state.pauseTiming();
+ * // disable timing while constructing test input
+ * Bitmap input = constructTestBitmap();
+ * state.resumeTiming();
+ *
+ * processBitmap(input);
+ * }
+ * }
+ * ```
+ *
+ * @throws [IllegalStateException] if the benchmark is already paused.
+ * @see resumeTiming
+ */
+ fun pauseTiming() {
+ check(!paused) { "Unable to pause the benchmark. The benchmark has already paused." }
+ currentMetrics.capturePaused()
+ paused = true
+ }
+
+ /**
+ * Resumes the benchmark timer.
+ *
+ * This method can be called only when the timer is stopped.
+ *
+ * ```
+ * @Test
+ * public void bitmapProcessing() {
+ * final BenchmarkState state = mBenchmarkRule.getState();
+ * while (state.keepRunning()) {
+ * state.pauseTiming();
+ * // disable timing while constructing test input
+ * Bitmap input = constructTestBitmap();
+ * state.resumeTiming();
+ *
+ * processBitmap(input);
+ * }
+ * }
+ * ```
+ *
+ * @throws [IllegalStateException] if the benchmark is already running.
+ * @see pauseTiming
+ */
+ fun resumeTiming() {
+ check(paused) { "Unable to resume the benchmark. The benchmark is already running." }
+ currentMetrics.captureResumed()
+ paused = false
+ }
+
+ private fun startNextPhase(): Boolean {
+ check(phaseIndex < phases.size)
+
+ if (phaseIndex >= 0) {
+ currentPhase.profiler?.run { inMemoryTrace("profiler.stop()") { stop() } }
+ InMemoryTracing.endSection() // end phase
+ thermalThrottleSleepSeconds += currentPhase.thermalThrottleSleepSeconds
+ if (currentPhase.loopMode.warmupManager == null) {
+ // Save captured metrics except during warmup, where we intentionally discard
+ metricResults.addAll(
+ currentMetrics.captureFinished(maxIterations = currentLoopsPerMeasurement)
+ )
+ }
+ }
+ phaseIndex++
+ if (phaseIndex == phases.size) {
+ afterBenchmark()
+ return false
+ }
+ currentPhase = phases[phaseIndex]
+ currentMetrics = currentPhase.metricsContainer
+ currentMeasurement = 0
+
+ currentMetrics.captureInit()
+ if (currentPhase.gcBeforePhase) {
+ // Run GC to avoid memory pressure from previous run from affecting this one.
+ // Note, we don't use System.gc() because it doesn't always have consistent behavior
+ Runtime.getRuntime().gc()
+ }
+
+ currentLoopsPerMeasurement =
+ currentPhase.loopMode.getIterations(warmupEstimatedIterationTimeNs)
+
+ iterationsPerRepeat = iterationsPerRepeat.coerceAtLeast(currentLoopsPerMeasurement)
+
+ InMemoryTracing.beginSection(currentPhase.label)
+ val phaseProfilerResult =
+ currentPhase.profiler?.run {
+ val estimatedMethodTraceDurNs =
+ warmupEstimatedIterationTimeNs * METHOD_TRACING_ESTIMATED_SLOWDOWN_FACTOR
+ if (
+ this == MethodTracing &&
+ Looper.myLooper() == Looper.getMainLooper() &&
+ estimatedMethodTraceDurNs > METHOD_TRACING_MAX_DURATION_NS &&
+ Arguments.profilerSkipWhenDurationRisksAnr
+ ) {
+ val expectedDurSec = estimatedMethodTraceDurNs / 1_000_000_000.0
+ InstrumentationResults.scheduleIdeWarningOnNextReport(
+ """
+ Skipping method trace of estimated duration $expectedDurSec sec to avoid ANR
+
+ To disable this behavior, set instrumentation arg:
+ androidx.benchmark.profiling.skipWhenDurationRisksAnr = false
+ """
+ .trimIndent()
+ )
+ null
+ } else {
+ inMemoryTrace("start profiling") { start(traceUniqueName) }
+ }
+ }
+ if (phaseProfilerResult != null) {
+ require(profilerResult == null) {
+ "ProfileResult already set, only support one profiling phase"
+ }
+ profilerResult = phaseProfilerResult
+ }
+
+ // Warm up the metrics data structure to reduce the impact on the first measurement.
+ currentMetrics.captureStart()
+ currentMetrics.captureStop()
+ currentMetrics.captureInit()
+
+ currentMetrics.captureStart()
+ return true
+ }
+
+ /** @return true if the benchmark should still keep running */
+ private fun onMeasurementComplete(): Boolean {
+ currentMetrics.captureStop()
+ throwIfPaused()
+ currentMeasurement++
+
+ val tryStartNextPhase =
+ currentPhase.loopMode.let {
+ if (it.warmupManager != null) {
+ // warmup phase
+ currentMetrics.captureInit()
+ // Note that warmup is based on repeat time, *not* the timeNs metric, since we
+ // want
+ // to account for paused time during warmup (paused work should stabilize too)
+ val lastMeasuredWarmupValue = currentMetrics.peekSingleRepeatTime()
+ if (it.warmupManager.onNextIteration(lastMeasuredWarmupValue)) {
+ warmupEstimatedIterationTimeNs = lastMeasuredWarmupValue
+ warmupRepeats = currentMeasurement
+ true
+ } else {
+ false
+ }
+ } else {
+ currentMeasurement == currentPhase.measurementCount
+ }
+ }
+ return if (tryStartNextPhase) {
+ if (currentPhase.tryEnd()) {
+ startNextPhase()
+ } else {
+ // failed capture (due to thermal throttling), restart profiler and metrics
+ currentPhase.profiler?.apply {
+ stop()
+ profilerResult = inMemoryTrace("start profiling") { start(traceUniqueName) }
+ }
+ currentMetrics.captureInit()
+ currentMeasurement = 0
+ true
+ }
+ } else {
+ currentMetrics.captureStart()
+ true
+ }
+ }
+
+ /**
+ * Inline fast-path function for inner benchmark loop.
+ *
+ * Kotlin users should use `BenchmarkRule.measureRepeated`
+ *
+ * This code path uses exclusively @JvmField/const members, so there are no method calls at all
+ * in the inlined loop. On recent Android Platform versions, ART inlines these accessors anyway,
+ * but we want to be sure it's as simple as possible.
+ */
+ @Suppress("NOTHING_TO_INLINE")
+ @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+ inline fun keepRunningInline(): Boolean {
+ if (iterationsRemaining > 1) {
+ iterationsRemaining--
+ return true
+ }
+ return keepRunningInternal()
+ }
+
+ /**
+ * Returns true if the benchmark needs more samples - use this as the condition of a while loop.
+ *
+ * ```
+ * while (state.keepRunning()) {
+ * int[] dest = new int[src.length];
+ * System.arraycopy(src, 0, dest, 0, src.length);
+ * }
+ * ```
+ */
+ fun keepRunning(): Boolean {
+ if (iterationsRemaining > 1) {
+ iterationsRemaining--
+ return true
+ }
+ return keepRunningInternal()
+ }
+
+ /**
+ * Reimplementation of Kotlin check, which also resets thread priority, since we don't want to
+ * leave a thread with bumped thread priority
+ */
+ private inline fun check(value: Boolean, lazyMessage: () -> String) {
+ if (!value) {
+ cleanupBeforeThrow()
+ throw IllegalStateException(lazyMessage())
+ }
+ }
+
+ /**
+ * Ideally this would only be called when an exception is observed in measureRepeated, but to
+ * account for java callers, we explicitly trigger before throwing as well.
+ */
+ @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+ fun cleanupBeforeThrow() {
+ if (phaseIndex >= 0 && phaseIndex <= phases.size) {
+ Log.d(TAG, "aborting and cancelling benchmark")
+ // current phase cancelled, complete current phase cleanup (trace event and profiling)
+ InMemoryTracing.endSection()
+ currentPhase.profiler?.run { inMemoryTrace("profiling stop") { stop() } }
+
+ // for safety, set other state to done and do broader cleanup
+ phaseIndex = phases.size
+ afterBenchmark()
+ }
+ }
+
+ /**
+ * Internal loop control for benchmarks - will return true as long as there are more
+ * measurements to perform.
+ *
+ * Actual benchmarks should always go through [keepRunning] or [keepRunningInline], since they
+ * optimize the *Iteration* step to have extremely minimal logic performed.
+ *
+ * The looping behavior is functionally multiple nested loops, e.g.:
+ * - Stage - RUNNING_WARMUP vs RUNNING_TIME
+ * - Measurement - how many times iterations are measured
+ * - Iteration - how many iterations/loops are run between each measurement
+ *
+ * This has the effect of a 3 layer nesting loop structure, but all condensed to a single method
+ * returning true/false to simplify the entry point.
+ *
+ * @return whether the benchmarking system has anything left to do
+ */
+ @Suppress("ShowingMemberInHiddenClass")
+ @PublishedApi
+ internal fun keepRunningInternal(): Boolean {
+ val shouldKeepRunning =
+ if (phaseIndex == -1) {
+ // Initialize
+ beforeBenchmark()
+ startNextPhase()
+ } else {
+ // Trigger another repeat within current phase
+ onMeasurementComplete()
+ }
+
+ iterationsRemaining = currentLoopsPerMeasurement
+ return shouldKeepRunning
+ }
+
+ private fun beforeBenchmark() {
+ Errors.throwIfError()
+ if (!firstBenchmark && Arguments.startupMode) {
+ throw AssertionError(
+ "Error - multiple benchmarks in startup mode. Only one " +
+ "benchmark may be run per 'am instrument' call, to ensure result " +
+ "isolation."
+ )
+ }
+ check(DeviceInfo.artMainlineVersion != DeviceInfo.ART_MAINLINE_VERSION_UNDETECTED_ERROR) {
+ "Unable to detect ART mainline module version to check for interference from method" +
+ " tracing, please see logcat for details, and/or file a bug with logcat."
+ }
+ check(
+ !BenchmarkState.enableMethodTracingAffectsMeasurementError ||
+ !DeviceInfo.methodTracingAffectsMeasurements ||
+ !MethodTracing.hasBeenUsed
+ ) {
+ "Measurement prevented by method trace - Running on a device/configuration where " +
+ "method tracing affects measurements, and a method trace has been captured " +
+ "- no additional benchmarks can be run without restarting the test suite. Use " +
+ "ProfilerConfig.MethodTracing.affectsMeasurementOnThisDevice to detect affected " +
+ "devices, see its documentation for more info."
+ }
+
+ thermalThrottleSleepSeconds = 0
+
+ if (!simplifiedTimingOnlyMode) {
+ ThrottleDetector.computeThrottleBaselineIfNeeded()
+ ThreadPriority.bumpCurrentThreadPriority()
+ }
+
+ totalRunTimeStartNs = System.nanoTime() // Record this time to find total duration
+ }
+
+ private fun afterBenchmark() {
+ totalRunTimeNs = System.nanoTime() - totalRunTimeStartNs
+
+ if (!simplifiedTimingOnlyMode) {
+ // Don't modify thread priority when checking for thermal throttling, since 'outer'
+ // BenchmarkState owns thread priority
+ ThreadPriority.resetBumpedThread()
+ }
+ warmupManager.logInfo()
+ }
+
+ private fun throwIfPaused() =
+ check(!paused) {
+ "Benchmark loop finished in paused state." +
+ " Call BenchmarkState.resumeTiming() before BenchmarkState.keepRunning()."
+ }
+
+ private fun getTestResult(testName: String, className: String, perfettoTracePath: String?) =
+ BenchmarkData.TestResult(
+ name = testName,
+ className = className,
+ totalRunTimeNs = totalRunTimeNs,
+ metrics = metricResults,
+ warmupIterations = warmupRepeats,
+ repeatIterations = iterationsPerRepeat,
+ thermalThrottleSleepSeconds = thermalThrottleSleepSeconds,
+ profilerOutputs =
+ listOfNotNull(
+ perfettoTracePath?.let {
+ BenchmarkData.TestResult.ProfilerOutput(
+ Profiler.ResultFile.ofPerfettoTrace(
+ label = "Trace",
+ absolutePath = perfettoTracePath
+ )
+ )
+ },
+ profilerResult?.let { BenchmarkData.TestResult.ProfilerOutput(it) }
+ )
+ )
+
+ @ExperimentalBenchmarkStateApi
+ fun getMeasurementTimeNs(): List<Double> = metricResults.first { it.name == "timeNs" }.data
+
+ internal fun peekTestResult() =
+ checkFinished().run {
+ getTestResult(testName = "", className = "", perfettoTracePath = null)
+ }
+
+ /**
+ * Acquires a status report bundle
+ *
+ * @param key Run identifier, prepended to bundle properties.
+ * @param reportMetrics True if stats should be included in the output bundle.
+ */
+ internal fun getFullStatusReport(
+ key: String,
+ reportMetrics: Boolean,
+ tracePath: String?
+ ): Bundle {
+ Log.i(TAG, key + metricResults.map { it.getSummary() } + "count=$iterationsPerRepeat")
+ val status = Bundle()
+ if (reportMetrics) {
+ // these 'legacy' CI output metrics are considered output
+ metricResults.forEach { it.putInBundle(status, PREFIX) }
+ }
+ InstrumentationResultScope(status)
+ .reportSummaryToIde(
+ testName = key,
+ measurements =
+ Measurements(singleMetrics = metricResults, sampledMetrics = emptyList()),
+ profilerResults =
+ listOfNotNull(
+ tracePath?.let {
+ Profiler.ResultFile.ofPerfettoTrace(
+ label = "Trace",
+ absolutePath = tracePath
+ )
+ },
+ profilerResult
+ )
+ )
+ return status
+ }
+
+ @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+ fun report(
+ fullClassName: String,
+ simpleClassName: String,
+ methodName: String,
+ perfettoTracePath: String?
+ ) {
+ if (phaseIndex == -1) {
+ return // nothing to report, BenchmarkState wasn't used
+ }
+
+ profilerResult?.convertBeforeSync?.invoke()
+ if (perfettoTracePath != null) {
+ profilerResult?.embedInPerfettoTrace(perfettoTracePath)
+ }
+
+ checkFinished() // this method is triggered externally
+ val fullTestName = "$PREFIX$simpleClassName.$methodName"
+ val bundle =
+ getFullStatusReport(
+ key = fullTestName,
+ reportMetrics = !Arguments.dryRunMode,
+ tracePath = perfettoTracePath
+ )
+ reportBundle(bundle)
+ ResultWriter.appendTestResult(
+ getTestResult(
+ testName = PREFIX + methodName,
+ className = fullClassName,
+ perfettoTracePath = perfettoTracePath
+ )
+ )
+ }
+
+ companion object {
+ internal const val TAG = "Benchmark"
+
+ internal const val REPEAT_COUNT_ALLOCATION = 5
+
+ /**
+ * Conservative estimate for how much method tracing slows down runtime how much longer will
+ * `methodTrace {x()}` be than `x()`
+ *
+ * This is a conservative estimate, better version of this would account for OS/Art version
+ *
+ * Value derived from observed numbers on bramble API 31 (600-800x slowdown)
+ */
+ internal const val METHOD_TRACING_ESTIMATED_SLOWDOWN_FACTOR = 1000
+
+ /**
+ * Maximum duration to trace on main thread to avoid ANRs
+ *
+ * In practice, other types of tracing can be equally dangerous for ANRs, but method tracing
+ * is the default tracing mode.
+ */
+ internal const val METHOD_TRACING_MAX_DURATION_NS = 4_000_000_000
+
+ internal val DEFAULT_MEASUREMENT_DURATION_NS = TimeUnit.MILLISECONDS.toNanos(100)
+ internal val SAMPLED_PROFILER_DURATION_NS =
+ TimeUnit.SECONDS.toNanos(Arguments.profilerSampleDurationSeconds)
+
+ private var firstBenchmark = true
+
+ @RequiresOptIn
+ @Retention(AnnotationRetention.BINARY)
+ @Target(AnnotationTarget.FUNCTION)
+ annotation class ExperimentalExternalReport
+
+ /**
+ * Hooks for benchmarks not using [androidx.benchmark.junit4.BenchmarkRule] to register
+ * results.
+ *
+ * Results are printed to Studio console, and added to the output JSON file.
+ *
+ * @param className Name of class the benchmark runs in
+ * @param testName Name of the benchmark
+ * @param totalRunTimeNs The total run time of the benchmark
+ * @param dataNs List of all measured timing results, in nanoseconds
+ * @param warmupIterations Number of iterations of warmup before measurements started.
+ * Should be no less than 0.
+ * @param thermalThrottleSleepSeconds Number of seconds benchmark was paused during thermal
+ * throttling.
+ * @param repeatIterations Number of iterations in between each measurement. Should be no
+ * less than 1.
+ */
+ @JvmStatic
+ @ExperimentalExternalReport
+ fun reportData(
+ className: String,
+ testName: String,
+ @IntRange(from = 0) totalRunTimeNs: Long,
+ dataNs: List<Long>,
+ @IntRange(from = 0) warmupIterations: Int,
+ @IntRange(from = 0) thermalThrottleSleepSeconds: Long,
+ @IntRange(from = 1) repeatIterations: Int
+ ) {
+ val metricsContainer = MetricsContainer(repeatCount = dataNs.size)
+ dataNs.forEachIndexed { index, value -> metricsContainer.data[index][0] = value }
+ val metrics = metricsContainer.captureFinished(maxIterations = 1)
+ val report =
+ BenchmarkData.TestResult(
+ className = className,
+ name = testName,
+ totalRunTimeNs = totalRunTimeNs,
+ metrics = metrics,
+ repeatIterations = repeatIterations,
+ thermalThrottleSleepSeconds = thermalThrottleSleepSeconds,
+ warmupIterations = warmupIterations,
+ profilerOutputs = null,
+ )
+ // Report value to Studio console
+ val fullTestName =
+ PREFIX + if (className.isNotEmpty()) "$className.$testName" else testName
+
+ instrumentationReport {
+ reportSummaryToIde(
+ testName = fullTestName,
+ measurements = Measurements(metrics, emptyList()),
+ )
+ }
+
+ // Report values to file output
+ ResultWriter.appendTestResult(report)
+ }
+ }
+}
diff --git a/benchmark/benchmark-common/src/main/java/androidx/benchmark/Microbenchmark.kt b/benchmark/benchmark-common/src/main/java/androidx/benchmark/Microbenchmark.kt
new file mode 100644
index 0000000..0fa13ee
--- /dev/null
+++ b/benchmark/benchmark-common/src/main/java/androidx/benchmark/Microbenchmark.kt
@@ -0,0 +1,466 @@
+/*
+ * 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.benchmark
+
+import android.os.Build
+import android.util.Log
+import androidx.annotation.RestrictTo
+import androidx.benchmark.BenchmarkState.Companion.enableMethodTracingAffectsMeasurementError
+import androidx.benchmark.perfetto.PerfettoCapture
+import androidx.benchmark.perfetto.PerfettoCaptureWrapper
+import androidx.benchmark.perfetto.PerfettoConfig
+import androidx.benchmark.perfetto.UiState
+import androidx.benchmark.perfetto.appendUiState
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.tracing.Trace
+import androidx.tracing.trace
+import java.io.File
+import java.util.concurrent.TimeUnit
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.yield
+
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+open class MicrobenchmarkScope(internal val state: MicrobenchmarkRunningState) {
+ inline fun <T> runWithMeasurementDisabled(block: () -> T): T {
+ pauseMeasurement()
+ // Note: we only bother with tracing for the runWithTimingDisabled function for
+ // Kotlin callers, as it's more difficult to corrupt the trace with incorrectly
+ // paired BenchmarkState pause/resume calls
+ val ret: T =
+ try {
+ // TODO: use `trace() {}` instead of this manual try/finally,
+ // once the block parameter is marked crossinline.
+ Trace.beginSection("runWithTimingDisabled")
+ block()
+ } finally {
+ Trace.endSection()
+ }
+ resumeMeasurement()
+ return ret
+ }
+
+ /**
+ * Resume measurement after a call to [pauseMeasurement].
+ *
+ * Kotlin callers should generally instead use [runWithTimingDisabled].
+ */
+ fun pauseMeasurement() {
+ state.pauseMeasurement()
+ }
+
+ /**
+ * Resume measurement after a call to [pauseMeasurement]
+ *
+ * Kotlin callers should generally instead use [runWithTimingDisabled].
+ */
+ fun resumeMeasurement() {
+ state.resumeMeasurement()
+ }
+}
+
+/**
+ * State carried across multiple phases, including metric and output files
+ *
+ * This is maintained as a state object rather than return objects from each phase to avoid
+ * allocation
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+class MicrobenchmarkRunningState
+internal constructor(metrics: MetricsContainer, val yieldThreadPeriodically: Boolean) {
+ internal var warmupEstimatedIterationTimeNs: Long = 0
+ internal var warmupIterations: Int = 0
+ internal var totalThermalThrottleSleepSeconds: Long = 0
+ internal var maxIterationsPerRepeat = 0
+ internal var metrics: MetricsContainer = metrics
+ internal var metricResults = mutableListOf<MetricResult>()
+ internal var profilerResults = mutableListOf<Profiler.ResultFile>()
+ internal var paused = false
+
+ internal var initialTimeNs: Long = 0
+ internal var softDeadlineNs: Long = 0
+ internal var hardDeadlineNs: Long = 0
+
+ fun pauseMeasurement() {
+ check(!paused) { "Unable to pause the benchmark. The benchmark has already paused." }
+ metrics.capturePaused()
+ paused = true
+ }
+
+ fun resumeMeasurement() {
+ check(paused) { "Unable to resume the benchmark. The benchmark is already running." }
+ metrics.captureResumed()
+ paused = false
+ }
+
+ fun beginTaskTrace() {
+ if (yieldThreadPeriodically) {
+ Trace.beginSection("benchmark task")
+ initialTimeNs = System.nanoTime()
+ // we try to stop next measurement after soft deadline...
+ softDeadlineNs = initialTimeNs + TimeUnit.SECONDS.toNanos(2)
+ // ... and throw if took longer than hard deadline
+ hardDeadlineNs = initialTimeNs + TimeUnit.SECONDS.toNanos(10)
+ }
+ }
+
+ fun endTaskTrace() {
+ if (yieldThreadPeriodically) {
+ Trace.endSection()
+ }
+ }
+
+ internal suspend inline fun yieldThreadIfDeadlinePassed() {
+ if (yieldThreadPeriodically) {
+ val timeNs = System.nanoTime()
+ if (timeNs >= softDeadlineNs) {
+
+ if (timeNs > hardDeadlineNs && Arguments.measureRepeatedOnMainThrowOnDeadline) {
+ val overrunInSec = (timeNs - hardDeadlineNs) / 1_000_000_000.0
+ // note - we throw without cancelling task trace, since outer layer handles that
+ throw IllegalStateException(
+ "Benchmark loop overran hard time limit by $overrunInSec seconds"
+ )
+ }
+
+ // pause and resume task trace around yield
+ endTaskTrace()
+ yield()
+ beginTaskTrace()
+ }
+ }
+ }
+}
+
+private var firstBenchmark = true
+
+private fun checkForErrors() {
+ Errors.throwIfError()
+ if (!firstBenchmark && Arguments.startupMode) {
+ throw AssertionError(
+ "Error - multiple benchmarks in startup mode. Only one " +
+ "benchmark may be run per 'am instrument' call, to ensure result " +
+ "isolation."
+ )
+ }
+ check(DeviceInfo.artMainlineVersion != DeviceInfo.ART_MAINLINE_VERSION_UNDETECTED_ERROR) {
+ "Unable to detect ART mainline module version to check for interference from method" +
+ " tracing, please see logcat for details, and/or file a bug with logcat."
+ }
+ check(
+ !enableMethodTracingAffectsMeasurementError ||
+ !DeviceInfo.methodTracingAffectsMeasurements ||
+ !MethodTracing.hasBeenUsed
+ ) {
+ "Measurement prevented by method trace - Running on a device/configuration where " +
+ "method tracing affects measurements, and a method trace has been captured " +
+ "- no additional benchmarks can be run without restarting the test suite. Use " +
+ "ProfilerConfig.MethodTracing.affectsMeasurementOnThisDevice to detect affected " +
+ "devices, see its documentation for more info."
+ }
+}
+
+internal typealias LoopedMeasurementBlock = suspend (MicrobenchmarkScope, Int) -> Unit
+
+internal typealias ScopeFactory = (MicrobenchmarkRunningState) -> MicrobenchmarkScope
+
+private fun <T> runBlockingOverrideMain(
+ runOnMainDispatcher: Boolean,
+ block: suspend CoroutineScope.() -> T
+): T {
+ return if (runOnMainDispatcher) {
+ runBlocking(Dispatchers.Main, block)
+ } else {
+ runBlocking { block(this) }
+ }
+}
+
+internal fun captureMicroPerfettoTrace(
+ definition: TestDefinition,
+ config: MicrobenchmarkConfig?,
+ block: () -> Unit
+): String? =
+ PerfettoCaptureWrapper()
+ .record(
+ fileLabel = definition.traceUniqueName,
+ config =
+ PerfettoConfig.Benchmark(
+ appTagPackages =
+ if (config?.traceAppTagEnabled == true) {
+ listOf(InstrumentationRegistry.getInstrumentation().context.packageName)
+ } else {
+ emptyList()
+ },
+ useStackSamplingConfig = false
+ ),
+ // TODO(290918736): add support for Perfetto SDK Tracing in
+ // Microbenchmark in other cases, outside of MicrobenchmarkConfig
+ perfettoSdkConfig =
+ if (
+ config?.perfettoSdkTracingEnabled == true &&
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
+ ) {
+ PerfettoCapture.PerfettoSdkConfig(
+ InstrumentationRegistry.getInstrumentation().context.packageName,
+ PerfettoCapture.PerfettoSdkConfig.InitialProcessState.Alive
+ )
+ } else {
+ null
+ },
+
+ // Optimize throughput in dryRunMode, since trace isn't useful, and extremely
+ // expensive on some emulators. Could alternately use UserspaceTracing if
+ // desired
+ // Additionally, skip on misconfigured devices to still enable benchmarking.
+ enableTracing = !Arguments.dryRunMode && !DeviceInfo.misconfiguredForTracing,
+ inMemoryTracingLabel = "Microbenchmark",
+ block = block
+ )
+
+/**
+ * Core engine of microbenchmark, used in one of three ways:
+ * 1. [measureRepeatedImplWithTracing] - standard tracing microbenchmark
+ * 2. [measureRepeatedImplNoTracing] - legacy, non-tracing, suspending functionality backing
+ * [BenchmarkState] compatibility
+ * 3. [measureRepeatedCheckNanosReentrant] - microbenchmark which avoids modifying global state,
+ * which runs *within* other variants to check for thermal throttling
+ */
+internal class Microbenchmark(
+ private val definition: TestDefinition,
+ private val phaseConfig: MicrobenchmarkPhase.Config,
+ private val yieldThreadPeriodically: Boolean,
+ private val scopeFactory: ScopeFactory,
+ private val loopedMeasurementBlock: LoopedMeasurementBlock
+) {
+ constructor(
+ definition: TestDefinition,
+ config: MicrobenchmarkConfig,
+ simplifiedTimingOnlyMode: Boolean,
+ yieldThreadPeriodically: Boolean,
+ scopeFactory: ScopeFactory = { runningState -> MicrobenchmarkScope(runningState) },
+ loopedMeasurementBlock: LoopedMeasurementBlock
+ ) : this(
+ definition = definition,
+ phaseConfig = MicrobenchmarkPhase.Config(config, simplifiedTimingOnlyMode),
+ yieldThreadPeriodically = yieldThreadPeriodically,
+ scopeFactory = scopeFactory,
+ loopedMeasurementBlock = loopedMeasurementBlock
+ )
+
+ private var startTimeNs = System.nanoTime()
+
+ init {
+ if (!phaseConfig.simplifiedTimingOnlyMode) {
+ Log.d(TAG, "-- Running ${definition.fullNameUnsanitized} --")
+ checkForErrors()
+ }
+ }
+
+ private val phases = phaseConfig.generatePhases()
+ private val state =
+ MicrobenchmarkRunningState(phases[0].metricsContainer, yieldThreadPeriodically)
+ private val scope = scopeFactory(state)
+
+ suspend fun executePhases() {
+ state.beginTaskTrace()
+ try {
+ if (!phaseConfig.simplifiedTimingOnlyMode) {
+ ThrottleDetector.computeThrottleBaselineIfNeeded()
+ ThreadPriority.bumpCurrentThreadPriority()
+ }
+ firstBenchmark = false
+ phases.forEach {
+ it.execute(
+ traceUniqueName = definition.traceUniqueName,
+ scope = scope,
+ state = state,
+ loopedMeasurementBlock = loopedMeasurementBlock
+ )
+ }
+ } finally {
+ if (!phaseConfig.simplifiedTimingOnlyMode) {
+ // Don't modify thread priority in simplified timing mode, since 'outer'
+ // measureRepeated owns thread priority
+ ThreadPriority.resetBumpedThread()
+ }
+ phaseConfig.warmupManager.logInfo()
+ state.endTaskTrace()
+ }
+ }
+
+ fun output(perfettoTracePath: String?): MicrobenchmarkOutput {
+ Log.i(
+ BenchmarkState.TAG,
+ definition.outputTestName +
+ state.metricResults.map { it.getSummary() } +
+ "count=${state.maxIterationsPerRepeat}"
+ )
+ return MicrobenchmarkOutput(
+ definition = definition,
+ metricResults = state.metricResults,
+ profilerResults = processProfilerResults(perfettoTracePath),
+ totalRunTimeNs = System.nanoTime() - startTimeNs,
+ warmupIterations = state.warmupIterations,
+ repeatIterations = state.maxIterationsPerRepeat,
+ thermalThrottleSleepSeconds = state.totalThermalThrottleSleepSeconds,
+ reportMetricsInBundle = !Arguments.dryRunMode
+ )
+ .apply {
+ InstrumentationResults.reportBundle(createBundle())
+ ResultWriter.appendTestResult(createJsonTestResult())
+ }
+ }
+
+ fun getMinTimeNanos(): Double {
+ return state.metricResults.first { it.name == "timeNs" }.min
+ }
+
+ private fun processProfilerResults(perfettoTracePath: String?): List<Profiler.ResultFile> {
+ // prepare profiling result files
+ perfettoTracePath?.apply {
+ // trace completed, and copied into shell writeable dir
+ val file = File(this)
+ file.appendUiState(
+ UiState(
+ timelineStart = null,
+ timelineEnd = null,
+ highlightPackage =
+ InstrumentationRegistry.getInstrumentation().context.packageName
+ )
+ )
+ }
+ state.profilerResults.forEach {
+ it.convertBeforeSync?.invoke()
+ if (perfettoTracePath != null) {
+ it.embedInPerfettoTrace(perfettoTracePath)
+ }
+ }
+ val profilerResults =
+ listOfNotNull(
+ perfettoTracePath?.let {
+ Profiler.ResultFile.ofPerfettoTrace(label = "Trace", absolutePath = it)
+ }
+ ) + state.profilerResults
+ return profilerResults
+ }
+
+ companion object {
+ internal const val TAG = "Benchmark"
+ }
+}
+
+internal inline fun measureRepeatedCheckNanosReentrant(
+ crossinline measureBlock: MicrobenchmarkScope.() -> Unit
+): Double {
+ return Microbenchmark(
+ TestDefinition(
+ fullClassName = "ThrottleDetector",
+ simpleClassName = "ThrottleDetector",
+ methodName = "checkThrottle"
+ ),
+ config = MicrobenchmarkConfig(),
+ simplifiedTimingOnlyMode = true,
+ yieldThreadPeriodically = false,
+ loopedMeasurementBlock = { scope, iterations ->
+ var remainingIterations = iterations
+ do {
+ measureBlock.invoke(scope)
+ remainingIterations--
+ } while (remainingIterations > 0)
+ }
+ )
+ .run {
+ runBlocking { executePhases() }
+ getMinTimeNanos()
+ }
+}
+
+/**
+ * Limited version of [measureRepeatedImplWithTracing] which doesn't capture a trace, and doesn't
+ * support posting work to main thread.
+ */
+internal suspend fun measureRepeatedImplNoTracing(
+ definition: TestDefinition,
+ config: MicrobenchmarkConfig,
+ loopedMeasurementBlock: LoopedMeasurementBlock
+) {
+ Microbenchmark(
+ definition = definition,
+ config = config,
+ simplifiedTimingOnlyMode = false,
+ yieldThreadPeriodically = false,
+ loopedMeasurementBlock = loopedMeasurementBlock
+ )
+ .apply {
+ executePhases()
+ output(perfettoTracePath = null)
+ }
+}
+
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+fun measureRepeatedImplWithTracing(
+ definition: TestDefinition,
+ config: MicrobenchmarkConfig?,
+ postToMainThread: Boolean,
+ scopeFactory: ScopeFactory = { runningState -> MicrobenchmarkScope(runningState) },
+ loopedMeasurementBlock: LoopedMeasurementBlock
+) {
+ val microbenchmark =
+ Microbenchmark(
+ definition = definition,
+ config = config ?: MicrobenchmarkConfig(),
+ simplifiedTimingOnlyMode = false,
+ yieldThreadPeriodically = postToMainThread,
+ scopeFactory = scopeFactory,
+ loopedMeasurementBlock = loopedMeasurementBlock
+ )
+ val perfettoTracePath =
+ captureMicroPerfettoTrace(definition, config) {
+ trace(definition.fullNameUnsanitized) {
+ runBlockingOverrideMain(runOnMainDispatcher = postToMainThread) {
+ microbenchmark.executePhases()
+ }
+ }
+ }
+ microbenchmark.output(perfettoTracePath)
+}
+
+/**
+ * Top level entry point for capturing a microbenchmark with a trace.
+ *
+ * Eventually this method (or one like it) should be public, and also expose a results object
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+inline fun measureRepeated(
+ definition: TestDefinition,
+ config: MicrobenchmarkConfig? = null,
+ crossinline measureBlock: MicrobenchmarkScope.() -> Unit
+) {
+ measureRepeatedImplWithTracing(
+ postToMainThread = false,
+ definition = definition,
+ config = config,
+ loopedMeasurementBlock = { scope, iterations ->
+ var remainingIterations = iterations
+ do {
+ measureBlock.invoke(scope)
+ remainingIterations--
+ } while (remainingIterations > 0)
+ }
+ )
+}
diff --git a/benchmark/benchmark-common/src/main/java/androidx/benchmark/MicrobenchmarkConfig.kt b/benchmark/benchmark-common/src/main/java/androidx/benchmark/MicrobenchmarkConfig.kt
index 58ec6af..102de5e 100644
--- a/benchmark/benchmark-common/src/main/java/androidx/benchmark/MicrobenchmarkConfig.kt
+++ b/benchmark/benchmark-common/src/main/java/androidx/benchmark/MicrobenchmarkConfig.kt
@@ -29,7 +29,18 @@
*
* Defaults to [TimeCapture].
*/
- val metrics: List<MetricCapture> = listOf(TimeCapture()),
+ val metrics: List<MetricCapture> =
+ if (Arguments.cpuEventCounterMask != 0) {
+ listOf(
+ TimeCapture(),
+ CpuEventCounterCapture(
+ MicrobenchmarkPhase.cpuEventCounter,
+ Arguments.cpuEventCounterMask
+ )
+ )
+ } else {
+ listOf(TimeCapture())
+ },
/**
* Set to true to enable capture of `trace("foo") {}` blocks in the output Perfetto trace.
@@ -52,4 +63,10 @@
/** Optional profiler to be used after the primary timing phase. */
val profiler: ProfilerConfig? = null,
+ @Suppress("AutoBoxing") // null is distinct, and boxing cost is trivial (off critical path)
+ @get:Suppress("AutoBoxing") // null is distinct, and boxing cost is trivial (off critical path)
+ val warmupCount: Int? = null,
+ @Suppress("AutoBoxing") // null is distinct, and boxing cost is trivial (off critical path)
+ @get:Suppress("AutoBoxing") // null is distinct, and boxing cost is trivial (off critical path)
+ val measurementCount: Int? = null
)
diff --git a/benchmark/benchmark-common/src/main/java/androidx/benchmark/MicrobenchmarkOutput.kt b/benchmark/benchmark-common/src/main/java/androidx/benchmark/MicrobenchmarkOutput.kt
new file mode 100644
index 0000000..a3fab45
--- /dev/null
+++ b/benchmark/benchmark-common/src/main/java/androidx/benchmark/MicrobenchmarkOutput.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.benchmark
+
+import android.os.Bundle
+import androidx.benchmark.Errors.PREFIX
+import androidx.benchmark.json.BenchmarkData
+
+internal class MicrobenchmarkOutput(
+ val definition: TestDefinition,
+ val metricResults: List<MetricResult>,
+ val profilerResults: List<Profiler.ResultFile>,
+ val totalRunTimeNs: Long,
+ val warmupIterations: Int,
+ val repeatIterations: Int,
+ val thermalThrottleSleepSeconds: Long,
+ val reportMetricsInBundle: Boolean
+) {
+ fun createBundle() =
+ Bundle().apply {
+ if (reportMetricsInBundle) {
+ // these 'legacy' CI output metrics are considered output
+ metricResults.forEach { it.putInBundle(this, PREFIX) }
+ }
+ InstrumentationResultScope(this)
+ .reportSummaryToIde(
+ testName = definition.outputTestName,
+ measurements =
+ Measurements(singleMetrics = metricResults, sampledMetrics = emptyList()),
+ profilerResults = profilerResults
+ )
+ }
+
+ fun createJsonTestResult() =
+ BenchmarkData.TestResult(
+ name = definition.outputMethodName,
+ className = definition.fullClassName,
+ totalRunTimeNs = totalRunTimeNs,
+ metrics = metricResults,
+ warmupIterations = warmupIterations,
+ repeatIterations = repeatIterations,
+ thermalThrottleSleepSeconds = thermalThrottleSleepSeconds,
+ profilerOutputs = profilerResults.map { BenchmarkData.TestResult.ProfilerOutput(it) }
+ )
+}
diff --git a/benchmark/benchmark-common/src/main/java/androidx/benchmark/MicrobenchmarkPhase.kt b/benchmark/benchmark-common/src/main/java/androidx/benchmark/MicrobenchmarkPhase.kt
index 96d686e..8370a6e 100644
--- a/benchmark/benchmark-common/src/main/java/androidx/benchmark/MicrobenchmarkPhase.kt
+++ b/benchmark/benchmark-common/src/main/java/androidx/benchmark/MicrobenchmarkPhase.kt
@@ -4,6 +4,7 @@
import android.util.Log
import androidx.benchmark.CpuEventCounter.Event
import java.util.concurrent.TimeUnit
+import kotlinx.coroutines.delay
internal class MicrobenchmarkPhase(
val label: String,
@@ -60,6 +61,109 @@
else -> false
}
+ internal suspend inline fun execute(
+ traceUniqueName: String,
+ scope: MicrobenchmarkScope,
+ state: MicrobenchmarkRunningState,
+ loopedMeasurementBlock: LoopedMeasurementBlock
+ ) {
+ var thermalThrottleSleepsRemaining = thermalThrottleSleepsMax
+ val loopsPerMeasurement = loopMode.getIterations(state.warmupEstimatedIterationTimeNs)
+ state.maxIterationsPerRepeat =
+ state.maxIterationsPerRepeat.coerceAtLeast(loopsPerMeasurement)
+
+ var phaseProfilerResult: Profiler.ResultFile?
+ try {
+ InMemoryTracing.beginSection(label)
+ if (gcBeforePhase) {
+ // Run GC to avoid memory pressure from previous run from affecting this one.
+ // Note, we don't use System.gc() because it doesn't always have consistent behavior
+ Runtime.getRuntime().gc()
+ }
+ while (true) { // keep running until phase successful
+ try {
+ phaseProfilerResult =
+ profiler?.run {
+ inMemoryTrace("start profiling") {
+ startIfNotRiskingAnrDeadline(
+ traceUniqueName = traceUniqueName,
+ estimatedDurationNs = state.warmupEstimatedIterationTimeNs
+ )
+ }
+ }
+ state.metrics = metricsContainer // needed for pausing
+ metricsContainer.captureInit()
+
+ // warmup the container
+ metricsContainer.captureStart()
+ metricsContainer.captureStop()
+ metricsContainer.captureInit()
+
+ repeat(measurementCount) {
+ // perform measurement
+ metricsContainer.captureStart()
+ loopedMeasurementBlock.invoke(scope, loopsPerMeasurement)
+ metricsContainer.captureStop()
+ state.yieldThreadIfDeadlinePassed()
+ }
+ if (loopMode.warmupManager != null) {
+ // warmup, so retry until complete
+ metricsContainer.captureInit()
+ // Note that warmup is based on repeat time, *not* the timeNs metric, since
+ // we
+ // want to account for paused time during warmup (paused work should
+ // stabilize
+ // too)
+ val lastMeasuredWarmupValue = metricsContainer.peekSingleRepeatTime()
+ if (loopMode.warmupManager.onNextIteration(lastMeasuredWarmupValue)) {
+ state.warmupEstimatedIterationTimeNs = lastMeasuredWarmupValue
+ state.warmupIterations = loopMode.warmupManager.iteration
+ break
+ } else {
+ continue
+ }
+ }
+ } finally {
+ profiler?.run { inMemoryTrace("profiler.stop()") { stop() } }
+ state.yieldThreadIfDeadlinePassed()
+ }
+ if (!ThrottleDetector.isDeviceThermalThrottled()) {
+ // not thermal throttled, phase complete
+ break
+ } else {
+ // thermal throttled! delay and retry!
+ Log.d(
+ BenchmarkState.TAG,
+ "THERMAL THROTTLE DETECTED, DELAYING FOR " +
+ "${Arguments.thermalThrottleSleepDurationSeconds} SECONDS"
+ )
+ val startTimeNs = System.nanoTime()
+ inMemoryTrace("Sleep due to Thermal Throttle") {
+ delay(
+ TimeUnit.SECONDS.toMillis(Arguments.thermalThrottleSleepDurationSeconds)
+ )
+ }
+ val sleepTimeNs = System.nanoTime() - startTimeNs
+ state.totalThermalThrottleSleepSeconds +=
+ TimeUnit.NANOSECONDS.toSeconds(sleepTimeNs)
+ thermalThrottleSleepsRemaining--
+ if (thermalThrottleSleepsRemaining <= 0) break
+ }
+ }
+ } finally {
+ InMemoryTracing.endSection()
+ }
+ if (loopMode.warmupManager == null) {
+ // Save captured metrics except during warmup, where we intentionally discard
+ state.metricResults.addAll(
+ metricsContainer.captureFinished(maxIterations = loopsPerMeasurement)
+ )
+ }
+ if (phaseProfilerResult != null) {
+ state.profilerResults.add(phaseProfilerResult)
+ }
+ }
+
internal sealed class LoopMode(val warmupManager: WarmupManager? = null) {
/** Warmup looping mode - reports a single iteration, but there is specialized code in */
class Warmup(warmupManager: WarmupManager) : LoopMode(warmupManager) {
@@ -207,6 +311,20 @@
val measurementCount: Int?,
val metrics: Array<MetricCapture>,
) {
+ constructor(
+ microbenchmarkConfig: MicrobenchmarkConfig,
+ simplifiedTimingOnlyMode: Boolean
+ ) : this(
+ dryRunMode = Arguments.dryRunMode,
+ startupMode = Arguments.startupMode,
+ profiler = microbenchmarkConfig.profiler?.profiler ?: Arguments.profiler,
+ profilerPerfCompareMode = Arguments.profilerPerfCompareEnable,
+ warmupCount = microbenchmarkConfig.warmupCount,
+ measurementCount = Arguments.iterations ?: microbenchmarkConfig.measurementCount,
+ simplifiedTimingOnlyMode = simplifiedTimingOnlyMode,
+ metrics = microbenchmarkConfig.metrics.toTypedArray()
+ )
+
val warmupManager = WarmupManager(overrideCount = warmupCount)
init {
diff --git a/benchmark/benchmark-common/src/main/java/androidx/benchmark/Profiler.kt b/benchmark/benchmark-common/src/main/java/androidx/benchmark/Profiler.kt
index 950f6d7..931af7b 100644
--- a/benchmark/benchmark-common/src/main/java/androidx/benchmark/Profiler.kt
+++ b/benchmark/benchmark-common/src/main/java/androidx/benchmark/Profiler.kt
@@ -19,10 +19,13 @@
import android.annotation.SuppressLint
import android.os.Build
import android.os.Debug
+import android.os.Looper
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.annotation.RestrictTo
import androidx.annotation.VisibleForTesting
+import androidx.benchmark.BenchmarkState.Companion.METHOD_TRACING_ESTIMATED_SLOWDOWN_FACTOR
+import androidx.benchmark.BenchmarkState.Companion.METHOD_TRACING_MAX_DURATION_NS
import androidx.benchmark.BenchmarkState.Companion.TAG
import androidx.benchmark.Outputs.dateToFileName
import androidx.benchmark.json.BenchmarkData.TestResult.ProfilerOutput
@@ -99,6 +102,35 @@
abstract fun start(traceUniqueName: String): ResultFile?
+ /** Start profiling only if expected trace duration is unlikely to trigger an ANR */
+ fun startIfNotRiskingAnrDeadline(
+ traceUniqueName: String,
+ estimatedDurationNs: Long
+ ): ResultFile? {
+ val estimatedMethodTraceDurNs =
+ estimatedDurationNs * METHOD_TRACING_ESTIMATED_SLOWDOWN_FACTOR
+ return if (
+ this == MethodTracing &&
+ Looper.myLooper() == Looper.getMainLooper() &&
+ estimatedMethodTraceDurNs > METHOD_TRACING_MAX_DURATION_NS &&
+ Arguments.profilerSkipWhenDurationRisksAnr
+ ) {
+ val expectedDurSec = estimatedMethodTraceDurNs / 1_000_000_000.0
+ InstrumentationResults.scheduleIdeWarningOnNextReport(
+ """
+ Skipping method trace of estimated duration $expectedDurSec sec to avoid ANR
+
+ To disable this behavior, set instrumentation arg:
+ androidx.benchmark.profiling.skipWhenDurationRisksAnr = false
+ """
+ .trimIndent()
+ )
+ null
+ } else {
+ start(traceUniqueName)
+ }
+ }
+
abstract fun stop()
internal open fun config(packageNames: List<String>): StackSamplingConfig? = null
diff --git a/benchmark/benchmark-common/src/main/java/androidx/benchmark/TestDefinition.kt b/benchmark/benchmark-common/src/main/java/androidx/benchmark/TestDefinition.kt
new file mode 100644
index 0000000..69ff620
--- /dev/null
+++ b/benchmark/benchmark-common/src/main/java/androidx/benchmark/TestDefinition.kt
@@ -0,0 +1,32 @@
+/*
+ * 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.benchmark
+
+import androidx.annotation.RestrictTo
+import androidx.benchmark.Errors.PREFIX
+
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+class TestDefinition(
+ val fullClassName: String,
+ val simpleClassName: String,
+ val methodName: String,
+) {
+ val outputMethodName: String = PREFIX + methodName
+ val outputTestName: String = "$PREFIX$simpleClassName.$methodName"
+ val fullNameUnsanitized: String = "$fullClassName#$methodName"
+ val traceUniqueName: String = simpleClassName + "_" + methodName
+}
diff --git a/benchmark/benchmark-common/src/main/java/androidx/benchmark/ThrottleDetector.kt b/benchmark/benchmark-common/src/main/java/androidx/benchmark/ThrottleDetector.kt
index f89e6cc..9b0b435 100644
--- a/benchmark/benchmark-common/src/main/java/androidx/benchmark/ThrottleDetector.kt
+++ b/benchmark/benchmark-common/src/main/java/androidx/benchmark/ThrottleDetector.kt
@@ -33,12 +33,12 @@
repeat(10) { System.arraycopy(a, 0, b, 0, a.size) }
}
- internal fun measureWorkNs(): Double {
+ internal fun measureWorkNs1(): Double {
// Access a non-trivial amount of data to try and 'reset' any cache state.
// Have observed this to give more consistent performance when clocks are unlocked.
copySomeData()
- val state = BenchmarkState(simplifiedTimingOnlyMode = true)
+ val state = BenchmarkStateLegacy(simplifiedTimingOnlyMode = true)
val sourceMatrix = FloatArray(16) { System.nanoTime().toFloat() }
val resultMatrix = FloatArray(16)
@@ -50,6 +50,20 @@
return state.getMinTimeNanos()
}
+ internal fun measureWorkNs(): Double =
+ inMemoryTrace("measureWorkNs") {
+ // Access a non-trivial amount of data to try and 'reset' any cache state.
+ // Have observed this to give more consistent performance when clocks are unlocked.
+ copySomeData()
+ val sourceMatrix = FloatArray(16) { System.nanoTime().toFloat() }
+ val resultMatrix = FloatArray(16)
+
+ return measureRepeatedCheckNanosReentrant {
+ // Benchmark a trivial consistent task
+ Matrix.translateM(resultMatrix, 0, sourceMatrix, 0, 1F, 2F, 3F)
+ }
+ }
+
/**
* Called to calculate throttling baseline, will be ignored after first call until reset.
*
diff --git a/benchmark/benchmark-common/src/main/java/androidx/benchmark/perfetto/PerfettoCaptureWrapper.kt b/benchmark/benchmark-common/src/main/java/androidx/benchmark/perfetto/PerfettoCaptureWrapper.kt
index 0bfa464..7dd4e03 100644
--- a/benchmark/benchmark-common/src/main/java/androidx/benchmark/perfetto/PerfettoCaptureWrapper.kt
+++ b/benchmark/benchmark-common/src/main/java/androidx/benchmark/perfetto/PerfettoCaptureWrapper.kt
@@ -34,6 +34,9 @@
import androidx.tracing.perfetto.handshake.protocol.ResponseResultCodes.RESULT_CODE_SUCCESS
import java.io.FileOutputStream
import java.lang.RuntimeException
+import kotlin.contracts.ExperimentalContracts
+import kotlin.contracts.InvocationKind
+import kotlin.contracts.contract
/** Wrapper for [PerfettoCapture] which does nothing below API 23. */
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
@@ -109,6 +112,7 @@
}
}
+ @OptIn(ExperimentalContracts::class)
fun record(
fileLabel: String,
config: PerfettoConfig,
@@ -118,6 +122,7 @@
inMemoryTracingLabel: String? = null,
block: () -> Unit
): String? {
+ contract { callsInPlace(block, InvocationKind.EXACTLY_ONCE) }
// skip if Perfetto not supported, or if caller opts out
if (Build.VERSION.SDK_INT < 23 || !isAbiSupported() || !enableTracing) {
block()
diff --git a/benchmark/benchmark-junit4/api/current.txt b/benchmark/benchmark-junit4/api/current.txt
index 9cf3391..9f33a3c 100644
--- a/benchmark/benchmark-junit4/api/current.txt
+++ b/benchmark/benchmark-junit4/api/current.txt
@@ -9,10 +9,15 @@
ctor public BenchmarkRule();
ctor @SuppressCompatibility @androidx.benchmark.ExperimentalBenchmarkConfigApi public BenchmarkRule(androidx.benchmark.MicrobenchmarkConfig config);
method public org.junit.runners.model.Statement apply(org.junit.runners.model.Statement base, org.junit.runner.Description description);
+ method public androidx.benchmark.MicrobenchmarkConfig getConfig();
method public androidx.benchmark.BenchmarkState getState();
+ property public final androidx.benchmark.MicrobenchmarkConfig config;
}
public final class BenchmarkRule.Scope {
+ method public final void pauseMeasurement();
+ method public final void resumeMeasurement();
+ method public final <T> T! runWithMeasurementDisabled(kotlin.jvm.functions.Function0<? extends T!>);
method public inline <T> T runWithTimingDisabled(kotlin.jvm.functions.Function0<? extends T> block);
}
diff --git a/benchmark/benchmark-junit4/api/restricted_current.txt b/benchmark/benchmark-junit4/api/restricted_current.txt
index 13794ef..e34a578 100644
--- a/benchmark/benchmark-junit4/api/restricted_current.txt
+++ b/benchmark/benchmark-junit4/api/restricted_current.txt
@@ -9,11 +9,18 @@
ctor public BenchmarkRule();
ctor @SuppressCompatibility @androidx.benchmark.ExperimentalBenchmarkConfigApi public BenchmarkRule(androidx.benchmark.MicrobenchmarkConfig config);
method public org.junit.runners.model.Statement apply(org.junit.runners.model.Statement base, org.junit.runner.Description description);
+ method public androidx.benchmark.MicrobenchmarkConfig getConfig();
method public androidx.benchmark.BenchmarkState getState();
+ property public final androidx.benchmark.MicrobenchmarkConfig config;
+ property @kotlin.PublishedApi internal final androidx.benchmark.TestDefinition? testDefinition;
+ field @kotlin.PublishedApi internal androidx.benchmark.TestDefinition? testDefinition;
}
public final class BenchmarkRule.Scope {
method @kotlin.PublishedApi internal androidx.benchmark.BenchmarkState getOuterState();
+ method public final void pauseMeasurement();
+ method public final void resumeMeasurement();
+ method public final <T> T! runWithMeasurementDisabled(kotlin.jvm.functions.Function0<? extends T!>);
method public inline <T> T runWithTimingDisabled(kotlin.jvm.functions.Function0<? extends T> block);
}
diff --git a/benchmark/benchmark-junit4/src/androidTest/java/androidx/benchmark/junit4/BenchmarkRuleAnnotationTest.kt b/benchmark/benchmark-junit4/src/androidTest/java/androidx/benchmark/junit4/BenchmarkRuleAnnotationTest.kt
index 59039df..ed17189 100644
--- a/benchmark/benchmark-junit4/src/androidTest/java/androidx/benchmark/junit4/BenchmarkRuleAnnotationTest.kt
+++ b/benchmark/benchmark-junit4/src/androidTest/java/androidx/benchmark/junit4/BenchmarkRuleAnnotationTest.kt
@@ -37,4 +37,9 @@
fun throwsIfNotAnnotatedMeasure() {
unannotatedRule.measureRepeated {}
}
+
+ @Test(expected = IllegalStateException::class)
+ fun throwsIfNotAnnotatedMeasureMain() {
+ unannotatedRule.measureRepeatedOnMainThread {}
+ }
}
diff --git a/benchmark/benchmark-junit4/src/androidTest/java/androidx/benchmark/junit4/BenchmarkRuleLegacyTest.kt b/benchmark/benchmark-junit4/src/androidTest/java/androidx/benchmark/junit4/BenchmarkRuleLegacyTest.kt
new file mode 100644
index 0000000..84ccc31
--- /dev/null
+++ b/benchmark/benchmark-junit4/src/androidTest/java/androidx/benchmark/junit4/BenchmarkRuleLegacyTest.kt
@@ -0,0 +1,88 @@
+/*
+ * Copyright 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.benchmark.junit4
+
+import android.annotation.SuppressLint
+import android.os.Looper
+import androidx.test.annotation.UiThreadTest
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.LargeTest
+import androidx.test.filters.SmallTest
+import java.util.concurrent.TimeUnit
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertTrue
+import org.junit.Assert
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@LargeTest
+@RunWith(AndroidJUnit4::class)
+class BenchmarkRuleLegacyTest {
+ @get:Rule val benchmarkRule = BenchmarkRuleLegacy()
+
+ @SuppressLint("BanThreadSleep") // doesn't affect runtime, since we have target time
+ @Test
+ fun runWithTimingDisabled() {
+ benchmarkRule.measureRepeated { runWithTimingDisabled { Thread.sleep(5) } }
+ val min = benchmarkRule.getState().getMinTimeNanos()
+ Assert.assertTrue(
+ "minimum $min should be less than 1ms",
+ min < TimeUnit.MILLISECONDS.toNanos(1)
+ )
+ }
+
+ @Test
+ fun measureRepeatedMainThread() {
+ var scheduledOnMain = false
+
+ // validate rethrow behavior
+ assertFailsWith<IllegalStateException> {
+ benchmarkRule.measureRepeatedOnMainThread {
+ scheduledOnMain = Looper.myLooper() == Looper.getMainLooper()
+
+ throw IllegalStateException("just a test")
+ }
+ }
+
+ // validate work done on main thread
+ assertTrue(scheduledOnMain)
+
+ // let a benchmark actually run, so "benchmark hasn't finished" isn't thrown
+ benchmarkRule.measureRepeatedOnMainThread {}
+ }
+
+ @SmallTest
+ @Test
+ @UiThreadTest
+ fun measureRepeatedOnMainThread_throwOnMain() {
+ assertEquals(Looper.myLooper(), Looper.getMainLooper())
+ // validate rethrow behavior
+ val exception =
+ assertFailsWith<IllegalStateException> {
+ benchmarkRule.measureRepeatedOnMainThread {
+ // Doesn't matter
+ }
+ }
+ assertTrue(
+ exception.message!!.contains(
+ "Cannot invoke measureRepeatedOnMainThread from the main thread"
+ )
+ )
+ }
+}
diff --git a/benchmark/benchmark-junit4/src/androidTest/java/androidx/benchmark/junit4/BenchmarkRuleTest.kt b/benchmark/benchmark-junit4/src/androidTest/java/androidx/benchmark/junit4/BenchmarkRuleTest.kt
index 8adb35b..f0fa7fe 100644
--- a/benchmark/benchmark-junit4/src/androidTest/java/androidx/benchmark/junit4/BenchmarkRuleTest.kt
+++ b/benchmark/benchmark-junit4/src/androidTest/java/androidx/benchmark/junit4/BenchmarkRuleTest.kt
@@ -16,17 +16,14 @@
package androidx.benchmark.junit4
-import android.annotation.SuppressLint
import android.os.Looper
import androidx.test.annotation.UiThreadTest
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import androidx.test.filters.SmallTest
-import java.util.concurrent.TimeUnit
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
-import org.junit.Assert
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@@ -36,17 +33,6 @@
class BenchmarkRuleTest {
@get:Rule val benchmarkRule: BenchmarkRule = BenchmarkRule()
- @SuppressLint("BanThreadSleep") // doesn't affect runtime, since we have target time
- @Test
- fun runWithTimingDisabled() {
- benchmarkRule.measureRepeated { runWithTimingDisabled { Thread.sleep(5) } }
- val min = benchmarkRule.getState().getMinTimeNanos()
- Assert.assertTrue(
- "minimum $min should be less than 1ms",
- min < TimeUnit.MILLISECONDS.toNanos(1)
- )
- }
-
@Test
fun measureRepeatedMainThread() {
var scheduledOnMain = false
diff --git a/benchmark/benchmark-junit4/src/main/java/androidx/benchmark/junit4/BenchmarkRule.kt b/benchmark/benchmark-junit4/src/main/java/androidx/benchmark/junit4/BenchmarkRule.kt
index b1afdf0..ac56c23 100644
--- a/benchmark/benchmark-junit4/src/main/java/androidx/benchmark/junit4/BenchmarkRule.kt
+++ b/benchmark/benchmark-junit4/src/main/java/androidx/benchmark/junit4/BenchmarkRule.kt
@@ -17,30 +17,18 @@
package androidx.benchmark.junit4
import android.Manifest
-import android.os.Build
import android.os.Looper
-import android.util.Log
import androidx.annotation.RestrictTo
import androidx.benchmark.Arguments
import androidx.benchmark.BenchmarkState
import androidx.benchmark.DeviceInfo
import androidx.benchmark.ExperimentalBenchmarkConfigApi
import androidx.benchmark.MicrobenchmarkConfig
-import androidx.benchmark.perfetto.PerfettoCapture
-import androidx.benchmark.perfetto.PerfettoCaptureWrapper
-import androidx.benchmark.perfetto.PerfettoConfig
-import androidx.benchmark.perfetto.UiState
-import androidx.benchmark.perfetto.appendUiState
-import androidx.test.platform.app.InstrumentationRegistry
-import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
+import androidx.benchmark.MicrobenchmarkRunningState
+import androidx.benchmark.MicrobenchmarkScope
+import androidx.benchmark.TestDefinition
+import androidx.benchmark.measureRepeatedImplWithTracing
import androidx.test.rule.GrantPermissionRule
-import androidx.tracing.Trace
-import androidx.tracing.trace
-import java.io.File
-import java.util.concurrent.ExecutionException
-import java.util.concurrent.FutureTask
-import java.util.concurrent.TimeUnit
-import org.junit.Assert.assertTrue
import org.junit.Assume.assumeFalse
import org.junit.Assume.assumeTrue
import org.junit.rules.RuleChain
@@ -66,25 +54,31 @@
* @sample androidx.benchmark.samples.benchmarkRuleSample
*/
class BenchmarkRule
-private constructor(
- private val config: MicrobenchmarkConfig?,
- /**
- * This param is ignored, and just present to disambiguate the internal (nullable) vs external
- * (non-null) variants of the constructor, since a lint failure occurs if they have the same
- * signature, even if the external variant uses `this(config as MicrobenchmarkConfig?)`.
- *
- * In the future, we should just always pass a "default" config object, which can reference
- * default values from Arguments, but that's a deeper change.
- */
- @Suppress("UNUSED_PARAMETER") ignored: Boolean = true
+@ExperimentalBenchmarkConfigApi
+constructor(
+ val config: MicrobenchmarkConfig,
) : TestRule {
- constructor() : this(config = null, ignored = true)
+ constructor() : this(config = MicrobenchmarkConfig())
- @ExperimentalBenchmarkConfigApi
- constructor(config: MicrobenchmarkConfig) : this(config, ignored = true)
+ @PublishedApi
+ internal // synthetic access
+ var testDefinition: TestDefinition? = null
+ get() {
+ throwIfNotApplied()
+ return field
+ }
internal // synthetic access
- var internalState = BenchmarkState(config)
+ var internalState: BenchmarkState? = null
+
+ internal fun throwIfNotApplied() {
+ if (!applied) {
+ throw IllegalStateException(
+ "Cannot get state before BenchmarkRule is applied to a test. Check that your " +
+ "BenchmarkRule is annotated correctly (@Rule in Java, @get:Rule in Kotlin)."
+ )
+ }
+ }
/**
* Object used for benchmarking in Java.
@@ -106,56 +100,65 @@
*
* @throws [IllegalStateException] if the BenchmarkRule isn't correctly applied to a test.
*/
- public fun getState(): BenchmarkState {
+ fun getState(): BenchmarkState {
// Note: this is an explicit method instead of an accessor to help convey it's only for Java
// Kotlin users should call the [measureRepeated] method.
- if (!applied) {
- throw IllegalStateException(
- "Cannot get state before BenchmarkRule is applied to a test. Check that your " +
- "BenchmarkRule is annotated correctly (@Rule in Java, @get:Rule in Kotlin)."
- )
- }
- return internalState
+ throwIfNotApplied()
+ return internalState!!
}
internal // synthetic access
var applied = false
- @get:RestrictTo(RestrictTo.Scope.LIBRARY) public val scope: Scope = Scope()
+ // can we avoid published API here?
+ @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+ @get:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+ val scopeFactory: (MicrobenchmarkRunningState) -> MicrobenchmarkScope = { runningState ->
+ Scope(runningState)
+ }
- /** Handle used for controlling timing during [measureRepeated]. */
- public inner class Scope internal constructor() {
- /**
- * Disable timing for a block of code.
+ /** Handle used for controlling measurement during [measureRepeated]. */
+ inner class Scope internal constructor(internal val state: MicrobenchmarkRunningState) :
+
+ /*
+ * Ideally, the microbenchmark scope concept would live entirely in benchmark-common so that
+ * we can define it in common code, without a dependence on benchmark-junit / JUnit.
*
- * Used for disabling timing for work that isn't part of the benchmark:
+ * To preserve compatibility though, we have to preserve this copy, which causes the
+ * following layering compromises:
+ *
+ * 1. The top level `measureRepeated` function accepts a `scopeFactory` function to let it
+ * construct a BenchmarkRule.Scope object, even though it's a trivial wrapper around
+ * MicrobenchmarkScope.
+ *
+ * 2. To let scope-ish calls go from BenchmarkState -> MicrobenchmarkScope ->
+ * MicrobenchmarkRunningState, BenchmarkState has a LIBRARY_GROUP mutable var, so both
+ * legacy BenchmarkState.keepRunning() and modern BenchmarkRule.measureRepeated have to
+ * separately set BenchmarkState.scope after scope is constructed. This stinks, but it's the
+ * price of compat. Both are needed only because it was valid to pause timing in a pure
+ * Kotlin benchmark with rule.getState().pauseTiming()
+ */
+ MicrobenchmarkScope(state) {
+
+ /**
+ * Disable measurement for a block of code.
+ *
+ * Used for disabling timing/measurement for work that isn't part of the benchmark:
* - When constructing per-loop randomized inputs for operations with caching,
* - Controlling which parts of multi-stage work are measured (e.g. View measure/layout)
- * - Disabling timing during per-loop verification
+ * - Per-loop verification
*
* @sample androidx.benchmark.samples.runWithTimingDisabledSample
*/
- public inline fun <T> runWithTimingDisabled(block: () -> T): T {
- getOuterState().pauseTiming()
- // Note: we only bother with tracing for the runWithTimingDisabled function for
- // Kotlin callers, as it's more difficult to corrupt the trace with incorrectly
- // paired BenchmarkState pause/resume calls
- val ret: T =
- try {
- // TODO: use `trace() {}` instead of this manual try/finally,
- // once the block parameter is marked crossinline.
- Trace.beginSection("runWithTimingDisabled")
- block()
- } finally {
- Trace.endSection()
- }
- getOuterState().resumeTiming()
- return ret
+ inline fun <T> runWithTimingDisabled(block: () -> T): T {
+ return runWithMeasurementDisabled(block)
}
/**
- * Allows the inline function [runWithTimingDisabled] to be called outside of this scope.
+ * Allows the inline function [runWithTimingDisabled] to be called outside of this scope for
+ * compat with compiled code using old versions of the library.
*/
+ @Suppress("unused")
@PublishedApi
internal fun getOuterState(): BenchmarkState {
return getState()
@@ -184,84 +187,18 @@
)
}
- var invokeMethodName = description.methodName
- Log.d(TAG, "-- Running ${description.className}#$invokeMethodName --")
+ testDefinition =
+ TestDefinition(
+ fullClassName = description.className,
+ simpleClassName = description.testClass.simpleName,
+ methodName = description.methodName
+ )
- // validate and simplify the function name.
- // First, remove the "test" prefix which normally comes from CTS test.
- // Then make sure the [subTestName] is valid, not just numbers like [0].
- if (invokeMethodName.startsWith("test")) {
- assertTrue("The test name $invokeMethodName is too short", invokeMethodName.length > 5)
- invokeMethodName =
- invokeMethodName.substring(4, 5).lowercase() + invokeMethodName.substring(5)
- }
- val uniqueName = description.testClass.simpleName + "_" + invokeMethodName
- internalState.traceUniqueName = uniqueName
+ // only used with legacy getState() API, which is intended to be deprecated in the future,
+ // to be replaced by Java variant of measureRepeated
+ internalState = BenchmarkState(testDefinition!!, config)
- val tracePath =
- PerfettoCaptureWrapper()
- .record(
- fileLabel = uniqueName,
- config =
- PerfettoConfig.Benchmark(
- appTagPackages =
- if (config?.traceAppTagEnabled == true) {
- listOf(
- InstrumentationRegistry.getInstrumentation()
- .context
- .packageName
- )
- } else {
- emptyList()
- },
- useStackSamplingConfig = false
- ),
- // TODO(290918736): add support for Perfetto SDK Tracing in
- // Microbenchmark in other cases, outside of MicrobenchmarkConfig
- perfettoSdkConfig =
- if (
- config?.perfettoSdkTracingEnabled == true &&
- Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
- ) {
- PerfettoCapture.PerfettoSdkConfig(
- getInstrumentation().context.packageName,
- PerfettoCapture.PerfettoSdkConfig.InitialProcessState.Alive
- )
- } else {
- null
- },
-
- // Optimize throughput in dryRunMode, since trace isn't useful, and extremely
- // expensive on some emulators. Could alternately use UserspaceTracing if
- // desired
- // Additionally, skip on misconfigured devices to still enable benchmarking.
- enableTracing = !Arguments.dryRunMode && !DeviceInfo.misconfiguredForTracing,
- inMemoryTracingLabel = "Microbenchmark"
- ) {
- trace(description.displayName) { base.evaluate() }
- }
- ?.apply {
- // trace completed, and copied into shell writeable dir
- val file = File(this)
- file.appendUiState(
- UiState(
- timelineStart = null,
- timelineEnd = null,
- highlightPackage = getInstrumentation().context.packageName
- )
- )
- }
-
- internalState.report(
- fullClassName = description.className,
- simpleClassName = description.testClass.simpleName,
- methodName = invokeMethodName,
- perfettoTracePath = tracePath
- )
- }
-
- internal companion object {
- private const val TAG = "Benchmark"
+ base.evaluate()
}
}
@@ -273,26 +210,27 @@
*/
public inline fun BenchmarkRule.measureRepeated(crossinline block: BenchmarkRule.Scope.() -> Unit) {
// Note: this is an extension function to discourage calling from Java.
-
if (Arguments.throwOnMainThreadMeasureRepeated) {
check(Looper.myLooper() != Looper.getMainLooper()) {
"Cannot invoke measureRepeated from the main thread. Instead use" +
" measureRepeatedOnMainThread()"
}
}
-
- // Extract members to locals, to ensure we check #applied, and we don't hit accessors
- val localState = getState()
- val localScope = scope
-
- try {
- while (localState.keepRunningInline()) {
- block(localScope)
+ measureRepeatedImplWithTracing(
+ postToMainThread = false,
+ definition = testDefinition!!,
+ config = config,
+ scopeFactory = scopeFactory, // inflate custom Scope object to respect/maintain public API
+ loopedMeasurementBlock = { scope, iterations ->
+ val ruleScope = scope as BenchmarkRule.Scope // cast back to outer scope type
+ getState().scope = scope
+ var remainingIterations = iterations
+ do {
+ block.invoke(ruleScope)
+ remainingIterations--
+ } while (remainingIterations > 0)
}
- } catch (t: Throwable) {
- localState.cleanupBeforeThrow()
- throw t
- }
+ )
}
/**
@@ -315,76 +253,21 @@
"Cannot invoke measureRepeatedOnMainThread from the main thread"
}
- var resumeScheduled = false
- while (true) {
- val task = FutureTask {
- // Extract members to locals, to ensure we check #applied, and we don't hit accessors
- val localState = getState()
- val localScope = scope
-
- val initialTimeNs = System.nanoTime()
- // we try to stop next measurement after soft deadline...
- val softDeadlineNs = initialTimeNs + TimeUnit.SECONDS.toNanos(2)
- // ... and throw if took longer than hard deadline
- val hardDeadlineNs = initialTimeNs + TimeUnit.SECONDS.toNanos(10)
- var timeNs: Long = 0
-
- try {
- Trace.beginSection("measureRepeatedOnMainThread task")
-
- if (resumeScheduled) {
- localState.resumeTiming()
- }
-
- do {
- // note that this function can still block for considerable time, e.g. when
- // setting up / tearing down profiling, or sleeping to let the device cool off.
- if (!localState.keepRunningInline()) {
- return@FutureTask false
- }
-
- block(localScope)
-
- // Avoid checking for deadline on all but last iteration per measurement,
- // to amortize cost of System.nanoTime(). Without this optimization, minimum
- // measured time can be 10x higher.
- if (localState.getIterationsRemaining() != 1) {
- continue
- }
- timeNs = System.nanoTime()
- } while (timeNs <= softDeadlineNs)
-
- resumeScheduled = true
- localState.pauseTiming()
-
- if (timeNs > hardDeadlineNs && Arguments.measureRepeatedOnMainThrowOnDeadline) {
- localState.cleanupBeforeThrow()
- val overrunInSec = (timeNs - hardDeadlineNs) / 1_000_000_000.0
- throw IllegalStateException(
- "Benchmark loop overran hard time limit by $overrunInSec seconds"
- )
- }
-
- return@FutureTask true // continue
- } finally {
- Trace.endSection()
- }
+ measureRepeatedImplWithTracing(
+ postToMainThread = true,
+ definition = testDefinition!!,
+ config = config,
+ scopeFactory = scopeFactory, // inflate custom Scope object to respect/maintain public API
+ loopedMeasurementBlock = { scope, iterations ->
+ val ruleScope = scope as BenchmarkRule.Scope // cast back to outer scope type
+ getState().scope = scope
+ var remainingIterations = iterations
+ do {
+ block.invoke(ruleScope)
+ remainingIterations--
+ } while (remainingIterations > 0)
}
- getInstrumentation().runOnMainSync(task)
- val shouldContinue: Boolean =
- try {
- // Ideally we'd implement the delay here, as a timeout, but we can't do this until
- // have a way to move thermal throttle sleeping off the UI thread.
- task.get()
- } catch (e: ExecutionException) {
- // Expose the original exception
- throw e.cause!!
- }
- if (!shouldContinue) {
- // all done
- break
- }
- }
+ )
}
internal inline fun Statement(crossinline evaluate: () -> Unit) =
diff --git a/benchmark/benchmark-junit4/src/main/java/androidx/benchmark/junit4/BenchmarkRuleLegacy.kt b/benchmark/benchmark-junit4/src/main/java/androidx/benchmark/junit4/BenchmarkRuleLegacy.kt
new file mode 100644
index 0000000..cdbe16e
--- /dev/null
+++ b/benchmark/benchmark-junit4/src/main/java/androidx/benchmark/junit4/BenchmarkRuleLegacy.kt
@@ -0,0 +1,389 @@
+/*
+ * Copyright 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.benchmark.junit4
+
+import android.Manifest
+import android.os.Build
+import android.os.Looper
+import android.util.Log
+import androidx.annotation.RestrictTo
+import androidx.benchmark.Arguments
+import androidx.benchmark.BenchmarkStateLegacy
+import androidx.benchmark.DeviceInfo
+import androidx.benchmark.ExperimentalBenchmarkConfigApi
+import androidx.benchmark.MicrobenchmarkConfig
+import androidx.benchmark.perfetto.PerfettoCapture
+import androidx.benchmark.perfetto.PerfettoCaptureWrapper
+import androidx.benchmark.perfetto.PerfettoConfig
+import androidx.benchmark.perfetto.UiState
+import androidx.benchmark.perfetto.appendUiState
+import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
+import androidx.test.rule.GrantPermissionRule
+import androidx.tracing.Trace
+import androidx.tracing.trace
+import java.io.File
+import java.util.concurrent.ExecutionException
+import java.util.concurrent.FutureTask
+import java.util.concurrent.TimeUnit
+import org.junit.Assert.assertTrue
+import org.junit.Assume.assumeFalse
+import org.junit.Assume.assumeTrue
+import org.junit.rules.RuleChain
+import org.junit.rules.TestRule
+import org.junit.runner.Description
+import org.junit.runners.model.Statement
+
+/**
+ * JUnit rule for benchmarking code on an Android device.
+ *
+ * In Kotlin, benchmark with [measureRepeated]. In Java, use [getState].
+ *
+ * Benchmark results will be output:
+ * - Summary in Android Studio in the test log
+ * - In JSON format, on the host
+ * - In simple form in Logcat with the tag "Benchmark"
+ *
+ * Every test in the Class using this @Rule must contain a single benchmark.
+ *
+ * See the [Benchmark Guide](https://developer.android.com/studio/profile/benchmark) for more
+ * information on writing Benchmarks.
+ *
+ * @sample androidx.benchmark.samples.benchmarkRuleSample
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+class BenchmarkRuleLegacy
+private constructor(
+ private val config: MicrobenchmarkConfig?,
+ /**
+ * This param is ignored, and just present to disambiguate the internal (nullable) vs external
+ * (non-null) variants of the constructor, since a lint failure occurs if they have the same
+ * signature, even if the external variant uses `this(config as MicrobenchmarkConfig?)`.
+ *
+ * In the future, we should just always pass a "default" config object, which can reference
+ * default values from Arguments, but that's a deeper change.
+ */
+ @Suppress("UNUSED_PARAMETER") ignored: Boolean = true
+) : TestRule {
+ constructor() : this(config = null, ignored = true)
+
+ @ExperimentalBenchmarkConfigApi
+ constructor(config: MicrobenchmarkConfig) : this(config, ignored = true)
+
+ internal // synthetic access
+ var internalState = BenchmarkStateLegacy(config)
+
+ /**
+ * Object used for benchmarking in Java.
+ *
+ * ```java
+ * @Rule
+ * public BenchmarkRule benchmarkRule = new BenchmarkRule();
+ *
+ * @Test
+ * public void myBenchmark() {
+ * ...
+ * BenchmarkState state = benchmarkRule.getBenchmarkState();
+ * while (state.keepRunning()) {
+ * doSomeWork();
+ * }
+ * ...
+ * }
+ * ```
+ *
+ * @throws [IllegalStateException] if the BenchmarkRule isn't correctly applied to a test.
+ */
+ public fun getState(): BenchmarkStateLegacy {
+ // Note: this is an explicit method instead of an accessor to help convey it's only for Java
+ // Kotlin users should call the [measureRepeated] method.
+ if (!applied) {
+ throw IllegalStateException(
+ "Cannot get state before BenchmarkRule is applied to a test. Check that your " +
+ "BenchmarkRule is annotated correctly (@Rule in Java, @get:Rule in Kotlin)."
+ )
+ }
+ return internalState
+ }
+
+ internal // synthetic access
+ var applied = false
+
+ @get:RestrictTo(RestrictTo.Scope.LIBRARY) public val scope: Scope = Scope()
+
+ /** Handle used for controlling timing during [measureRepeated]. */
+ inner class Scope internal constructor() {
+ /**
+ * Disable timing for a block of code.
+ *
+ * Used for disabling timing for work that isn't part of the benchmark:
+ * - When constructing per-loop randomized inputs for operations with caching,
+ * - Controlling which parts of multi-stage work are measured (e.g. View measure/layout)
+ * - Disabling timing during per-loop verification
+ *
+ * @sample androidx.benchmark.samples.runWithTimingDisabledSample
+ */
+ public inline fun <T> runWithTimingDisabled(block: () -> T): T {
+ getOuterState().pauseTiming()
+ // Note: we only bother with tracing for the runWithTimingDisabled function for
+ // Kotlin callers, as it's more difficult to corrupt the trace with incorrectly
+ // paired BenchmarkState pause/resume calls
+ val ret: T =
+ try {
+ // TODO: use `trace() {}` instead of this manual try/finally,
+ // once the block parameter is marked crossinline.
+ Trace.beginSection("runWithTimingDisabled")
+ block()
+ } finally {
+ Trace.endSection()
+ }
+ getOuterState().resumeTiming()
+ return ret
+ }
+
+ /**
+ * Allows the inline function [runWithTimingDisabled] to be called outside of this scope.
+ */
+ @Suppress("ShowingMemberInHiddenClass")
+ @PublishedApi
+ internal fun getOuterState(): BenchmarkStateLegacy {
+ return getState()
+ }
+ }
+
+ override fun apply(base: Statement, description: Description): Statement {
+ return RuleChain.outerRule(
+ GrantPermissionRule.grant(Manifest.permission.WRITE_EXTERNAL_STORAGE)
+ )
+ .around(::applyInternal)
+ .apply(base, description)
+ }
+
+ private fun applyInternal(base: Statement, description: Description) = Statement {
+ applied = true
+
+ assumeTrue(Arguments.RuleType.Microbenchmark in Arguments.enabledRules)
+
+ // When running on emulator and argument `skipOnEmulator` is passed,
+ // the test is skipped.
+ if (Arguments.skipBenchmarksOnEmulator) {
+ assumeFalse(
+ "Skipping test because it's running on emulator and `skipOnEmulator` is enabled",
+ DeviceInfo.isEmulator
+ )
+ }
+
+ var invokeMethodName = description.methodName
+ Log.d(TAG, "-- Running ${description.className}#$invokeMethodName --")
+
+ // validate and simplify the function name.
+ // First, remove the "test" prefix which normally comes from CTS test.
+ // Then make sure the [subTestName] is valid, not just numbers like [0].
+ if (invokeMethodName.startsWith("test")) {
+ assertTrue("The test name $invokeMethodName is too short", invokeMethodName.length > 5)
+ invokeMethodName =
+ invokeMethodName.substring(4, 5).lowercase() + invokeMethodName.substring(5)
+ }
+ val uniqueName = description.testClass.simpleName + "_" + invokeMethodName
+ internalState.traceUniqueName = uniqueName
+
+ val tracePath =
+ PerfettoCaptureWrapper()
+ .record(
+ fileLabel = uniqueName,
+ config =
+ PerfettoConfig.Benchmark(
+ appTagPackages =
+ if (config?.traceAppTagEnabled == true) {
+ listOf(getInstrumentation().context.packageName)
+ } else {
+ emptyList()
+ },
+ useStackSamplingConfig = false
+ ),
+ // TODO(290918736): add support for Perfetto SDK Tracing in
+ // Microbenchmark in other cases, outside of MicrobenchmarkConfig
+ perfettoSdkConfig =
+ if (
+ config?.perfettoSdkTracingEnabled == true &&
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
+ ) {
+ PerfettoCapture.PerfettoSdkConfig(
+ getInstrumentation().context.packageName,
+ PerfettoCapture.PerfettoSdkConfig.InitialProcessState.Alive
+ )
+ } else {
+ null
+ },
+
+ // Optimize throughput in dryRunMode, since trace isn't useful, and extremely
+ // expensive on some emulators. Could alternately use UserspaceTracing if
+ // desired
+ // Additionally, skip on misconfigured devices to still enable benchmarking.
+ enableTracing = !Arguments.dryRunMode && !DeviceInfo.misconfiguredForTracing,
+ inMemoryTracingLabel = "Microbenchmark"
+ ) {
+ trace(description.displayName) { base.evaluate() }
+ }
+ ?.apply {
+ // trace completed, and copied into shell writeable dir
+ val file = File(this)
+ file.appendUiState(
+ UiState(
+ timelineStart = null,
+ timelineEnd = null,
+ highlightPackage = getInstrumentation().context.packageName
+ )
+ )
+ }
+
+ internalState.report(
+ fullClassName = description.className,
+ simpleClassName = description.testClass.simpleName,
+ methodName = invokeMethodName,
+ perfettoTracePath = tracePath
+ )
+ }
+
+ internal companion object {
+ private const val TAG = "Benchmark"
+ }
+}
+
+/**
+ * Benchmark a block of code.
+ *
+ * @param block The block of code to benchmark.
+ * @sample androidx.benchmark.samples.benchmarkRuleSample
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+inline fun BenchmarkRuleLegacy.measureRepeated(
+ crossinline block: BenchmarkRuleLegacy.Scope.() -> Unit
+) {
+ // Note: this is an extension function to discourage calling from Java.
+
+ if (Arguments.throwOnMainThreadMeasureRepeated) {
+ check(Looper.myLooper() != Looper.getMainLooper()) {
+ "Cannot invoke measureRepeated from the main thread. Instead use" +
+ " measureRepeatedOnMainThread()"
+ }
+ }
+
+ // Extract members to locals, to ensure we check #applied, and we don't hit accessors
+ val localState = getState()
+ val localScope = scope
+
+ try {
+ while (localState.keepRunningInline()) {
+ block(localScope)
+ }
+ } catch (t: Throwable) {
+ localState.cleanupBeforeThrow()
+ throw t
+ }
+}
+
+/**
+ * Benchmark a block of code, which runs on the main thread, and can safely interact with UI.
+ *
+ * While `@UiThreadRule` works for a standard test, it doesn't work for benchmarks of arbitrary
+ * duration, as they may run for much more than 5 seconds and suffer ANRs, especially in continuous
+ * runs.
+ *
+ * @param block The block of code to benchmark.
+ * @throws java.lang.Throwable when an exception is thrown on the main thread.
+ * @throws IllegalStateException if a hard deadline is exceeded while the block is running on the
+ * main thread.
+ * @sample androidx.benchmark.samples.measureRepeatedOnMainThreadSample
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+inline fun BenchmarkRuleLegacy.measureRepeatedOnMainThread(
+ crossinline block: BenchmarkRuleLegacy.Scope.() -> Unit
+) {
+ check(Looper.myLooper() != Looper.getMainLooper()) {
+ "Cannot invoke measureRepeatedOnMainThread from the main thread"
+ }
+
+ var resumeScheduled = false
+ while (true) {
+ val task = FutureTask {
+ // Extract members to locals, to ensure we check #applied, and we don't hit accessors
+ val localState = getState()
+ val localScope = scope
+
+ val initialTimeNs = System.nanoTime()
+ // we try to stop next measurement after soft deadline...
+ val softDeadlineNs = initialTimeNs + TimeUnit.SECONDS.toNanos(2)
+ // ... and throw if took longer than hard deadline
+ val hardDeadlineNs = initialTimeNs + TimeUnit.SECONDS.toNanos(10)
+ var timeNs: Long = 0
+
+ try {
+ Trace.beginSection("measureRepeatedOnMainThread task")
+
+ if (resumeScheduled) {
+ localState.resumeTiming()
+ }
+
+ do {
+ // note that this function can still block for considerable time, e.g. when
+ // setting up / tearing down profiling, or sleeping to let the device cool off.
+ if (!localState.keepRunningInline()) {
+ return@FutureTask false
+ }
+
+ block(localScope)
+
+ // Avoid checking for deadline on all but last iteration per measurement,
+ // to amortize cost of System.nanoTime(). Without this optimization, minimum
+ // measured time can be 10x higher.
+ if (localState.getIterationsRemaining() != 1) {
+ continue
+ }
+ timeNs = System.nanoTime()
+ } while (timeNs <= softDeadlineNs)
+
+ resumeScheduled = true
+ localState.pauseTiming()
+
+ if (timeNs > hardDeadlineNs && Arguments.measureRepeatedOnMainThrowOnDeadline) {
+ localState.cleanupBeforeThrow()
+ val overrunInSec = (timeNs - hardDeadlineNs) / 1_000_000_000.0
+ throw IllegalStateException(
+ "Benchmark loop overran hard time limit by $overrunInSec seconds"
+ )
+ }
+
+ return@FutureTask true // continue
+ } finally {
+ Trace.endSection()
+ }
+ }
+ getInstrumentation().runOnMainSync(task)
+ val shouldContinue: Boolean =
+ try {
+ // Ideally we'd implement the delay here, as a timeout, but we can't do this until
+ // have a way to move thermal throttle sleeping off the UI thread.
+ task.get()
+ } catch (e: ExecutionException) {
+ // Expose the original exception
+ throw e.cause!!
+ }
+ if (!shouldContinue) {
+ // all done
+ break
+ }
+ }
+}
diff --git a/benchmark/benchmark/src/androidTest/java/androidx/benchmark/benchmark/BenchmarkStateBenchmark.kt b/benchmark/benchmark/src/androidTest/java/androidx/benchmark/benchmark/BenchmarkStateBenchmark.kt
index a1d8c31..f71a6a7 100644
--- a/benchmark/benchmark/src/androidTest/java/androidx/benchmark/benchmark/BenchmarkStateBenchmark.kt
+++ b/benchmark/benchmark/src/androidTest/java/androidx/benchmark/benchmark/BenchmarkStateBenchmark.kt
@@ -17,7 +17,7 @@
package androidx.benchmark.benchmark
import androidx.benchmark.BenchmarkState
-import androidx.benchmark.ExperimentalBenchmarkStateApi
+import androidx.benchmark.TestDefinition
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import org.junit.Test
@@ -26,13 +26,19 @@
@LargeTest
@RunWith(AndroidJUnit4::class)
class BenchmarkStateBenchmark {
- @OptIn(ExperimentalBenchmarkStateApi::class)
+ /** Proof of concept benchmark using BenchmarkState without a JUnit Rule */
@Test
fun nothing() {
- val state = BenchmarkState(warmupCount = 10, repeatCount = 10)
+ val state =
+ BenchmarkState(
+ TestDefinition(
+ "androidx.benchmark.benchmark.BenchmarkState2Benchmark",
+ "BenchmarkState2Benchmark",
+ "increment"
+ ),
+ )
while (state.keepRunning()) {
- // do nothing
+ //
}
- state.getMeasurementTimeNs()
}
}
diff --git a/benchmark/benchmark/src/androidTest/java/androidx/benchmark/benchmark/BenchmarkStateLegacyBenchmark.kt b/benchmark/benchmark/src/androidTest/java/androidx/benchmark/benchmark/BenchmarkStateLegacyBenchmark.kt
new file mode 100644
index 0000000..2824881
--- /dev/null
+++ b/benchmark/benchmark/src/androidTest/java/androidx/benchmark/benchmark/BenchmarkStateLegacyBenchmark.kt
@@ -0,0 +1,38 @@
+/*
+ * Copyright 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.benchmark.benchmark
+
+import androidx.benchmark.BenchmarkStateLegacy
+import androidx.benchmark.ExperimentalBenchmarkStateApi
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.LargeTest
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@LargeTest
+@RunWith(AndroidJUnit4::class)
+class BenchmarkStateLegacyBenchmark {
+ @OptIn(ExperimentalBenchmarkStateApi::class)
+ @Test
+ fun nothing() {
+ val state = BenchmarkStateLegacy(warmupCount = 10, repeatCount = 10)
+ while (state.keepRunning()) {
+ // do nothing
+ }
+ state.getMeasurementTimeNs()
+ }
+}
diff --git a/benchmark/benchmark/src/androidTest/java/androidx/benchmark/benchmark/MeasureRepeatedSampleBenchmark.kt b/benchmark/benchmark/src/androidTest/java/androidx/benchmark/benchmark/MeasureRepeatedSampleBenchmark.kt
new file mode 100644
index 0000000..7cbe0c6
--- /dev/null
+++ b/benchmark/benchmark/src/androidTest/java/androidx/benchmark/benchmark/MeasureRepeatedSampleBenchmark.kt
@@ -0,0 +1,47 @@
+/*
+ * Copyright 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.benchmark.benchmark
+
+import androidx.benchmark.ExperimentalBenchmarkConfigApi
+import androidx.benchmark.ExperimentalBlackHoleApi
+import androidx.benchmark.TestDefinition
+import androidx.benchmark.measureRepeated
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.LargeTest
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@OptIn(ExperimentalBenchmarkConfigApi::class, ExperimentalBlackHoleApi::class)
+@LargeTest
+@RunWith(AndroidJUnit4::class)
+class MeasureRepeatedSampleBenchmark {
+ /** Proof of concept of top-level benchmark function, without a JUnit Rule */
+ @Test
+ fun increment() {
+ println("increment")
+ var i: Int = 0
+ measureRepeated(
+ TestDefinition(
+ "androidx.benchmark.benchmark.MeasureRepeatedSampleBenchmark",
+ "MeasureRepeatedSampleBenchmark",
+ "increment"
+ )
+ ) {
+ i++
+ }
+ }
+}
diff --git a/benchmark/benchmark/src/androidTest/java/androidx/benchmark/benchmark/TrivialKotlinBenchmarkLegacy.kt b/benchmark/benchmark/src/androidTest/java/androidx/benchmark/benchmark/TrivialKotlinBenchmarkLegacy.kt
new file mode 100644
index 0000000..97c91c3
--- /dev/null
+++ b/benchmark/benchmark/src/androidTest/java/androidx/benchmark/benchmark/TrivialKotlinBenchmarkLegacy.kt
@@ -0,0 +1,42 @@
+/*
+ * Copyright 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.benchmark.benchmark
+
+import android.annotation.SuppressLint
+import androidx.benchmark.junit4.BenchmarkRuleLegacy
+import androidx.benchmark.junit4.measureRepeated
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.LargeTest
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@LargeTest
+@RunWith(AndroidJUnit4::class)
+class TrivialKotlinBenchmarkLegacy {
+ @get:Rule val benchmarkRule = BenchmarkRuleLegacy()
+
+ @SuppressLint("BanThreadSleep") // intentional bad behavior / regression
+ @Test
+ fun nothing() = benchmarkRule.measureRepeated { Thread.sleep(1) }
+
+ @Test
+ fun increment() {
+ var i = 0
+ benchmarkRule.measureRepeated { i++ }
+ }
+}
diff --git a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/Camera2DeviceManager.kt b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/Camera2DeviceManager.kt
index 0a28700..1fcd4f7 100644
--- a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/Camera2DeviceManager.kt
+++ b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/Camera2DeviceManager.kt
@@ -25,6 +25,7 @@
import androidx.camera.camera2.pipe.core.PruningProcessingQueue
import androidx.camera.camera2.pipe.core.PruningProcessingQueue.Companion.processIn
import androidx.camera.camera2.pipe.core.Threads
+import androidx.camera.camera2.pipe.core.Token
import androidx.camera.camera2.pipe.core.WakeLock
import androidx.camera.camera2.pipe.graph.GraphListener
import androidx.camera.camera2.pipe.graph.GraphRequestProcessor
@@ -138,8 +139,14 @@
}
}
- suspend fun connectTo(virtualCameraState: VirtualCameraState) {
- val token = wakelock.acquire()
+ // Acquire this ActiveCamera. This ensures the camera stay opened for as long as the token is
+ // held. This is important for camera open scenarios where the device manager should acquire
+ // the ActiveCamera for the duration under which it's processing an open request.
+ fun acquire() = wakelock.acquire()
+
+ // TODO: b/389758537, b/390530866 - Make Token non-nullable. If we cannot acquire a token, the
+ // ActiveCamera has issued a RequestClose for this ActiveCamera already.
+ suspend fun connectTo(virtualCameraState: VirtualCameraState, token: Token?) {
val previous = current
current = virtualCameraState
@@ -179,8 +186,18 @@
private val queue =
PruningProcessingQueue<CameraRequest>(prune = ::prune) { process(it) }.processIn(scope)
private val activeCameras: MutableSet<ActiveCamera> = mutableSetOf()
- private val pendingRequestOpens = mutableListOf<RequestOpen>()
- private val pendingRequestOpenActiveCameraMap = mutableMapOf<RequestOpen, ActiveCamera>()
+
+ // PendingRequestOpen stores the context information for the pending RequestOpens to be
+ // connected in concurrent camera scenarios. It contains the request itself, the active camera
+ // it should be connected with, and the usage token for the active camera. The token should
+ // always be closed if the context is removed without being connected to a VirtualCamera.
+ private class PendingRequestOpen(
+ val request: RequestOpen,
+ val activeCamera: ActiveCamera,
+ val token: Token,
+ )
+
+ private val pendingRequestOpens = mutableListOf<PendingRequestOpen>()
override fun open(
cameraId: CameraId,
@@ -358,12 +375,9 @@
if (camerasToClose.isNotEmpty()) {
// Shutdown of cameras should always happen first (and suspend until complete)
activeCameras.removeAll(camerasToClose)
- for (requestOpen in pendingRequestOpens) {
- if (camerasToClose.contains(pendingRequestOpenActiveCameraMap[requestOpen])) {
- pendingRequestOpens.remove(requestOpen)
- pendingRequestOpenActiveCameraMap.remove(requestOpen)
- }
- }
+ disconnectPendingRequestOpens(
+ pendingRequestOpens.filter { camerasToClose.contains(it.activeCamera) }
+ )
for (camera in camerasToClose) {
camera.close()
}
@@ -374,51 +388,37 @@
// Step 2: Open the camera if not opened already.
camera2ErrorProcessor.setActiveVirtualCamera(cameraIdToOpen, request.virtualCamera)
- var realCamera = activeCameras.firstOrNull { it.cameraId == cameraIdToOpen }
- if (realCamera == null) {
- val openResult =
- openCameraWithRetry(
- cameraIdToOpen,
- request.sharedCameraIds,
- request.isForegroundObserver,
- scope,
- )
- when (openResult) {
- is OpenVirtualCameraResult.Success -> {
- realCamera = openResult.activeCamera
- activeCameras.add(realCamera)
- }
- is OpenVirtualCameraResult.Error -> {
- request.virtualCamera.disconnect(openResult.lastCameraError)
- return
- }
- }
+ val result = retrieveActiveCamera(cameraIdToOpen, request)
+ if (result == null) {
+ Log.error { "Failed to retrieve active camera for $cameraIdToOpen" }
+ return
}
+ val realCamera = result.activeCamera
+ val realCameraToken = result.token
// Step 3: Connect the opened camera(s).
if (request.sharedCameraIds.isNotEmpty()) {
- // Both sharedCameraIds and activeCameras are small collections. Looping over them
- // in what equates to nested for-loops are actually going to be more efficient than
- // say, replacing activeCameras with a hashmap.
+ // Both sharedCameraIds and pendingRequestOpenContexts are small collections. Looping
+ // over them in what equates to nested for-loops are actually going to be more efficient
+ // than say, replacing activeCameras with a hashmap.
if (
request.sharedCameraIds.all { cameraId ->
- activeCameras.any { it.cameraId == cameraId }
+ pendingRequestOpens.any { it.activeCamera.cameraId == cameraId }
}
) {
// If the camera of the request and the cameras it is shared with have been
// opened, we can connect the ActiveCameras.
check(!request.isPrewarm)
- realCamera.connectTo(request.virtualCamera)
- connectPendingRequestOpens(request.sharedCameraIds)
+ realCamera.connectTo(request.virtualCamera, realCameraToken)
+ connectPendingRequestOpens(request.sharedCameraIds.toSet())
} else {
// Else, save the request in the pending request queue, and connect the request
// once other cameras are opened.
- pendingRequestOpens.add(request)
- pendingRequestOpenActiveCameraMap[request] = realCamera
+ pendingRequestOpens.add(PendingRequestOpen(request, realCamera, realCameraToken))
}
} else {
if (!request.isPrewarm) {
- realCamera.connectTo(request.virtualCamera)
+ realCamera.connectTo(request.virtualCamera, realCameraToken)
}
}
}
@@ -430,16 +430,13 @@
if (activeCameras.contains(request.activeCamera)) {
activeCameras.remove(request.activeCamera)
}
- for (requestOpen in pendingRequestOpens) {
- // Edge case: There is a possibility that we receive RequestClose after a RequestOpen
- // for concurrent cameras has been processed. As such, we don't want to close the
- // ActiveCamera newly created by the RequestOpen, but only the one RequestClose is
- // aiming to close.
- if (pendingRequestOpenActiveCameraMap[requestOpen] == request.activeCamera) {
- pendingRequestOpens.remove(requestOpen)
- pendingRequestOpenActiveCameraMap.remove(requestOpen)
- }
- }
+
+ // Edge case: There is a possibility that we receive RequestClose after a RequestOpen for
+ // concurrent cameras has been processed. As such, we don't want to close the ActiveCamera
+ // newly created by the RequestOpen, but only the one RequestClose is aiming to close.
+ disconnectPendingRequestOpens(
+ pendingRequestOpens.filter { it.activeCamera == request.activeCamera }
+ )
request.activeCamera.close()
request.activeCamera.awaitClosed()
}
@@ -448,12 +445,9 @@
val cameraId = request.activeCameraId
Log.info { "PruningCamera2DeviceManager#processRequestCloseById(${request.activeCameraId}" }
- for (requestOpen in pendingRequestOpens) {
- if (requestOpen.virtualCamera.cameraId == cameraId) {
- pendingRequestOpens.remove(requestOpen)
- pendingRequestOpenActiveCameraMap.remove(requestOpen)
- }
- }
+ disconnectPendingRequestOpens(
+ pendingRequestOpens.filter { it.request.virtualCamera.cameraId == cameraId }
+ )
val activeCamera = activeCameras.firstOrNull { it.cameraId == cameraId }
if (activeCamera != null) {
activeCameras.remove(activeCamera)
@@ -466,8 +460,7 @@
private suspend fun processRequestCloseAll(requestCloseAll: RequestCloseAll) {
Log.info { "PruningCamera2DeviceManager#processRequestCloseAll()" }
- pendingRequestOpens.clear()
- pendingRequestOpenActiveCameraMap.clear()
+ disconnectPendingRequestOpens(pendingRequestOpens)
for (activeCamera in activeCameras) {
activeCamera.close()
}
@@ -478,6 +471,59 @@
requestCloseAll.deferred.complete(Unit)
}
+ private suspend fun retrieveActiveCamera(
+ cameraId: CameraId,
+ requestOpen: RequestOpen,
+ ): RetrieveActiveCameraResult? {
+ var realCamera: ActiveCamera? = null
+ var realCameraToken: Token? = null
+ for (activeCamera in activeCameras) {
+ if (activeCamera.cameraId == cameraId) {
+ // Important: When we retrieve an active camera, there is a chance it has already
+ // reached its idle timeout, but we haven't processed the close request and remove
+ // it from the list. It's also possible that after we fetch this camera, this camera
+ // then gets closed in parallel by the idle timeout. Therefore, here we should
+ // acquire a token from this active camera to mark it as used and keep it opened.
+ val token = activeCamera.acquire()
+ if (token != null) {
+ realCamera = activeCamera
+ realCameraToken = token
+ break
+ } else {
+ // This ActiveCamera is already disconnected (i.e., WakeLock closed). Make sure
+ // the camera is closed before reopening.
+ activeCamera.close()
+ activeCamera.awaitClosed()
+ activeCameras.remove(activeCamera)
+ }
+ }
+ }
+ if (realCamera == null) {
+ val openResult =
+ openCameraWithRetry(
+ cameraId,
+ requestOpen.sharedCameraIds,
+ requestOpen.isForegroundObserver,
+ scope,
+ )
+ when (openResult) {
+ is OpenVirtualCameraResult.Success -> {
+ Log.info { "PruningCameraDeviceManager: $cameraId opened successfully" }
+ realCamera = openResult.activeCamera
+ // Acquire a token to mark this active camera as used.
+ realCameraToken = checkNotNull(realCamera.acquire())
+ activeCameras.add(realCamera)
+ }
+ is OpenVirtualCameraResult.Error -> {
+ Log.info { "PruningCameraDeviceManager: Failed to open $cameraId" }
+ requestOpen.virtualCamera.disconnect(openResult.lastCameraError)
+ return null
+ }
+ }
+ }
+ return RetrieveActiveCameraResult(realCamera, checkNotNull(realCameraToken))
+ }
+
private suspend fun openCameraWithRetry(
cameraId: CameraId,
sharedCameraIds: List<CameraId>,
@@ -509,23 +555,32 @@
)
}
- private suspend fun connectPendingRequestOpens(cameraIds: List<CameraId>) {
- val requestOpensToRemove = mutableListOf<RequestOpen>()
- val requestOpens =
- pendingRequestOpens.filter { cameraIds.contains(it.virtualCamera.cameraId) }
- for (request in requestOpens) {
- // If the request is shared with this pending request, then we should be
- // able to connect this pending request too, since we don't allow
- // overlapping.
+ private suspend fun connectPendingRequestOpens(cameraIds: Set<CameraId>) {
+ val filteredPendingRequestOpens =
+ pendingRequestOpens.filter { cameraIds.contains(it.request.virtualCamera.cameraId) }
+ for (pendingRequestOpen in filteredPendingRequestOpens) {
+ val request = pendingRequestOpen.request
+
+ // If the request is shared with this pending request, then we should be able to connect
+ // this pending request too, since we don't allow overlapping.
val allCameraIds = listOf(request.virtualCamera.cameraId) + request.sharedCameraIds
check(allCameraIds.all { cameraId -> activeCameras.any { it.cameraId == cameraId } })
- val realCamera = activeCameras.find { it.cameraId == request.virtualCamera.cameraId }
- checkNotNull(realCamera)
- realCamera.connectTo(request.virtualCamera)
- requestOpensToRemove.add(request)
+ pendingRequestOpen.activeCamera.connectTo(
+ request.virtualCamera,
+ pendingRequestOpen.token
+ )
+ pendingRequestOpens.remove(pendingRequestOpen)
}
- pendingRequestOpens.removeAll(requestOpensToRemove)
+ }
+
+ private suspend fun disconnectPendingRequestOpens(
+ pendingRequestOpensToDisconnect: List<PendingRequestOpen>,
+ ) {
+ for (pendingRequestOpen in pendingRequestOpensToDisconnect) {
+ pendingRequestOpen.token.release()
+ pendingRequestOpens.remove(pendingRequestOpen)
+ }
}
private inline fun <T> List<T>.firstFromIndexOrNull(
@@ -548,6 +603,8 @@
return removedElements
}
+ private class RetrieveActiveCameraResult(val activeCamera: ActiveCamera, val token: Token)
+
private sealed interface OpenVirtualCameraResult {
data class Success(val activeCamera: ActiveCamera) : OpenVirtualCameraResult
@@ -788,7 +845,7 @@
// If the camera of the request and the cameras it is shared with have been
// opened, we can connect the ActiveCameras.
check(!request.isPrewarm)
- realCamera.connectTo(request.virtualCamera)
+ realCamera.connectTo(request.virtualCamera, realCamera.acquire())
connectPendingRequestOpens(request.sharedCameraIds)
} else {
// Else, save the request in the pending request queue, and connect the request
@@ -797,7 +854,7 @@
}
} else {
if (!request.isPrewarm) {
- realCamera.connectTo(request.virtualCamera)
+ realCamera.connectTo(request.virtualCamera, realCamera.acquire())
}
}
requests.remove(request)
@@ -850,7 +907,7 @@
val realCamera = activeCameras.find { it.cameraId == request.virtualCamera.cameraId }
checkNotNull(realCamera)
- realCamera.connectTo(request.virtualCamera)
+ realCamera.connectTo(request.virtualCamera, realCamera.acquire())
requestOpensToRemove.add(request)
}
pendingRequestOpens.removeAll(requestOpensToRemove)
diff --git a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/Camera2Quirks.kt b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/Camera2Quirks.kt
index 3732cf5..96c7d56 100644
--- a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/Camera2Quirks.kt
+++ b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/Camera2Quirks.kt
@@ -83,7 +83,7 @@
/**
* A quirk that closes the camera devices before creating a new capture session. This is needed
- * on legacy devices where creating a capture session directly may lead to deadlocks, NPEs or
+ * on certain devices where creating a capture session directly may lead to deadlocks, NPEs or
* other undesirable behaviors. When [shouldCreateEmptyCaptureSessionBeforeClosing] is also
* required, a regular camera device closure would then be expanded to:
* 1. Close the camera device.
@@ -91,13 +91,20 @@
* 3. Create an empty capture session.
* 4. Close the capture session.
* 5. Close the camera device.
- * - Bug(s): b/237341513, b/359062845, b/342263275, b/379347826
+ * - Bug(s): b/237341513, b/359062845, b/342263275, b/379347826, b/359062845
* - Device(s): Camera devices on hardware level LEGACY
* - API levels: 23 (M) – 31 (S_V2)
*/
- internal fun shouldCloseCameraBeforeCreatingCaptureSession(cameraId: CameraId): Boolean =
- Build.VERSION.SDK_INT in (Build.VERSION_CODES.M..Build.VERSION_CODES.S_V2) &&
- metadataProvider.awaitCameraMetadata(cameraId).isHardwareLevelLegacy
+ internal fun shouldCloseCameraBeforeCreatingCaptureSession(cameraId: CameraId): Boolean {
+ val isLegacyDevice =
+ Build.VERSION.SDK_INT in (Build.VERSION_CODES.M..Build.VERSION_CODES.S_V2) &&
+ metadataProvider.awaitCameraMetadata(cameraId).isHardwareLevelLegacy
+ val isQuirkyDevice =
+ "motorola".equals(Build.BRAND, ignoreCase = true) &&
+ "moto e20".equals(Build.MODEL, ignoreCase = true) &&
+ cameraId.value == "1"
+ return isLegacyDevice || isQuirkyDevice
+ }
companion object {
private val SHOULD_WAIT_FOR_REPEATING_DEVICE_MAP =
diff --git a/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/compat/Camera2DeviceManagerTest.kt b/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/compat/Camera2DeviceManagerTest.kt
index cb7ac0b..d8b0924 100644
--- a/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/compat/Camera2DeviceManagerTest.kt
+++ b/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/compat/Camera2DeviceManagerTest.kt
@@ -315,7 +315,7 @@
val virtualCamera1 =
deviceManager.open(cameraId0, listOf(cameraId1), fakeGraphListener1, false) { true }
assertNotNull(virtualCamera1)
- // Advance time by just a bit to allow coroutines to finish but not closing the camera.
+ // Advance time by just a bit to allow coroutines to finish.
advanceTimeBy(100)
assertEquals(fakeRetryingCameraStateOpener.androidCameraStates.size, 1)
@@ -344,6 +344,42 @@
}
@Test
+ fun pendingCamerasShouldBeHeldWhenOpeningConcurrentCameras() =
+ testScope.runTest {
+ val virtualCamera1 =
+ deviceManager.open(cameraId0, listOf(cameraId1), fakeGraphListener1, false) { true }
+ assertNotNull(virtualCamera1)
+ // Advance until idle. This is the only but notable difference between this and the
+ // prior test. Note that here because the request is still pending, the active camera
+ // should not be closed.
+ advanceUntilIdle()
+
+ assertEquals(fakeRetryingCameraStateOpener.androidCameraStates.size, 1)
+ val androidCameraState1 = fakeRetryingCameraStateOpener.androidCameraStates.first()
+ androidCameraState1.onOpened(fakeCameraDevice0)
+ advanceUntilIdle()
+
+ // Since camera 1 is not yet opened, the virtual camera should not be connected yet.
+ var virtualCameraState1 = virtualCamera1.value
+ assertIsNot<CameraStateOpen>(virtualCameraState1)
+
+ val virtualCamera2 =
+ deviceManager.open(cameraId1, listOf(cameraId0), fakeGraphListener2, false) { true }
+ assertNotNull(virtualCamera2)
+ advanceUntilIdle()
+
+ assertEquals(fakeRetryingCameraStateOpener.androidCameraStates.size, 2)
+ val androidCameraState2 = fakeRetryingCameraStateOpener.androidCameraStates.last()
+ androidCameraState2.onOpened(fakeCameraDevice1)
+ advanceUntilIdle()
+
+ virtualCameraState1 = virtualCamera1.value
+ assertIs<CameraStateOpen>(virtualCameraState1)
+ val virtualCameraState2 = virtualCamera2.value
+ assertIs<CameraStateOpen>(virtualCameraState2)
+ }
+
+ @Test
fun singleCameraShouldBeClosedWhenConcurrentCamerasAreRequested() =
testScope.runTest {
// First open camera 0 in regular (single) camera mode.
@@ -395,7 +431,7 @@
val virtualCamera1 =
deviceManager.open(cameraId0, listOf(cameraId1), fakeGraphListener1, false) { true }
assertNotNull(virtualCamera1)
- // Advance time by just a bit to allow coroutines to finish but not closing the camera.
+ // Advance time by just a bit to allow coroutines to finish.
advanceTimeBy(100)
assertEquals(fakeRetryingCameraStateOpener.androidCameraStates.size, 1)
diff --git a/camera/camera-camera2/src/androidTest/java/androidx/camera/camera2/internal/TorchControlDeviceTest.java b/camera/camera-camera2/src/androidTest/java/androidx/camera/camera2/internal/TorchControlDeviceTest.java
index de994f6..8bcdb8c 100644
--- a/camera/camera-camera2/src/androidTest/java/androidx/camera/camera2/internal/TorchControlDeviceTest.java
+++ b/camera/camera-camera2/src/androidTest/java/androidx/camera/camera2/internal/TorchControlDeviceTest.java
@@ -23,6 +23,7 @@
import androidx.camera.camera2.Camera2Config;
import androidx.camera.camera2.internal.util.TestUtil;
+import androidx.camera.core.CameraInfo;
import androidx.camera.core.CameraSelector;
import androidx.camera.core.CameraXConfig;
import androidx.camera.core.ImageAnalysis;
@@ -88,7 +89,8 @@
mCamera = CameraUtil.createCameraAndAttachUseCase(context, cameraSelector, imageAnalysis);
mCameraControl = TestUtil.getCamera2CameraControlImpl(mCamera.getCameraControl());
mTorchControl = mCameraControl.getTorchControl();
- mIsTorchStrengthSupported = mCamera.getCameraInfo().getMaxTorchStrengthLevel() > 1;
+ mIsTorchStrengthSupported = mCamera.getCameraInfo().getMaxTorchStrengthLevel()
+ != CameraInfo.TORCH_STRENGTH_LEVEL_UNSUPPORTED;
}
@After
@@ -118,7 +120,7 @@
@Test(timeout = 5000L)
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.VANILLA_ICE_CREAM)
- public void setTorchStrengthLevel_futureCompleteWhenTorchIsOnLevel()
+ public void setTorchStrengthLevel_futureCompleteWhenTorchIsOn()
throws ExecutionException, InterruptedException {
assumeTrue(mIsTorchStrengthSupported);
@@ -132,7 +134,7 @@
@Test(timeout = 5000L)
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.VANILLA_ICE_CREAM)
- public void setTorchStrengthLevel_futureCompleteWhenTorchIsOffLevel()
+ public void setTorchStrengthLevel_futureCompleteWhenTorchIsOff()
throws ExecutionException, InterruptedException {
assumeTrue(mIsTorchStrengthSupported);
diff --git a/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/Camera2CameraControlImpl.java b/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/Camera2CameraControlImpl.java
index 8a2a439..d6321f2 100644
--- a/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/Camera2CameraControlImpl.java
+++ b/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/Camera2CameraControlImpl.java
@@ -516,6 +516,10 @@
return Futures.immediateFailedFuture(
new OperationCanceledException("Camera is not active."));
}
+ if (!mCameraCharacteristics.isTorchStrengthLevelSupported()) {
+ return Futures.immediateFailedFuture(new UnsupportedOperationException(
+ "The device doesn't support configuring torch strength level."));
+ }
if (torchStrengthLevel < 1
|| torchStrengthLevel > mCameraCharacteristics.getMaxTorchStrengthLevel()) {
return Futures.immediateFailedFuture(new IllegalArgumentException(
diff --git a/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/Camera2CameraInfoImpl.java b/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/Camera2CameraInfoImpl.java
index aeb7058..4804b521 100644
--- a/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/Camera2CameraInfoImpl.java
+++ b/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/Camera2CameraInfoImpl.java
@@ -719,9 +719,11 @@
}
@Override
- @IntRange(from = 1)
+ @IntRange(from = 0)
public int getMaxTorchStrengthLevel() {
- return mCameraCharacteristicsCompat.getMaxTorchStrengthLevel();
+ return mCameraCharacteristicsCompat.isTorchStrengthLevelSupported()
+ ? mCameraCharacteristicsCompat.getMaxTorchStrengthLevel()
+ : TORCH_STRENGTH_LEVEL_UNSUPPORTED;
}
@Override
@@ -730,7 +732,9 @@
if (mCamera2CameraControlImpl == null) {
if (mRedirectTorchStrengthLiveData == null) {
mRedirectTorchStrengthLiveData = new RedirectableLiveData<>(
- mCameraCharacteristicsCompat.getDefaultTorchStrengthLevel());
+ mCameraCharacteristicsCompat.isTorchStrengthLevelSupported()
+ ? mCameraCharacteristicsCompat.getDefaultTorchStrengthLevel()
+ : TORCH_STRENGTH_LEVEL_UNSUPPORTED);
}
return mRedirectTorchStrengthLiveData;
}
diff --git a/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/CaptureSession.java b/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/CaptureSession.java
index 95d9e1c..49ef6d6 100644
--- a/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/CaptureSession.java
+++ b/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/CaptureSession.java
@@ -16,7 +16,6 @@
package androidx.camera.camera2.internal;
-import android.annotation.SuppressLint;
import android.graphics.ImageFormat;
import android.hardware.camera2.CameraAccessException;
import android.hardware.camera2.CameraCaptureSession;
@@ -68,8 +67,6 @@
import org.jspecify.annotations.NonNull;
import org.jspecify.annotations.Nullable;
-import java.lang.reflect.InvocationTargetException;
-import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
@@ -1067,7 +1064,8 @@
continue;
}
List<OutputConfiguration> outputConfigurations =
- createInstancesForMultiResolutionOutput(streamInfos, imageFormat);
+ OutputConfiguration.createInstancesForMultiResolutionOutput(streamInfos,
+ imageFormat);
if (outputConfigurations != null) {
for (SessionConfig.OutputConfig outputConfig : groupIdToOutputConfigsMap.get(
groupId)) {
@@ -1082,31 +1080,6 @@
return outputConfigToOutputConfigurationCompatMap;
}
- /**
- * Use java reflection to access the API so that we don't need to upgrade compileSdk as 35 in
- * the release branch. When this method is invoked, the API has become public on the device. It
- * won't cause the problem about accessing the non-SDK API.
- */
- /** @noinspection unchecked */
- @SuppressLint("BanUncheckedReflection")
- @SuppressWarnings("unchecked")
- @RequiresApi(35)
- private static @Nullable List<OutputConfiguration> createInstancesForMultiResolutionOutput(
- @NonNull List<MultiResolutionStreamInfo> streamInfos, int format) {
- // TODO(b/376185185): Invoke the API directly after the androidx code base upgrades to
- // compile by API 35 SDK.
- try {
- Method createInstanceMethod = OutputConfiguration.class.getMethod(
- "createInstancesForMultiResolutionOutput", Collection.class, int.class);
- return (List<OutputConfiguration>) createInstanceMethod.invoke(null, streamInfos,
- format);
- } catch (NoSuchMethodException | InvocationTargetException | IllegalAccessException e) {
- Logger.e(TAG,
- "Failed to create instances for multi-resolution output, " + e.getMessage());
- return null;
- }
- }
-
// Debugging note: these states are kept in ordinal order. Any additions or changes should try
// to maintain the same order such that the highest ordinal is the state of largest resource
// utilization.
diff --git a/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/TorchControl.java b/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/TorchControl.java
index 7ea9db9..1bb729d 100644
--- a/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/TorchControl.java
+++ b/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/TorchControl.java
@@ -103,10 +103,10 @@
mExecutor = executor;
mHasFlashUnit = FlashAvailabilityChecker.isFlashAvailable(cameraCharacteristics::get);
- mIsTorchStrengthSupported =
- mHasFlashUnit && Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM
- && cameraCharacteristics.getMaxTorchStrengthLevel() > 1;
- mDefaultTorchStrength = cameraCharacteristics.getDefaultTorchStrengthLevel();
+ mIsTorchStrengthSupported = cameraCharacteristics.isTorchStrengthLevelSupported();
+ mDefaultTorchStrength = mHasFlashUnit && mIsTorchStrengthSupported
+ ? cameraCharacteristics.getDefaultTorchStrengthLevel()
+ : Camera2CameraInfoImpl.TORCH_STRENGTH_LEVEL_UNSUPPORTED;
mTargetTorchStrength = mDefaultTorchStrength;
mTorchState = new MutableLiveData<>(DEFAULT_TORCH_STATE);
mTorchStrength = new MutableLiveData<>(mDefaultTorchStrength);
diff --git a/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/compat/CameraCharacteristicsCompat.java b/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/compat/CameraCharacteristicsCompat.java
index 1f2cc60..766cfec 100644
--- a/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/compat/CameraCharacteristicsCompat.java
+++ b/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/compat/CameraCharacteristicsCompat.java
@@ -22,6 +22,7 @@
import android.os.Build;
import androidx.annotation.GuardedBy;
+import androidx.annotation.IntRange;
import androidx.annotation.VisibleForTesting;
import androidx.camera.camera2.internal.compat.workaround.OutputSizesCorrector;
@@ -135,23 +136,40 @@
*/
public int getDefaultTorchStrengthLevel() {
Integer defaultLevel = null;
- if (Build.VERSION.SDK_INT >= 35) {
+ if (hasFlashUnit() && Build.VERSION.SDK_INT >= 35) {
defaultLevel = get(CameraCharacteristics.FLASH_TORCH_STRENGTH_DEFAULT_LEVEL);
}
+ // The framework returns 1 when the device doesn't support configuring torch strength. So
+ // also return 1 if the device doesn't have flash unit or is unable to provide the
+ // information.
return defaultLevel == null ? 1 : defaultLevel;
}
/**
* Returns the maximum torch strength level.
*/
+ @IntRange(from = 1)
public int getMaxTorchStrengthLevel() {
Integer maxLevel = null;
- if (Build.VERSION.SDK_INT >= 35) {
+ if (hasFlashUnit() && Build.VERSION.SDK_INT >= 35) {
maxLevel = get(CameraCharacteristics.FLASH_TORCH_STRENGTH_MAX_LEVEL);
}
+ // The framework returns 1 when the device doesn't support configuring torch strength. So
+ // also return 1 if the device doesn't have flash unit or is unable to provide the
+ // information.
return maxLevel == null ? 1 : maxLevel;
}
+ public boolean isTorchStrengthLevelSupported() {
+ return hasFlashUnit() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM
+ && getMaxTorchStrengthLevel() > 1;
+ }
+
+ private boolean hasFlashUnit() {
+ Boolean flashInfoAvailable = get(CameraCharacteristics.FLASH_INFO_AVAILABLE);
+ return flashInfoAvailable != null && flashInfoAvailable;
+ }
+
/**
* Obtains the {@link StreamConfigurationMapCompat} which contains the output sizes related
* workarounds in it.
diff --git a/camera/camera-camera2/src/test/java/androidx/camera/camera2/internal/Camera2CameraInfoImplTest.java b/camera/camera-camera2/src/test/java/androidx/camera/camera2/internal/Camera2CameraInfoImplTest.java
index e7fcc70..1d87d00 100644
--- a/camera/camera-camera2/src/test/java/androidx/camera/camera2/internal/Camera2CameraInfoImplTest.java
+++ b/camera/camera-camera2/src/test/java/androidx/camera/camera2/internal/Camera2CameraInfoImplTest.java
@@ -916,6 +916,30 @@
assertThat(cameraInfo.getMaxTorchStrengthLevel()).isEqualTo(CAMERA0_MAX_TORCH_STRENGTH);
}
+ @Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
+ @Test
+ public void apiVersionMet_canReturnMaxTorchStrengthUnsupported()
+ throws CameraAccessExceptionCompat {
+ init(/* hasAvailableCapabilities = */ true);
+
+ final CameraInfo cameraInfo = new Camera2CameraInfoImpl(CAMERA1_ID, mCameraManagerCompat);
+
+ assertThat(cameraInfo.getMaxTorchStrengthLevel()).isEqualTo(
+ CameraInfo.TORCH_STRENGTH_LEVEL_UNSUPPORTED);
+ }
+
+ @Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
+ @Test
+ public void apiVersionMet_canReturnTorchStrengthUnsupported()
+ throws CameraAccessExceptionCompat {
+ init(/* hasAvailableCapabilities = */ true);
+
+ final CameraInfo cameraInfo = new Camera2CameraInfoImpl(CAMERA1_ID, mCameraManagerCompat);
+
+ assertThat(cameraInfo.getTorchStrengthLevel().getValue()).isEqualTo(
+ CameraInfo.TORCH_STRENGTH_LEVEL_UNSUPPORTED);
+ }
+
@Config(minSdk = 33)
@Test
public void apiVersionMet_canReturnSupportedDynamicRanges_fromFullySpecified()
@@ -944,13 +968,14 @@
@Config(maxSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM - 1)
@Test
- public void apiVersionNotMet_returnMaxTorchStrengthOne()
+ public void apiVersionNotMet_returnMaxTorchStrengthUnsupported()
throws CameraAccessExceptionCompat {
init(/* hasAvailableCapabilities = */ true);
final CameraInfo cameraInfo = new Camera2CameraInfoImpl(CAMERA0_ID, mCameraManagerCompat);
- assertThat(cameraInfo.getMaxTorchStrengthLevel()).isEqualTo(1);
+ assertThat(cameraInfo.getMaxTorchStrengthLevel()).isEqualTo(
+ CameraInfo.TORCH_STRENGTH_LEVEL_UNSUPPORTED);
}
@Config(maxSdk = 32)
diff --git a/camera/camera-camera2/src/test/java/androidx/camera/camera2/internal/TorchControlTest.java b/camera/camera-camera2/src/test/java/androidx/camera/camera2/internal/TorchControlTest.java
index d725f7f..9f45cd9 100644
--- a/camera/camera-camera2/src/test/java/androidx/camera/camera2/internal/TorchControlTest.java
+++ b/camera/camera-camera2/src/test/java/androidx/camera/camera2/internal/TorchControlTest.java
@@ -155,6 +155,17 @@
}
@Test
+ public void setTorchStrengthLevel_throwExceptionWhenNoFlashUnit() throws InterruptedException {
+ Throwable cause = null;
+ try {
+ mNoFlashUnitTorchControl.setTorchStrengthLevel(1).get();
+ } catch (ExecutionException e) {
+ cause = e.getCause();
+ }
+ assertThat(cause).isInstanceOf(UnsupportedOperationException.class);
+ }
+
+ @Test
public void enableTorch_whenInactive() throws InterruptedException {
mTorchControl.setActive(false);
ListenableFuture<Void> listenableFuture = mTorchControl.enableTorch(true);
diff --git a/camera/camera-core/api/current.txt b/camera/camera-core/api/current.txt
index 7837ece..893b313 100644
--- a/camera/camera-core/api/current.txt
+++ b/camera/camera-core/api/current.txt
@@ -18,6 +18,7 @@
method public com.google.common.util.concurrent.ListenableFuture<java.lang.Void!> enableTorch(boolean);
method public com.google.common.util.concurrent.ListenableFuture<java.lang.Integer!> setExposureCompensationIndex(int);
method public com.google.common.util.concurrent.ListenableFuture<java.lang.Void!> setLinearZoom(@FloatRange(from=0.0f, to=1.0f) float);
+ method public default com.google.common.util.concurrent.ListenableFuture<java.lang.Void!> setTorchStrengthLevelAsync(@IntRange(from=1) int);
method public com.google.common.util.concurrent.ListenableFuture<java.lang.Void!> setZoomRatio(float);
method public com.google.common.util.concurrent.ListenableFuture<androidx.camera.core.FocusMeteringResult!> startFocusAndMetering(androidx.camera.core.FocusMeteringAction);
}
@@ -48,11 +49,13 @@
method @FloatRange(from=0, fromInclusive=false) public default float getIntrinsicZoomRatio();
method public default int getLensFacing();
method public default androidx.lifecycle.LiveData<java.lang.Integer!> getLowLightBoostState();
+ method @IntRange(from=0) public default int getMaxTorchStrengthLevel();
method public default java.util.Set<androidx.camera.core.CameraInfo!> getPhysicalCameraInfos();
method public int getSensorRotationDegrees();
method public int getSensorRotationDegrees(int);
method public default java.util.Set<android.util.Range<java.lang.Integer!>!> getSupportedFrameRateRanges();
method public androidx.lifecycle.LiveData<java.lang.Integer!> getTorchState();
+ method public default androidx.lifecycle.LiveData<java.lang.Integer!> getTorchStrengthLevel();
method public androidx.lifecycle.LiveData<androidx.camera.core.ZoomState!> getZoomState();
method public boolean hasFlashUnit();
method public default boolean isFocusMeteringSupported(androidx.camera.core.FocusMeteringAction);
@@ -61,6 +64,7 @@
method @SuppressCompatibility @androidx.camera.core.ExperimentalZeroShutterLag public default boolean isZslSupported();
method public static boolean mustPlayShutterSound();
method public default java.util.Set<androidx.camera.core.DynamicRange!> querySupportedDynamicRanges(java.util.Set<androidx.camera.core.DynamicRange!>);
+ field public static final int TORCH_STRENGTH_LEVEL_UNSUPPORTED = 0; // 0x0
}
public final class CameraInfoUnavailableException extends java.lang.Exception {
diff --git a/camera/camera-core/api/restricted_current.txt b/camera/camera-core/api/restricted_current.txt
index 7837ece..893b313 100644
--- a/camera/camera-core/api/restricted_current.txt
+++ b/camera/camera-core/api/restricted_current.txt
@@ -18,6 +18,7 @@
method public com.google.common.util.concurrent.ListenableFuture<java.lang.Void!> enableTorch(boolean);
method public com.google.common.util.concurrent.ListenableFuture<java.lang.Integer!> setExposureCompensationIndex(int);
method public com.google.common.util.concurrent.ListenableFuture<java.lang.Void!> setLinearZoom(@FloatRange(from=0.0f, to=1.0f) float);
+ method public default com.google.common.util.concurrent.ListenableFuture<java.lang.Void!> setTorchStrengthLevelAsync(@IntRange(from=1) int);
method public com.google.common.util.concurrent.ListenableFuture<java.lang.Void!> setZoomRatio(float);
method public com.google.common.util.concurrent.ListenableFuture<androidx.camera.core.FocusMeteringResult!> startFocusAndMetering(androidx.camera.core.FocusMeteringAction);
}
@@ -48,11 +49,13 @@
method @FloatRange(from=0, fromInclusive=false) public default float getIntrinsicZoomRatio();
method public default int getLensFacing();
method public default androidx.lifecycle.LiveData<java.lang.Integer!> getLowLightBoostState();
+ method @IntRange(from=0) public default int getMaxTorchStrengthLevel();
method public default java.util.Set<androidx.camera.core.CameraInfo!> getPhysicalCameraInfos();
method public int getSensorRotationDegrees();
method public int getSensorRotationDegrees(int);
method public default java.util.Set<android.util.Range<java.lang.Integer!>!> getSupportedFrameRateRanges();
method public androidx.lifecycle.LiveData<java.lang.Integer!> getTorchState();
+ method public default androidx.lifecycle.LiveData<java.lang.Integer!> getTorchStrengthLevel();
method public androidx.lifecycle.LiveData<androidx.camera.core.ZoomState!> getZoomState();
method public boolean hasFlashUnit();
method public default boolean isFocusMeteringSupported(androidx.camera.core.FocusMeteringAction);
@@ -61,6 +64,7 @@
method @SuppressCompatibility @androidx.camera.core.ExperimentalZeroShutterLag public default boolean isZslSupported();
method public static boolean mustPlayShutterSound();
method public default java.util.Set<androidx.camera.core.DynamicRange!> querySupportedDynamicRanges(java.util.Set<androidx.camera.core.DynamicRange!>);
+ field public static final int TORCH_STRENGTH_LEVEL_UNSUPPORTED = 0; // 0x0
}
public final class CameraInfoUnavailableException extends java.lang.Exception {
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/CameraControl.java b/camera/camera-core/src/main/java/androidx/camera/core/CameraControl.java
index 09718fb..2332ad6 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/CameraControl.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/CameraControl.java
@@ -245,11 +245,14 @@
* {@link ListenableFuture} will fail with an {@link IllegalArgumentException} and it won't
* modify the torch strength.
*
+ * <p>If the device doesn't have a flash unit or doesn't support configuring torch strength
+ * level, the returned {@link ListenableFuture} will fail with an
+ * {@link UnsupportedOperationException}.
+ *
* @param torchStrengthLevel The desired torch strength level.
* @return a {@link ListenableFuture} that is completed when the torch strength has been
* applied.
*/
- @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
default @NonNull ListenableFuture<Void> setTorchStrengthLevelAsync(
@IntRange(from = 1) int torchStrengthLevel) {
return Futures.immediateFailedFuture(new UnsupportedOperationException(
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/CameraInfo.java b/camera/camera-core/src/main/java/androidx/camera/core/CameraInfo.java
index 91b2f00..cd2f8a2 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/CameraInfo.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/CameraInfo.java
@@ -50,6 +50,12 @@
public interface CameraInfo {
/**
+ * The torch strength level when the device doesn't have a flash unit or doesn't support
+ * adjusting torch strength.
+ */
+ int TORCH_STRENGTH_LEVEL_UNSUPPORTED = 0;
+
+ /**
* An unknown intrinsic zoom ratio. Usually to indicate the camera is unable to provide
* necessary information to resolve its intrinsic zoom ratio.
*
@@ -429,13 +435,12 @@
/**
* Returns the maximum torch strength level.
*
- * @return The maximum strength level. If the device doesn't support configuring torch
- * strength, returns {@code 1}.
+ * @return The maximum strength level, or {@link #TORCH_STRENGTH_LEVEL_UNSUPPORTED} if the
+ * device doesn't have a flash unit or doesn't support configuring torch strength.
*/
- @RestrictTo(Scope.LIBRARY_GROUP)
- @IntRange(from = 1)
+ @IntRange(from = 0)
default int getMaxTorchStrengthLevel() {
- return 1;
+ return TORCH_STRENGTH_LEVEL_UNSUPPORTED;
}
/**
@@ -443,10 +448,12 @@
*
* <p>The value of the {@link LiveData} will be the default torch strength level of this
* device if {@link CameraControl#setTorchStrengthLevelAsync(int)} hasn't been called.
+ *
+ * <p>The value of the {@link LiveData} will be {@link #TORCH_STRENGTH_LEVEL_UNSUPPORTED} if
+ * the device doesn't have a flash unit or doesn't support configuring torch strength.
*/
- @RestrictTo(Scope.LIBRARY_GROUP)
default @NonNull LiveData<Integer> getTorchStrengthLevel() {
- return new MutableLiveData<>(1);
+ return new MutableLiveData<>(TORCH_STRENGTH_LEVEL_UNSUPPORTED);
}
@StringDef(open = true, value = {IMPLEMENTATION_TYPE_UNKNOWN,
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/impl/ForwardingCameraInfo.java b/camera/camera-core/src/main/java/androidx/camera/core/impl/ForwardingCameraInfo.java
index 10d896a..de7943c 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/impl/ForwardingCameraInfo.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/impl/ForwardingCameraInfo.java
@@ -19,6 +19,7 @@
import android.util.Range;
import android.util.Size;
+import androidx.annotation.IntRange;
import androidx.camera.core.CameraInfo;
import androidx.camera.core.CameraSelector;
import androidx.camera.core.CameraState;
@@ -78,6 +79,7 @@
}
@Override
+ @IntRange(from = 0)
public int getMaxTorchStrengthLevel() {
return mCameraInfoInternal.getMaxTorchStrengthLevel();
}
diff --git a/camera/camera-video/src/androidTest/java/androidx/camera/video/VideoRecordingTest.kt b/camera/camera-video/src/androidTest/java/androidx/camera/video/VideoRecordingTest.kt
index befe1c0..c3aa6d1 100644
--- a/camera/camera-video/src/androidTest/java/androidx/camera/video/VideoRecordingTest.kt
+++ b/camera/camera-video/src/androidTest/java/androidx/camera/video/VideoRecordingTest.kt
@@ -365,13 +365,13 @@
fun getResolutionInfo_shouldMatchRecordedVideoResolution() {
// Arrange.
checkAndBindUseCases(preview, videoCapture)
+ val resolutionInfo = videoCapture.resolutionInfo!!
// Act.
val result = recordingSession.createRecording().recordAndVerify()
// Assert: the resolution of the video file should match the resolution calculated by
// rotating the cropRect specified in the ResolutionInfo.
- val resolutionInfo = videoCapture.resolutionInfo!!
val expectedResolution =
rotateSize(rectToSize(resolutionInfo.cropRect), resolutionInfo.rotationDegrees)
verifyVideoResolution(context, result.file, expectedResolution)
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 19331ab..de52e38 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
@@ -1322,7 +1322,7 @@
Bundle bundle = this.getIntent().getExtras();
if (bundle != null) {
mTargetAspectRatio = bundle.getInt(INTENT_EXTRA_TARGET_ASPECT_RATIO,
- AspectRatio.RATIO_4_3);
+ AspectRatio.RATIO_DEFAULT);
int scaleType = bundle.getInt(INTENT_EXTRA_SCALE_TYPE, INTENT_EXTRA_FILL_CENTER);
if (scaleType == INTENT_EXTRA_FIT_CENTER) {
// Scale the view according to the target aspect ratio, display size and device
diff --git a/car/app/app/src/main/java/androidx/car/app/CarToast.java b/car/app/app/src/main/java/androidx/car/app/CarToast.java
index 0df1034..97bc2a4b 100644
--- a/car/app/app/src/main/java/androidx/car/app/CarToast.java
+++ b/car/app/app/src/main/java/androidx/car/app/CarToast.java
@@ -72,6 +72,7 @@
/**
* Creates and sets the text and duration for the toast view.
*
+ * @param carContext the context used for string localization
* @param textResId the resource id for the text to show. If the {@code textResId} is 0, the
* text will be set to empty
* @param duration how long to display the message. Either {@link #LENGTH_SHORT} or {@link
@@ -89,6 +90,7 @@
/**
* Creates and sets the text and duration for the toast view.
*
+ * @param carContext the CarContext providing the service used to show the toast
* @param text the text to show
* @param duration how long to display the message. Either {@link #LENGTH_SHORT} or {@link
* #LENGTH_LONG}
diff --git a/car/app/app/src/main/java/androidx/car/app/model/AlertCallbackDelegate.java b/car/app/app/src/main/java/androidx/car/app/model/AlertCallbackDelegate.java
index b104443..73f61f3 100644
--- a/car/app/app/src/main/java/androidx/car/app/model/AlertCallbackDelegate.java
+++ b/car/app/app/src/main/java/androidx/car/app/model/AlertCallbackDelegate.java
@@ -34,6 +34,7 @@
/**
* Notifies that a cancel event happened with given {@code reason}.
*
+ * @param reason the {@link AlertCallback.Reason} for which the alert was cancelled
* @param callback the {@link OnDoneCallback} to trigger when the client finishes handling
* the event
*/
diff --git a/compose/animation/animation-core/proguard-rules.pro b/compose/animation/animation-core/proguard-rules.pro
index 72f4a6c..67d118b 100644
--- a/compose/animation/animation-core/proguard-rules.pro
+++ b/compose/animation/animation-core/proguard-rules.pro
@@ -15,7 +15,7 @@
# Keep all the functions created to throw an exception. We don't want these functions to be
# inlined in any way, which R8 will do by default. The whole point of these functions is to
# reduce the amount of code generated at the call site.
--keepclassmembers,allowshrinking,allowobfuscation class androidx.compose.**.* {
+-keep,allowshrinking,allowobfuscation class androidx.compose.**.* {
static void throw*Exception(...);
static void throw*ExceptionForNullCheck(...);
# For methods returning Nothing
diff --git a/compose/foundation/foundation-layout/proguard-rules.pro b/compose/foundation/foundation-layout/proguard-rules.pro
index 72f4a6c..67d118b 100644
--- a/compose/foundation/foundation-layout/proguard-rules.pro
+++ b/compose/foundation/foundation-layout/proguard-rules.pro
@@ -15,7 +15,7 @@
# Keep all the functions created to throw an exception. We don't want these functions to be
# inlined in any way, which R8 will do by default. The whole point of these functions is to
# reduce the amount of code generated at the call site.
--keepclassmembers,allowshrinking,allowobfuscation class androidx.compose.**.* {
+-keep,allowshrinking,allowobfuscation class androidx.compose.**.* {
static void throw*Exception(...);
static void throw*ExceptionForNullCheck(...);
# For methods returning Nothing
diff --git a/compose/foundation/foundation-layout/src/commonMain/kotlin/androidx/compose/foundation/layout/AlignmentLine.kt b/compose/foundation/foundation-layout/src/commonMain/kotlin/androidx/compose/foundation/layout/AlignmentLine.kt
index 99d2381..fb9ac8c05 100644
--- a/compose/foundation/foundation-layout/src/commonMain/kotlin/androidx/compose/foundation/layout/AlignmentLine.kt
+++ b/compose/foundation/foundation-layout/src/commonMain/kotlin/androidx/compose/foundation/layout/AlignmentLine.kt
@@ -33,7 +33,6 @@
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
-import androidx.compose.ui.unit.isSpecified
import androidx.compose.ui.unit.isUnspecified
import kotlin.math.max
@@ -143,14 +142,14 @@
@Stable
fun Modifier.paddingFromBaseline(top: Dp = Dp.Unspecified, bottom: Dp = Dp.Unspecified) =
this.then(
- if (top.isSpecified) {
+ if (top != Dp.Unspecified) {
Modifier.paddingFrom(FirstBaseline, before = top)
} else {
Modifier
}
)
.then(
- if (bottom.isSpecified) {
+ if (bottom != Dp.Unspecified) {
Modifier.paddingFrom(LastBaseline, after = bottom)
} else {
Modifier
@@ -193,8 +192,8 @@
) : ModifierNodeElement<AlignmentLineOffsetDpNode>() {
init {
requirePrecondition(
- (before.value >= 0f || before.isUnspecified) and
- (after.value >= 0f || after.isUnspecified)
+ (before.value >= 0f || before == Dp.Unspecified) &&
+ (after.value >= 0f || after == Dp.Unspecified)
) {
"Padding from alignment line must be a non-negative number"
}
@@ -320,12 +319,12 @@
val axisMax = if (alignmentLine.horizontal) constraints.maxHeight else constraints.maxWidth
// Compute padding required to satisfy the total before and after offsets.
val paddingBefore =
- ((if (before.isSpecified) before.roundToPx() else 0) - linePosition).coerceIn(
+ ((if (before != Dp.Unspecified) before.roundToPx() else 0) - linePosition).coerceIn(
0,
axisMax - axis
)
val paddingAfter =
- ((if (after.isSpecified) after.roundToPx() else 0) - axis + linePosition).coerceIn(
+ ((if (after != Dp.Unspecified) after.roundToPx() else 0) - axis + linePosition).coerceIn(
0,
axisMax - axis - paddingBefore
)
diff --git a/compose/foundation/foundation-layout/src/commonMain/kotlin/androidx/compose/foundation/layout/Padding.kt b/compose/foundation/foundation-layout/src/commonMain/kotlin/androidx/compose/foundation/layout/Padding.kt
index c038279..24ac686 100644
--- a/compose/foundation/foundation-layout/src/commonMain/kotlin/androidx/compose/foundation/layout/Padding.kt
+++ b/compose/foundation/foundation-layout/src/commonMain/kotlin/androidx/compose/foundation/layout/Padding.kt
@@ -33,7 +33,6 @@
import androidx.compose.ui.unit.constrainHeight
import androidx.compose.ui.unit.constrainWidth
import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.isUnspecified
import androidx.compose.ui.unit.offset
/**
@@ -204,14 +203,10 @@
) : PaddingValues {
init {
- requirePrecondition(
- (left.value >= 0f) and
- (top.value >= 0f) and
- (right.value >= 0f) and
- (bottom.value >= 0f)
- ) {
- "Padding must be non-negative"
- }
+ requirePrecondition(left.value >= 0) { "Left padding must be non-negative" }
+ requirePrecondition(top.value >= 0) { "Top padding must be non-negative" }
+ requirePrecondition(right.value >= 0) { "Right padding must be non-negative" }
+ requirePrecondition(bottom.value >= 0) { "Bottom padding must be non-negative" }
}
override fun calculateLeftPadding(layoutDirection: LayoutDirection) = left
@@ -296,11 +291,10 @@
) : PaddingValues {
init {
- requirePrecondition(
- (start.value >= 0f) and (top.value >= 0f) and (end.value >= 0f) and (bottom.value >= 0f)
- ) {
- "Padding must be non-negative"
- }
+ requirePrecondition(start.value >= 0) { "Start padding must be non-negative" }
+ requirePrecondition(top.value >= 0) { "Top padding must be non-negative" }
+ requirePrecondition(end.value >= 0) { "End padding must be non-negative" }
+ requirePrecondition(bottom.value >= 0) { "Bottom padding must be non-negative" }
}
override fun calculateLeftPadding(layoutDirection: LayoutDirection) =
@@ -338,10 +332,10 @@
init {
requirePrecondition(
- (start.value >= 0f || start.isUnspecified) and
- (top.value >= 0f || top.isUnspecified) and
- (end.value >= 0f || end.isUnspecified) and
- (bottom.value >= 0f || bottom.isUnspecified)
+ (start.value >= 0f || start == Dp.Unspecified) &&
+ (top.value >= 0f || top == Dp.Unspecified) &&
+ (end.value >= 0f || end == Dp.Unspecified) &&
+ (bottom.value >= 0f || bottom == Dp.Unspecified)
) {
"Padding must be non-negative"
}
@@ -442,30 +436,30 @@
measurable: Measurable,
constraints: Constraints
): MeasureResult {
- val leftPadding = paddingValues.calculateLeftPadding(layoutDirection)
- val topPadding = paddingValues.calculateTopPadding()
- val rightPadding = paddingValues.calculateRightPadding(layoutDirection)
- val bottomPadding = paddingValues.calculateBottomPadding()
-
requirePrecondition(
- (leftPadding >= 0.dp) and
- (topPadding >= 0.dp) and
- (rightPadding >= 0.dp) and
- (bottomPadding >= 0.dp)
+ paddingValues.calculateLeftPadding(layoutDirection) >= 0.dp &&
+ paddingValues.calculateTopPadding() >= 0.dp &&
+ paddingValues.calculateRightPadding(layoutDirection) >= 0.dp &&
+ paddingValues.calculateBottomPadding() >= 0.dp
) {
"Padding must be non-negative"
}
-
- val roundedLeftPadding = leftPadding.roundToPx()
- val horizontal = roundedLeftPadding + rightPadding.roundToPx()
-
- val roundedTopPadding = topPadding.roundToPx()
- val vertical = roundedTopPadding + bottomPadding.roundToPx()
+ val horizontal =
+ paddingValues.calculateLeftPadding(layoutDirection).roundToPx() +
+ paddingValues.calculateRightPadding(layoutDirection).roundToPx()
+ val vertical =
+ paddingValues.calculateTopPadding().roundToPx() +
+ paddingValues.calculateBottomPadding().roundToPx()
val placeable = measurable.measure(constraints.offset(-horizontal, -vertical))
val width = constraints.constrainWidth(placeable.width + horizontal)
val height = constraints.constrainHeight(placeable.height + vertical)
- return layout(width, height) { placeable.place(roundedLeftPadding, roundedTopPadding) }
+ return layout(width, height) {
+ placeable.place(
+ paddingValues.calculateLeftPadding(layoutDirection).roundToPx(),
+ paddingValues.calculateTopPadding().roundToPx()
+ )
+ }
}
}
diff --git a/compose/foundation/foundation-layout/src/commonMain/kotlin/androidx/compose/foundation/layout/Size.kt b/compose/foundation/foundation-layout/src/commonMain/kotlin/androidx/compose/foundation/layout/Size.kt
index 52e00de..b221c8b 100644
--- a/compose/foundation/foundation-layout/src/commonMain/kotlin/androidx/compose/foundation/layout/Size.kt
+++ b/compose/foundation/foundation-layout/src/commonMain/kotlin/androidx/compose/foundation/layout/Size.kt
@@ -39,10 +39,6 @@
import androidx.compose.ui.unit.constrain
import androidx.compose.ui.unit.constrainHeight
import androidx.compose.ui.unit.constrainWidth
-import androidx.compose.ui.unit.isSpecified
-import androidx.compose.ui.util.fastCoerceAtLeast
-import androidx.compose.ui.util.fastCoerceAtMost
-import androidx.compose.ui.util.fastCoerceIn
import androidx.compose.ui.util.fastRoundToInt
/**
@@ -698,7 +694,7 @@
val width =
(constraints.maxWidth * fraction)
.fastRoundToInt()
- .fastCoerceIn(constraints.minWidth, constraints.maxWidth)
+ .coerceIn(constraints.minWidth, constraints.maxWidth)
minWidth = width
maxWidth = width
} else {
@@ -711,7 +707,7 @@
val height =
(constraints.maxHeight * fraction)
.fastRoundToInt()
- .fastCoerceIn(constraints.minHeight, constraints.maxHeight)
+ .coerceIn(constraints.minHeight, constraints.maxHeight)
minHeight = height
maxHeight = height
} else {
@@ -786,28 +782,28 @@
private val Density.targetConstraints: Constraints
get() {
val maxWidth =
- if (maxWidth.isSpecified) {
- maxWidth.roundToPx().fastCoerceAtLeast(0)
+ if (maxWidth != Dp.Unspecified) {
+ maxWidth.roundToPx().coerceAtLeast(0)
} else {
Constraints.Infinity
}
val maxHeight =
- if (maxHeight.isSpecified) {
- maxHeight.roundToPx().fastCoerceAtLeast(0)
+ if (maxHeight != Dp.Unspecified) {
+ maxHeight.roundToPx().coerceAtLeast(0)
} else {
Constraints.Infinity
}
val minWidth =
- if (minWidth.isSpecified) {
- minWidth.roundToPx().fastCoerceIn(0, maxWidth).let {
+ if (minWidth != Dp.Unspecified) {
+ minWidth.roundToPx().coerceAtMost(maxWidth).coerceAtLeast(0).let {
if (it != Constraints.Infinity) it else 0
}
} else {
0
}
val minHeight =
- if (minHeight.isSpecified) {
- minHeight.roundToPx().fastCoerceIn(0, maxHeight).let {
+ if (minHeight != Dp.Unspecified) {
+ minHeight.roundToPx().coerceAtMost(maxHeight).coerceAtLeast(0).let {
if (it != Constraints.Infinity) it else 0
}
} else {
@@ -831,28 +827,28 @@
constraints.constrain(targetConstraints)
} else {
val resolvedMinWidth =
- if (minWidth.isSpecified) {
+ if (minWidth != Dp.Unspecified) {
targetConstraints.minWidth
} else {
- constraints.minWidth.fastCoerceAtMost(targetConstraints.maxWidth)
+ constraints.minWidth.coerceAtMost(targetConstraints.maxWidth)
}
val resolvedMaxWidth =
- if (maxWidth.isSpecified) {
+ if (maxWidth != Dp.Unspecified) {
targetConstraints.maxWidth
} else {
- constraints.maxWidth.fastCoerceAtLeast(targetConstraints.minWidth)
+ constraints.maxWidth.coerceAtLeast(targetConstraints.minWidth)
}
val resolvedMinHeight =
- if (minHeight.isSpecified) {
+ if (minHeight != Dp.Unspecified) {
targetConstraints.minHeight
} else {
- constraints.minHeight.fastCoerceAtMost(targetConstraints.maxHeight)
+ constraints.minHeight.coerceAtMost(targetConstraints.maxHeight)
}
val resolvedMaxHeight =
- if (maxHeight.isSpecified) {
+ if (maxHeight != Dp.Unspecified) {
targetConstraints.maxHeight
} else {
- constraints.maxHeight.fastCoerceAtLeast(targetConstraints.minHeight)
+ constraints.maxHeight.coerceAtLeast(targetConstraints.minHeight)
}
Constraints(
resolvedMinWidth,
@@ -1076,14 +1072,14 @@
): MeasureResult {
val wrappedConstraints =
Constraints(
- if (minWidth.isSpecified && constraints.minWidth == 0) {
- minWidth.roundToPx().fastCoerceIn(0, constraints.maxWidth)
+ if (minWidth != Dp.Unspecified && constraints.minWidth == 0) {
+ minWidth.roundToPx().coerceAtMost(constraints.maxWidth).coerceAtLeast(0)
} else {
constraints.minWidth
},
constraints.maxWidth,
- if (minHeight.isSpecified && constraints.minHeight == 0) {
- minHeight.roundToPx().fastCoerceIn(0, constraints.maxHeight)
+ if (minHeight != Dp.Unspecified && constraints.minHeight == 0) {
+ minHeight.roundToPx().coerceAtMost(constraints.maxHeight).coerceAtLeast(0)
} else {
constraints.minHeight
},
@@ -1099,7 +1095,7 @@
) =
measurable
.minIntrinsicWidth(height)
- .fastCoerceAtLeast(if (minWidth.isSpecified) minWidth.roundToPx() else 0)
+ .coerceAtLeast(if (minWidth != Dp.Unspecified) minWidth.roundToPx() else 0)
override fun IntrinsicMeasureScope.maxIntrinsicWidth(
measurable: IntrinsicMeasurable,
@@ -1107,7 +1103,7 @@
) =
measurable
.maxIntrinsicWidth(height)
- .fastCoerceAtLeast(if (minWidth.isSpecified) minWidth.roundToPx() else 0)
+ .coerceAtLeast(if (minWidth != Dp.Unspecified) minWidth.roundToPx() else 0)
override fun IntrinsicMeasureScope.minIntrinsicHeight(
measurable: IntrinsicMeasurable,
@@ -1115,7 +1111,7 @@
) =
measurable
.minIntrinsicHeight(width)
- .fastCoerceAtLeast(if (minHeight.isSpecified) minHeight.roundToPx() else 0)
+ .coerceAtLeast(if (minHeight != Dp.Unspecified) minHeight.roundToPx() else 0)
override fun IntrinsicMeasureScope.maxIntrinsicHeight(
measurable: IntrinsicMeasurable,
@@ -1123,7 +1119,7 @@
) =
measurable
.maxIntrinsicHeight(width)
- .fastCoerceAtLeast(if (minHeight.isSpecified) minHeight.roundToPx() else 0)
+ .coerceAtLeast(if (minHeight != Dp.Unspecified) minHeight.roundToPx() else 0)
}
internal enum class Direction {
diff --git a/compose/foundation/foundation/build.gradle b/compose/foundation/foundation/build.gradle
index 91a0069..0d84d31 100644
--- a/compose/foundation/foundation/build.gradle
+++ b/compose/foundation/foundation/build.gradle
@@ -50,7 +50,6 @@
implementation(project(":compose:ui:ui-text"))
implementation(project(":compose:ui:ui-util"))
implementation(project(":compose:foundation:foundation-layout"))
- implementation(project(":performance:performance-annotation"))
}
}
@@ -81,16 +80,12 @@
dependsOn(commonMain)
}
- nonAndroidStubsMain {
+ jvmStubsMain {
dependsOn(commonStubsMain)
}
- jvmStubsMain {
- dependsOn(nonAndroidStubsMain)
- }
-
linuxx64StubsMain {
- dependsOn(nonAndroidStubsMain)
+ dependsOn(commonStubsMain)
}
androidInstrumentedTest {
diff --git a/compose/foundation/foundation/proguard-rules.pro b/compose/foundation/foundation/proguard-rules.pro
index 2d14261..d07663a 100644
--- a/compose/foundation/foundation/proguard-rules.pro
+++ b/compose/foundation/foundation/proguard-rules.pro
@@ -15,12 +15,8 @@
# Keep all the functions created to throw an exception. We don't want these functions to be
# inlined in any way, which R8 will do by default. The whole point of these functions is to
# reduce the amount of code generated at the call site.
--keepclassmembers,allowshrinking,allowobfuscation class androidx.compose.**.* {
+-keep,allowshrinking,allowobfuscation class androidx.compose.**.* {
static void throw*Exception(...);
# For methods returning Nothing
static java.lang.Void throw*Exception(...);
}
-
--keepclassmembers class * {
- @dalvik.annotation.optimization.NeverInline *;
-}
diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/BasicTextFieldTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/BasicTextFieldTest.kt
index 78ac040..7f8b8f7 100644
--- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/BasicTextFieldTest.kt
+++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/BasicTextFieldTest.kt
@@ -16,6 +16,7 @@
package androidx.compose.foundation.text.input
+import android.R
import android.os.Build
import android.text.InputType
import android.text.SpannableStringBuilder
@@ -40,7 +41,10 @@
import androidx.compose.foundation.text.TEST_FONT_FAMILY
import androidx.compose.foundation.text.computeSizeForDefaultText
import androidx.compose.foundation.text.input.TextFieldBuffer.ChangeList
+import androidx.compose.foundation.text.input.internal.TextLayoutState
+import androidx.compose.foundation.text.input.internal.TransformedTextFieldState
import androidx.compose.foundation.text.input.internal.selection.FakeClipboard
+import androidx.compose.foundation.text.input.internal.selection.TextFieldSelectionState
import androidx.compose.foundation.text.input.internal.setComposingRegion
import androidx.compose.foundation.text.selection.fetchTextLayoutResult
import androidx.compose.foundation.verticalScroll
@@ -127,6 +131,9 @@
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.times
+import org.mockito.kotlin.verify
@OptIn(ExperimentalFoundationApi::class, ExperimentalTestApi::class)
@LargeTest
@@ -1130,9 +1137,7 @@
requestFocus(Tag)
- inputMethodInterceptor.withInputConnection {
- performContextMenuAction(android.R.id.selectAll)
- }
+ inputMethodInterceptor.withInputConnection { performContextMenuAction(R.id.selectAll) }
rule.runOnIdle {
assertThat(state.selection).isEqualTo(TextRange(0, 5))
@@ -1152,7 +1157,7 @@
requestFocus(Tag)
- inputMethodInterceptor.withInputConnection { performContextMenuAction(android.R.id.cut) }
+ inputMethodInterceptor.withInputConnection { performContextMenuAction(R.id.cut) }
rule.waitForIdle()
assertThat(clipboard.getClipEntry()?.readText()).isEqualTo("He")
@@ -1171,7 +1176,7 @@
requestFocus(Tag)
- inputMethodInterceptor.withInputConnection { performContextMenuAction(android.R.id.copy) }
+ inputMethodInterceptor.withInputConnection { performContextMenuAction(R.id.copy) }
rule.waitForIdle()
assertThat(clipboard.getClipEntry()?.readText()).isEqualTo("He")
@@ -1189,7 +1194,7 @@
requestFocus(Tag)
- inputMethodInterceptor.withInputConnection { performContextMenuAction(android.R.id.paste) }
+ inputMethodInterceptor.withInputConnection { performContextMenuAction(R.id.paste) }
rule.runOnIdle {
assertThat(state.text.toString()).isEqualTo("Worldo")
@@ -1299,6 +1304,34 @@
}
@Test
+ fun textField_state_invokesAutofill() {
+ val mockLambda: () -> Unit = mock()
+ var density by mutableStateOf(Density(1f))
+
+ val manager =
+ TextFieldSelectionState(
+ // other parameters not necessary to test autofill invocation
+ textFieldState =
+ TransformedTextFieldState(
+ textFieldState = TextFieldState(),
+ inputTransformation = null,
+ codepointTransformation = null,
+ outputTransformation = null
+ ),
+ textLayoutState = TextLayoutState(),
+ density = density,
+ enabled = true,
+ readOnly = false,
+ isFocused = false,
+ isPassword = false
+ )
+ .apply { requestAutofillAction = mockLambda }
+
+ manager.autofill()
+ verify(mockLambda, times(1)).invoke()
+ }
+
+ @Test
fun changingInputTransformation_doesNotRestartInput() {
var inputTransformation by mutableStateOf(InputTransformation.maxLength(10))
inputMethodInterceptor.setTextFieldTestContent {
diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/TextFieldKeyEventTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/TextFieldKeyEventTest.kt
index a90d80a..50eb946 100644
--- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/TextFieldKeyEventTest.kt
+++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/TextFieldKeyEventTest.kt
@@ -30,6 +30,7 @@
import androidx.compose.foundation.text.input.TextFieldLineLimits.SingleLine
import androidx.compose.foundation.text.input.internal.selection.FakeClipboard
import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
@@ -854,6 +855,38 @@
}
}
+ @Test
+ fun textField_keyEvent_functionReference() {
+ val state = mutableIntStateOf(0)
+ var handled = -1
+ val focusRequester = FocusRequester()
+ rule.setContent {
+ val stateValue = state.value
+
+ @Suppress("UNUSED_PARAMETER")
+ fun handle(key: KeyEvent): Boolean {
+ handled = stateValue
+ return true
+ }
+
+ BasicTextField(
+ value = "text",
+ onValueChange = {},
+ modifier = Modifier.focusRequester(focusRequester).testTag(tag).onKeyEvent(::handle)
+ )
+ }
+
+ rule.runOnIdle { focusRequester.requestFocus() }
+ rule.onNodeWithTag(tag).performKeyInput { pressKey(Key.A) }
+ rule.runOnIdle {
+ assertThat(handled).isEqualTo(0)
+ state.value += 1
+ }
+
+ rule.onNodeWithTag(tag).performKeyInput { pressKey(Key.A) }
+ rule.runOnIdle { assertThat(handled).isEqualTo(1) }
+ }
+
private inner class SequenceScope(
val state: TextFieldState,
val clipboard: Clipboard,
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 fb28556..9d8e33e 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
@@ -23,7 +23,6 @@
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
@@ -98,7 +97,6 @@
private val hapticFeedback = mock<HapticFeedback>()
private val focusRequester = mock<FocusRequester>()
private val multiParagraph = mock<MultiParagraph>()
- private val autofillManager = mock<AutofillManager>()
@Before
fun setup() {
@@ -110,7 +108,6 @@
manager.textToolbar = textToolbar
manager.hapticFeedBack = hapticFeedback
manager.focusRequester = focusRequester
- manager.autofillManager = autofillManager
manager.coroutineScope = null
whenever(layoutResult.layoutInput)
@@ -370,10 +367,12 @@
@Test
fun autofill_selection_collapse() {
manager.value = TextFieldValue(text = text, selection = TextRange(4, 4))
+ val mockLambda: () -> Unit = mock()
+ val manager = TextFieldSelectionManager().apply { requestAutofillAction = mockLambda }
manager.autofill()
- verify(autofillManager, times(1)).requestAutofillForActiveElement()
+ verify(mockLambda, times(1)).invoke()
assertThat(state.handleState).isEqualTo(HandleState.None)
}
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 7c73fa8..7a034224 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
@@ -443,28 +443,44 @@
measurable: IntrinsicMeasurable,
height: Int
): Int {
- return measurable.minIntrinsicWidth(if (isVertical) Constraints.Infinity else height)
+ return if (isVertical) {
+ measurable.minIntrinsicWidth(Constraints.Infinity)
+ } else {
+ measurable.minIntrinsicWidth(height)
+ }
}
override fun IntrinsicMeasureScope.minIntrinsicHeight(
measurable: IntrinsicMeasurable,
width: Int
): Int {
- return measurable.minIntrinsicHeight(if (isVertical) width else Constraints.Infinity)
+ return if (isVertical) {
+ measurable.minIntrinsicHeight(width)
+ } else {
+ measurable.minIntrinsicHeight(Constraints.Infinity)
+ }
}
override fun IntrinsicMeasureScope.maxIntrinsicWidth(
measurable: IntrinsicMeasurable,
height: Int
): Int {
- return measurable.maxIntrinsicWidth(if (isVertical) Constraints.Infinity else height)
+ return if (isVertical) {
+ measurable.maxIntrinsicWidth(Constraints.Infinity)
+ } else {
+ measurable.maxIntrinsicWidth(height)
+ }
}
override fun IntrinsicMeasureScope.maxIntrinsicHeight(
measurable: IntrinsicMeasurable,
width: Int
): Int {
- return measurable.maxIntrinsicHeight(if (isVertical) width else Constraints.Infinity)
+ return if (isVertical) {
+ measurable.maxIntrinsicHeight(width)
+ } else {
+ measurable.maxIntrinsicHeight(Constraints.Infinity)
+ }
}
override fun SemanticsPropertyReceiver.applySemantics() {
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/AnchoredDraggable.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/AnchoredDraggable.kt
index db0c790..dd3ef0b 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/AnchoredDraggable.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/AnchoredDraggable.kt
@@ -62,7 +62,6 @@
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
-import dalvik.annotation.optimization.NeverInline
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
@@ -633,7 +632,6 @@
positions[keys.size - 1] = position
}
- @NeverInline
internal fun buildPositions(): FloatArray {
// We might have expanded more than we actually need, so trim the array
return positions.copyOfRange(
@@ -645,7 +643,6 @@
internal fun buildKeys(): List<T> = keys
- @NeverInline
private fun expandPositions() {
positions = positions.copyOf(keys.size + 2)
}
@@ -1653,7 +1650,7 @@
}
}
-internal expect inline fun assertOnJvm(statement: Boolean, message: () -> String)
+internal expect inline fun assertOnJvm(statement: Boolean, message: () -> String): Unit
internal val AnchoredDraggableMinFlingVelocity = 125.dp
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/internal/InlineClassHelper.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/internal/InlineClassHelper.kt
index 686d885..4af862a 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/internal/InlineClassHelper.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/internal/InlineClassHelper.kt
@@ -53,7 +53,7 @@
}
}
-@Suppress("NOTHING_TO_INLINE", "BanInlineOptIn", "KotlinRedundantDiagnosticSuppress")
+@Suppress("NOTHING_TO_INLINE", "BanInlineOptIn")
@OptIn(ExperimentalContracts::class)
internal inline fun checkPrecondition(value: Boolean) {
contract { returns() implies value }
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/BasicTextField.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/BasicTextField.kt
index 925ecfd..7e63192 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/BasicTextField.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/BasicTextField.kt
@@ -68,7 +68,6 @@
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.input.pointer.pointerHoverIcon
import androidx.compose.ui.input.pointer.pointerInput
-import androidx.compose.ui.platform.LocalAutofillManager
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
@@ -301,7 +300,6 @@
val currentHapticFeedback = LocalHapticFeedback.current
val currentClipboard = LocalClipboard.current
val currentTextToolbar = LocalTextToolbar.current
- val autofillManager = LocalAutofillManager.current
val textToolbarHandler =
remember(coroutineScope, currentTextToolbar) {
@@ -360,7 +358,6 @@
enabled = enabled,
readOnly = readOnly,
isPassword = isPassword,
- autofillManager = autofillManager,
showTextToolbar = textToolbarHandler
)
}
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 14f8402..494f392 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
@@ -27,6 +27,7 @@
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.foundation.text.handwriting.stylusHandwriting
+import androidx.compose.foundation.text.input.internal.CoreTextFieldSemanticsModifier
import androidx.compose.foundation.text.input.internal.createLegacyPlatformTextInputServiceAdapter
import androidx.compose.foundation.text.input.internal.legacyTextInputAdapter
import androidx.compose.foundation.text.selection.LocalTextSelectionColors
@@ -58,7 +59,6 @@
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.snapshots.Snapshot
import androidx.compose.ui.Modifier
-import androidx.compose.ui.autofill.ContentDataType
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.focus.FocusManager
import androidx.compose.ui.focus.FocusRequester
@@ -82,7 +82,6 @@
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.LocalClipboard
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
@@ -92,33 +91,13 @@
import androidx.compose.ui.platform.LocalTextToolbar
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.platform.SoftwareKeyboardController
-import androidx.compose.ui.semantics.contentDataType
-import androidx.compose.ui.semantics.copyText
-import androidx.compose.ui.semantics.cutText
-import androidx.compose.ui.semantics.disabled
-import androidx.compose.ui.semantics.editableText
-import androidx.compose.ui.semantics.getTextLayoutResult
-import androidx.compose.ui.semantics.insertTextAtCursor
-import androidx.compose.ui.semantics.isEditable
-import androidx.compose.ui.semantics.onAutofillText
-import androidx.compose.ui.semantics.onClick
-import androidx.compose.ui.semantics.onImeAction
-import androidx.compose.ui.semantics.onLongClick
-import androidx.compose.ui.semantics.password
-import androidx.compose.ui.semantics.pasteText
import androidx.compose.ui.semantics.semantics
-import androidx.compose.ui.semantics.setSelection
-import androidx.compose.ui.semantics.setText
-import androidx.compose.ui.semantics.textSelectionRange
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
-import androidx.compose.ui.text.input.CommitTextCommand
-import androidx.compose.ui.text.input.DeleteAllCommand
import androidx.compose.ui.text.input.EditProcessor
-import androidx.compose.ui.text.input.FinishComposingTextCommand
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.ImeOptions
import androidx.compose.ui.text.input.KeyboardType
@@ -316,7 +295,6 @@
manager.coroutineScope = coroutineScope
manager.textToolbar = LocalTextToolbar.current
manager.hapticFeedBack = LocalHapticFeedback.current
- manager.autofillManager = LocalAutofillManager.current
manager.focusRequester = focusRequester
manager.editable = !readOnly
manager.enabled = enabled
@@ -474,151 +452,18 @@
val isPassword = visualTransformation is PasswordVisualTransformation
val semanticsModifier =
- Modifier.semantics(true) {
- // focused semantics are handled by Modifier.focusable()
- this.editableText = transformedText.text
- this.textSelectionRange = value.selection
-
- // The developer will set `contentType`. CTF populates the other autofill-related
- // semantics. And since we're in a TextField, set the `contentDataType` to be "Text".
- this.contentDataType = ContentDataType.Text
- onAutofillText { text ->
- state.justAutofilled = true
- state.autofillHighlightOn = true
- handleTextUpdateFromSemantics(state, text.text, readOnly, enabled)
- true
- }
-
- if (!enabled) this.disabled()
- if (isPassword) this.password()
- val editable = enabled && !readOnly
- isEditable = editable
- getTextLayoutResult {
- if (state.layoutResult != null) {
- it.add(state.layoutResult!!.value)
- true
- } else {
- false
- }
- }
- if (editable) {
- setText { text ->
- handleTextUpdateFromSemantics(state, text.text, readOnly, enabled)
- true
- }
-
- insertTextAtCursor { text ->
- if (readOnly || !enabled) return@insertTextAtCursor false
-
- // If the action is performed while in an active text editing session, treat
- // this like an IME command and update the text by going through the buffer.
- // This keeps the buffer state consistent if other IME commands are performed
- // before the next recomposition, and is used for the testing code path.
- state.inputSession?.let { session ->
- TextFieldDelegate.onEditCommand(
- // Finish composing text first because when the field is focused the IME
- // might
- // set composition.
- ops = listOf(FinishComposingTextCommand(), CommitTextCommand(text, 1)),
- editProcessor = state.processor,
- state.onValueChange,
- session
- )
- }
- ?: run {
- val newText =
- value.text.replaceRange(
- value.selection.start,
- value.selection.end,
- text
- )
- val newCursor = TextRange(value.selection.start + text.length)
- state.onValueChange(TextFieldValue(newText, newCursor))
- }
- true
- }
- }
-
- setSelection { selectionStart, selectionEnd, relativeToOriginalText ->
- // in traversal mode we get selection from the `textSelectionRange` semantics which
- // is
- // selection in original text. In non-traversal mode selection comes from the
- // Talkback
- // and indices are relative to the transformed text
- val start =
- if (relativeToOriginalText) {
- selectionStart
- } else {
- offsetMapping.transformedToOriginal(selectionStart)
- }
- val end =
- if (relativeToOriginalText) {
- selectionEnd
- } else {
- offsetMapping.transformedToOriginal(selectionEnd)
- }
-
- if (!enabled) {
- false
- } else if (start == value.selection.start && end == value.selection.end) {
- false
- } else if (
- minOf(start, end) >= 0 && maxOf(start, end) <= value.annotatedString.length
- ) {
- // Do not show toolbar if it's a traversal mode (with the volume keys), or
- // if the cursor just moved to beginning or end.
- if (relativeToOriginalText || start == end) {
- manager.exitSelectionMode()
- } else {
- manager.enterSelectionMode()
- }
- state.onValueChange(
- TextFieldValue(value.annotatedString, TextRange(start, end))
- )
- true
- } else {
- manager.exitSelectionMode()
- false
- }
- }
- onImeAction(imeOptions.imeAction) {
- // This will perform the appropriate default action if no handler has been
- // specified, so
- // as far as the platform is concerned, we always handle the action and never want
- // to
- // defer to the default _platform_ implementation.
- state.onImeActionPerformed(imeOptions.imeAction)
- true
- }
- onClick {
- // according to the documentation, we still need to provide proper semantics actions
- // even if the state is 'disabled'
- tapToFocus(state, focusRequester, !readOnly)
- true
- }
- onLongClick {
- manager.enterSelectionMode()
- true
- }
- if (!value.selection.collapsed && !isPassword) {
- copyText {
- manager.copy()
- true
- }
- if (enabled && !readOnly) {
- cutText {
- manager.cut()
- true
- }
- }
- }
- if (enabled && !readOnly) {
- pasteText {
- manager.paste()
- true
- }
- }
- }
+ CoreTextFieldSemanticsModifier(
+ transformedText,
+ value,
+ state,
+ readOnly,
+ enabled,
+ isPassword,
+ offsetMapping,
+ manager,
+ imeOptions,
+ focusRequester
+ )
val showCursor = enabled && !readOnly && windowInfo.isWindowFocused && !state.hasHighlight()
val cursorModifier = Modifier.cursor(state, value, offsetMapping, cursorBrush, showCursor)
@@ -884,30 +729,6 @@
}
}
-/**
- * In an active input session, semantics updates are handled just as user updates coming from the
- * IME. Otherwise the updates are directly applied on the current state.
- */
-private fun handleTextUpdateFromSemantics(
- state: LegacyTextFieldState,
- text: String,
- readOnly: Boolean,
- enabled: Boolean
-) {
- if (readOnly || !enabled) return
-
- // If the action is performed while in an active text editing session, treat this
- // like an IME command and update the text by going through the buffer.
- state.inputSession?.let { session ->
- TextFieldDelegate.onEditCommand(
- ops = listOf(DeleteAllCommand(), CommitTextCommand(text, 1)),
- editProcessor = state.processor,
- state.onValueChange,
- session
- )
- } ?: run { state.onValueChange(TextFieldValue(text, TextRange(text.length))) }
-}
-
internal class LegacyTextFieldState(
var textDelegate: TextDelegate,
val recomposeScope: RecomposeScope,
@@ -1108,7 +929,7 @@
}
/** Request focus on tap. If already focused, makes sure the keyboard is requested. */
-private fun tapToFocus(
+internal fun tapToFocus(
state: LegacyTextFieldState,
focusRequester: FocusRequester,
allowKeyboard: Boolean
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/CoreTextFieldSemanticsModifier.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/CoreTextFieldSemanticsModifier.kt
new file mode 100644
index 0000000..a5fe111
--- /dev/null
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/CoreTextFieldSemanticsModifier.kt
@@ -0,0 +1,334 @@
+/*
+ * Copyright 2025 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.foundation.text.input.internal
+
+import androidx.compose.foundation.text.LegacyTextFieldState
+import androidx.compose.foundation.text.TextFieldDelegate
+import androidx.compose.foundation.text.selection.TextFieldSelectionManager
+import androidx.compose.foundation.text.tapToFocus
+import androidx.compose.ui.autofill.ContentDataType
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.node.DelegatingNode
+import androidx.compose.ui.node.ModifierNodeElement
+import androidx.compose.ui.node.SemanticsModifierNode
+import androidx.compose.ui.node.invalidateSemantics
+import androidx.compose.ui.node.requestAutofill
+import androidx.compose.ui.platform.InspectorInfo
+import androidx.compose.ui.semantics.SemanticsPropertyReceiver
+import androidx.compose.ui.semantics.contentDataType
+import androidx.compose.ui.semantics.copyText
+import androidx.compose.ui.semantics.cutText
+import androidx.compose.ui.semantics.disabled
+import androidx.compose.ui.semantics.editableText
+import androidx.compose.ui.semantics.getTextLayoutResult
+import androidx.compose.ui.semantics.insertTextAtCursor
+import androidx.compose.ui.semantics.isEditable
+import androidx.compose.ui.semantics.onAutofillText
+import androidx.compose.ui.semantics.onClick
+import androidx.compose.ui.semantics.onImeAction
+import androidx.compose.ui.semantics.onLongClick
+import androidx.compose.ui.semantics.password
+import androidx.compose.ui.semantics.pasteText
+import androidx.compose.ui.semantics.setSelection
+import androidx.compose.ui.semantics.setText
+import androidx.compose.ui.semantics.textSelectionRange
+import androidx.compose.ui.text.TextRange
+import androidx.compose.ui.text.input.CommitTextCommand
+import androidx.compose.ui.text.input.DeleteAllCommand
+import androidx.compose.ui.text.input.FinishComposingTextCommand
+import androidx.compose.ui.text.input.ImeOptions
+import androidx.compose.ui.text.input.OffsetMapping
+import androidx.compose.ui.text.input.TextFieldValue
+import androidx.compose.ui.text.input.TransformedText
+
+internal data class CoreTextFieldSemanticsModifier(
+ val transformedText: TransformedText,
+ val value: TextFieldValue,
+ val state: LegacyTextFieldState,
+ val readOnly: Boolean,
+ val enabled: Boolean,
+ val isPassword: Boolean,
+ val offsetMapping: OffsetMapping,
+ val manager: TextFieldSelectionManager,
+ val imeOptions: ImeOptions,
+ val focusRequester: FocusRequester
+) : ModifierNodeElement<CoreTextFieldSemanticsModifierNode>() {
+ override fun create(): CoreTextFieldSemanticsModifierNode =
+ CoreTextFieldSemanticsModifierNode(
+ transformedText = transformedText,
+ value = value,
+ state = state,
+ readOnly = readOnly,
+ enabled = enabled,
+ isPassword = isPassword,
+ offsetMapping = offsetMapping,
+ manager = manager,
+ imeOptions = imeOptions,
+ focusRequester = focusRequester
+ )
+
+ override fun update(node: CoreTextFieldSemanticsModifierNode) {
+ node.updateNodeSemantics(
+ transformedText = transformedText,
+ value = value,
+ state = state,
+ readOnly = readOnly,
+ enabled = enabled,
+ isPassword = isPassword,
+ offsetMapping = offsetMapping,
+ manager = manager,
+ imeOptions = imeOptions,
+ focusRequester = focusRequester
+ )
+ }
+
+ override fun InspectorInfo.inspectableProperties() {
+ // Show nothing in the inspector.
+ }
+}
+
+internal class CoreTextFieldSemanticsModifierNode(
+ var transformedText: TransformedText,
+ var value: TextFieldValue,
+ var state: LegacyTextFieldState,
+ var readOnly: Boolean,
+ var enabled: Boolean,
+ var isPassword: Boolean,
+ var offsetMapping: OffsetMapping,
+ var manager: TextFieldSelectionManager,
+ var imeOptions: ImeOptions,
+ var focusRequester: FocusRequester
+) : DelegatingNode(), SemanticsModifierNode {
+ init {
+ manager.requestAutofillAction = { requestAutofill() }
+ }
+
+ override val shouldMergeDescendantSemantics: Boolean
+ get() = true
+
+ override fun SemanticsPropertyReceiver.applySemantics() {
+ this.editableText = transformedText.text
+ this.textSelectionRange = value.selection
+
+ // The developer will set `contentType`. CTF populates the other autofill-related
+ // semantics. And since we're in a TextField, set the `contentDataType` to be "Text".
+ this.contentDataType = ContentDataType.Text
+ onAutofillText { text ->
+ state.justAutofilled = true
+ state.autofillHighlightOn = true
+ handleTextUpdateFromSemantics(state, text.text, readOnly, enabled)
+ true
+ }
+
+ if (!enabled) this.disabled()
+ if (isPassword) this.password()
+ val editable = enabled && !readOnly
+ isEditable = editable
+ getTextLayoutResult {
+ if (state.layoutResult != null) {
+ it.add(state.layoutResult!!.value)
+ true
+ } else {
+ false
+ }
+ }
+
+ if (editable) {
+ setText { text ->
+ handleTextUpdateFromSemantics(state, text.text, readOnly, enabled)
+ true
+ }
+
+ insertTextAtCursor { text ->
+ if (readOnly || !enabled) return@insertTextAtCursor false
+
+ // If the action is performed while in an active text editing session, treat
+ // this like an IME command and update the text by going through the buffer.
+ // This keeps the buffer state consistent if other IME commands are performed
+ // before the next recomposition, and is used for the testing code path.
+ state.inputSession?.let { session ->
+ TextFieldDelegate.onEditCommand(
+ // Finish composing text first because when the field is focused the IME
+ // might
+ // set composition.
+ ops = listOf(FinishComposingTextCommand(), CommitTextCommand(text, 1)),
+ editProcessor = state.processor,
+ state.onValueChange,
+ session
+ )
+ }
+ ?: run {
+ val newText =
+ value.text.replaceRange(
+ value.selection.start,
+ value.selection.end,
+ text
+ )
+ val newCursor = TextRange(value.selection.start + text.length)
+ state.onValueChange(TextFieldValue(newText, newCursor))
+ }
+ true
+ }
+ }
+
+ setSelection { selectionStart, selectionEnd, relativeToOriginalText ->
+ // in traversal mode we get selection from the `textSelectionRange` semantics which
+ // is selection in original text. In non-traversal mode selection comes from the
+ // Talkback and indices are relative to the transformed text
+ val start =
+ if (relativeToOriginalText) {
+ selectionStart
+ } else {
+ offsetMapping.transformedToOriginal(selectionStart)
+ }
+ val end =
+ if (relativeToOriginalText) {
+ selectionEnd
+ } else {
+ offsetMapping.transformedToOriginal(selectionEnd)
+ }
+
+ if (!enabled) {
+ false
+ } else if (start == value.selection.start && end == value.selection.end) {
+ false
+ } else if (
+ minOf(start, end) >= 0 && maxOf(start, end) <= value.annotatedString.length
+ ) {
+ // Do not show toolbar if it's a traversal mode (with the volume keys), or
+ // if the cursor just moved to beginning or end.
+ if (relativeToOriginalText || start == end) {
+ manager.exitSelectionMode()
+ } else {
+ manager.enterSelectionMode()
+ }
+ state.onValueChange(TextFieldValue(value.annotatedString, TextRange(start, end)))
+ true
+ } else {
+ manager.exitSelectionMode()
+ false
+ }
+ }
+ onImeAction(imeOptions.imeAction) {
+ // This will perform the appropriate default action if no handler has been
+ // specified, so
+ // as far as the platform is concerned, we always handle the action and never want
+ // to
+ // defer to the default _platform_ implementation.
+ state.onImeActionPerformed(imeOptions.imeAction)
+ true
+ }
+ onClick {
+ // according to the documentation, we still need to provide proper semantics actions
+ // even if the state is 'disabled'
+ tapToFocus(state, focusRequester, !readOnly)
+ true
+ }
+ onLongClick {
+ manager.enterSelectionMode()
+ true
+ }
+ if (!value.selection.collapsed && !isPassword) {
+ copyText {
+ manager.copy()
+ true
+ }
+ if (enabled && !readOnly) {
+ cutText {
+ manager.cut()
+ true
+ }
+ }
+ }
+ if (enabled && !readOnly) {
+ pasteText {
+ manager.paste()
+ true
+ }
+ }
+ }
+
+ fun updateNodeSemantics(
+ transformedText: TransformedText,
+ value: TextFieldValue,
+ state: LegacyTextFieldState,
+ readOnly: Boolean,
+ enabled: Boolean,
+ isPassword: Boolean,
+ offsetMapping: OffsetMapping,
+ manager: TextFieldSelectionManager,
+ imeOptions: ImeOptions,
+ focusRequester: FocusRequester
+ ) {
+ // Find the diff: current previous and new values before updating current.
+ val previousEditable = this.enabled && !this.readOnly
+ val previousEnabled = this.enabled
+ val previousIsPassword = this.isPassword
+ val previousImeOptions = this.imeOptions
+ val previousManager = this.manager
+ val editable = enabled && !readOnly
+
+ // Apply the diff.
+ this.transformedText = transformedText
+ this.value = value
+ this.state = state
+ this.readOnly = readOnly
+ this.enabled = enabled
+ this.offsetMapping = offsetMapping
+ this.manager = manager
+ this.imeOptions = imeOptions
+ this.focusRequester = focusRequester
+
+ if (
+ enabled != previousEnabled ||
+ editable != previousEditable ||
+ imeOptions != previousImeOptions ||
+ isPassword != previousIsPassword ||
+ !value.selection.collapsed
+ ) {
+ invalidateSemantics()
+ }
+
+ if (manager != previousManager) {
+ manager.requestAutofillAction = { requestAutofill() }
+ }
+ }
+
+ /**
+ * In an active input session, semantics updates are handled just as user updates coming from
+ * the IME. Otherwise the updates are directly applied on the current state.
+ */
+ private fun handleTextUpdateFromSemantics(
+ state: LegacyTextFieldState,
+ text: String,
+ readOnly: Boolean,
+ enabled: Boolean
+ ) {
+ if (readOnly || !enabled) return
+
+ // If the action is performed while in an active text editing session, treat this
+ // like an IME command and update the text by going through the buffer.
+ state.inputSession?.let { session ->
+ TextFieldDelegate.onEditCommand(
+ ops = listOf(DeleteAllCommand(), CommitTextCommand(text, 1)),
+ editProcessor = state.processor,
+ state.onValueChange,
+ session
+ )
+ } ?: run { state.onValueChange(TextFieldValue(text, TextRange(text.length))) }
+ }
+}
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/OffsetMappingCalculator.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/OffsetMappingCalculator.kt
index eaa2856..b6fb607 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/OffsetMappingCalculator.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/OffsetMappingCalculator.kt
@@ -370,18 +370,18 @@
*/
@kotlin.jvm.JvmInline
private value class OpArray private constructor(private val values: IntArray) {
- constructor(size: Int) : this(IntArray(size * OpArrayElementSize))
+ constructor(size: Int) : this(IntArray(size * ElementSize))
val size: Int
- get() = values.size / OpArrayElementSize
+ get() = values.size / ElementSize
fun set(index: Int, offset: Int, srcLen: Int, destLen: Int) {
- values[index * OpArrayElementSize] = offset
- values[index * OpArrayElementSize + 1] = srcLen
- values[index * OpArrayElementSize + 2] = destLen
+ values[index * ElementSize] = offset
+ values[index * ElementSize + 1] = srcLen
+ values[index * ElementSize + 2] = destLen
}
- fun copyOf(newSize: Int) = OpArray(values.copyOf(newSize * OpArrayElementSize))
+ fun copyOf(newSize: Int) = OpArray(values.copyOf(newSize * ElementSize))
/**
* Loops through the array between 0 and [max] (exclusive). If [reversed] is false (the
@@ -399,20 +399,22 @@
// duplication here keeps the more complicated logic at the callsite more readable.
if (reversed) {
for (i in max - 1 downTo 0) {
- val offset = values[i * OpArrayElementSize]
- val srcLen = values[i * OpArrayElementSize + 1]
- val destLen = values[i * OpArrayElementSize + 2]
+ val offset = values[i * ElementSize]
+ val srcLen = values[i * ElementSize + 1]
+ val destLen = values[i * ElementSize + 2]
block(offset, srcLen, destLen)
}
} else {
for (i in 0 until max) {
- val offset = values[i * OpArrayElementSize]
- val srcLen = values[i * OpArrayElementSize + 1]
- val destLen = values[i * OpArrayElementSize + 2]
+ val offset = values[i * ElementSize]
+ val srcLen = values[i * ElementSize + 1]
+ val destLen = values[i * ElementSize + 2]
block(offset, srcLen, destLen)
}
}
}
-}
-private const val OpArrayElementSize = 3
+ private companion object {
+ const val ElementSize = 3
+ }
+}
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/TextFieldDecoratorModifier.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/TextFieldDecoratorModifier.kt
index c9eb589..d7810f9 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/TextFieldDecoratorModifier.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/TextFieldDecoratorModifier.kt
@@ -66,6 +66,7 @@
import androidx.compose.ui.node.currentValueOf
import androidx.compose.ui.node.invalidateSemantics
import androidx.compose.ui.node.observeReads
+import androidx.compose.ui.node.requestAutofill
import androidx.compose.ui.platform.InspectorInfo
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
@@ -202,8 +203,9 @@
ObserverModifierNode,
LayoutAwareModifierNode {
- private val editable
- get() = enabled && !readOnly
+ init {
+ textFieldSelectionState.requestAutofillAction = { requestAutofill() }
+ }
private val pointerInputNode =
delegate(
@@ -430,9 +432,7 @@
stylusHandwritingTrigger: MutableSharedFlow<Unit>?
) {
// Find the diff: current previous and new values before updating current.
- val previousEditable = this.editable
- val editable = enabled && !readOnly
-
+ val previousEditable = this.enabled && !this.readOnly
val previousEnabled = this.enabled
val previousTextFieldState = this.textFieldState
val previousKeyboardOptions = this.keyboardOptions
@@ -440,6 +440,7 @@
val previousInteractionSource = this.interactionSource
val previousIsPassword = this.isPassword
val previousStylusHandwritingTrigger = this.stylusHandwritingTrigger
+ val editable = enabled && !readOnly
// Apply the diff.
this.textFieldState = textFieldState
@@ -487,6 +488,7 @@
textFieldSelectionState.receiveContentConfiguration =
receiveContentConfigurationProvider
}
+ textFieldSelectionState.requestAutofillAction = { requestAutofill() }
}
if (interactionSource != previousInteractionSource) {
@@ -507,7 +509,8 @@
if (!enabled) disabled()
if (isPassword) password()
- isEditable = [email protected]
+ val editable = enabled && !readOnly
+ isEditable = editable
// The developer will set `contentType`. TF populates the other autofill-related
// semantics. And since we're in a TextField, set the `contentDataType` to be "Text".
@@ -630,6 +633,7 @@
isElementFocused = focusState.isFocused
onFocusChange()
+ val editable = enabled && !readOnly
if (focusState.isFocused) {
// Deselect when losing focus even if readonly.
if (editable) {
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldSelectionState.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldSelectionState.kt
index 5baa1a8..0a6c258 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldSelectionState.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldSelectionState.kt
@@ -66,7 +66,6 @@
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.snapshots.Snapshot
-import androidx.compose.ui.autofill.AutofillManager
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.isSpecified
@@ -115,9 +114,6 @@
var isFocused: Boolean,
private var isPassword: Boolean,
) {
- /** [AutofillManager] to perform Autofill. */
- private var autofillManager: AutofillManager? = null
-
/** [HapticFeedback] handle to perform haptic feedback. */
private var hapticFeedBack: HapticFeedback? = null
@@ -130,6 +126,9 @@
/** Whether user is interacting with the UI in touch mode. */
var isInTouchMode: Boolean by mutableStateOf(true)
+ /** The action to invoke when autofill is requested in text toolbar. */
+ var requestAutofillAction: (() -> Unit)? = null
+
/**
* Reduced [ReceiveContentConfiguration] from the attached modifier node hierarchy. This value
* is set by [TextFieldDecoratorModifierNode].
@@ -353,8 +352,7 @@
density: Density,
enabled: Boolean,
readOnly: Boolean,
- isPassword: Boolean,
- autofillManager: AutofillManager?
+ isPassword: Boolean
) {
if (!enabled) {
hideTextToolbar()
@@ -366,7 +364,6 @@
this.enabled = enabled
this.readOnly = readOnly
this.isPassword = isPassword
- this.autofillManager = autofillManager
}
/** Implements the complete set of gestures supported by the cursor handle. */
@@ -443,7 +440,6 @@
clipboard = null
hapticFeedBack = null
- autofillManager = null
}
/**
@@ -1395,7 +1391,7 @@
* Inserts credentials (if there exist any that match this field type) into the text field.
*/
fun autofill() {
- autofillManager?.requestAutofillForActiveElement()
+ requestAutofillAction?.invoke()
}
fun deselect() {
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/modifiers/SelectableTextAnnotatedStringNode.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/modifiers/SelectableTextAnnotatedStringNode.kt
index 363a279..a47838d 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/modifiers/SelectableTextAnnotatedStringNode.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/modifiers/SelectableTextAnnotatedStringNode.kt
@@ -62,6 +62,8 @@
autoSize: TextAutoSize? = null,
private var onShowTranslation: ((TextAnnotatedStringNode.TextSubstitutionValue) -> Unit)? = null
) : DelegatingNode(), LayoutModifierNode, DrawModifierNode, GlobalPositionAwareModifierNode {
+ override val shouldAutoInvalidate: Boolean
+ get() = false
private val textAnnotatedStringNode =
delegate(
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/modifiers/TextAnnotatedStringNode.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/modifiers/TextAnnotatedStringNode.kt
index 8a5c009..e8543a8 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/modifiers/TextAnnotatedStringNode.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/modifiers/TextAnnotatedStringNode.kt
@@ -82,6 +82,9 @@
private var autoSize: TextAutoSize? = null,
private var onShowTranslation: ((TextSubstitutionValue) -> Unit)? = null
) : Modifier.Node(), LayoutModifierNode, DrawModifierNode, SemanticsModifierNode {
+ override val shouldAutoInvalidate: Boolean
+ get() = false
+
@Suppress("PrimitiveInCollection")
private var baselineCache: MutableMap<AlignmentLine, Int>? = null
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 a8dd10b..e561553 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
@@ -77,6 +77,9 @@
private var minLines: Int = DefaultMinLines,
private var overrideColor: ColorProducer? = null
) : Modifier.Node(), LayoutModifierNode, DrawModifierNode, SemanticsModifierNode {
+ override val shouldAutoInvalidate: Boolean
+ get() = false
+
@Suppress("PrimitiveInCollection") // Map required for use in public API.
// Usages of this collection are so few that the gains of using
// MutableObjectIntMap<AlignmentLine> and then converting to a Map<AlignmentLine, Int>
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/selection/TextFieldSelectionManager.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/selection/TextFieldSelectionManager.kt
index 674b87a..6f75364 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/selection/TextFieldSelectionManager.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/selection/TextFieldSelectionManager.kt
@@ -38,7 +38,6 @@
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
-import androidx.compose.ui.autofill.AutofillManager
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
@@ -100,8 +99,8 @@
*/
internal var visualTransformation: VisualTransformation = VisualTransformation.None
- /** [AutofillManager] to perform clipboard features. */
- internal var autofillManager: AutofillManager? = null
+ /** The action to invoke when autofill is requested in text toolbar. */
+ internal var requestAutofillAction: (() -> Unit)? = null
/** [Clipboard] to perform clipboard features. */
internal var clipboard: Clipboard? = null
@@ -705,7 +704,7 @@
}
internal fun autofill() {
- autofillManager?.requestAutofillForActiveElement()
+ requestAutofillAction?.invoke()
}
internal fun getHandlePosition(isStartHandle: Boolean): Offset {
diff --git a/compose/material/material/src/androidInstrumentedTest/kotlin/androidx/compose/material/TextTest.kt b/compose/material/material/src/androidInstrumentedTest/kotlin/androidx/compose/material/TextTest.kt
index 68e6215..d2c14a0 100644
--- a/compose/material/material/src/androidInstrumentedTest/kotlin/androidx/compose/material/TextTest.kt
+++ b/compose/material/material/src/androidInstrumentedTest/kotlin/androidx/compose/material/TextTest.kt
@@ -18,11 +18,13 @@
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
+import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.semantics.getOrNull
+import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.assert
import androidx.compose.ui.test.assertTextEquals
@@ -112,6 +114,25 @@
}
@Test
+ fun testChangingFontSizeDoesNotInvalidateSemantics() {
+ val fontSize = mutableStateOf(16.sp)
+ var count = 0
+ val countModifier = Modifier.semantics { count++ }
+ rule.setContent {
+ ProvideTextStyle(ExpectedTextStyle) {
+ Box(Modifier.background(Color.White)) {
+ Text(modifier = countModifier, text = TestText, fontSize = fontSize.value)
+ }
+ }
+ }
+ rule.runOnIdle {
+ count = 0
+ fontSize.value = 20.sp
+ }
+ rule.runOnIdle { assertThat(count).isEqualTo(0) }
+ }
+
+ @Test
fun settingCustomTextStyle() {
var textColor: Color? = null
var textAlign: TextAlign? = null
diff --git a/compose/material3/adaptive/adaptive-layout/api/current.ignore b/compose/material3/adaptive/adaptive-layout/api/current.ignore
new file mode 100644
index 0000000..6562a08
--- /dev/null
+++ b/compose/material3/adaptive/adaptive-layout/api/current.ignore
@@ -0,0 +1,3 @@
+// Baseline format: 1.0
+ParameterNameChange: androidx.compose.material3.adaptive.layout.ThreePaneScaffoldDestinationItem#ThreePaneScaffoldDestinationItem(androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole, T) parameter #1:
+ Attempted to change parameter name from content to contentKey in constructor androidx.compose.material3.adaptive.layout.ThreePaneScaffoldDestinationItem
diff --git a/compose/material3/adaptive/adaptive-layout/api/current.txt b/compose/material3/adaptive/adaptive-layout/api/current.txt
index cbaeb22..b4cd5c9 100644
--- a/compose/material3/adaptive/adaptive-layout/api/current.txt
+++ b/compose/material3/adaptive/adaptive-layout/api/current.txt
@@ -94,7 +94,7 @@
property public androidx.compose.material3.adaptive.layout.ThreePaneScaffoldValue targetState;
}
- @SuppressCompatibility @androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi @kotlin.jvm.JvmInline public final value class PaneAdaptedValue {
+ @kotlin.jvm.JvmInline public final value class PaneAdaptedValue {
field public static final androidx.compose.material3.adaptive.layout.PaneAdaptedValue.Companion Companion;
}
@@ -110,12 +110,28 @@
property @androidx.compose.runtime.Composable public abstract String description;
}
- public static final class PaneExpansionAnchor.Offset extends androidx.compose.material3.adaptive.layout.PaneExpansionAnchor {
- ctor public PaneExpansionAnchor.Offset(float offset);
- method @androidx.compose.runtime.Composable public String getDescription();
- method public float getOffset();
- property @androidx.compose.runtime.Composable public String description;
+ public abstract static class PaneExpansionAnchor.Offset extends androidx.compose.material3.adaptive.layout.PaneExpansionAnchor {
+ method public final int getDirection();
+ method public final float getOffset();
+ property public final int direction;
property public final float offset;
+ field public static final androidx.compose.material3.adaptive.layout.PaneExpansionAnchor.Offset.Companion Companion;
+ }
+
+ public static final class PaneExpansionAnchor.Offset.Companion {
+ method public androidx.compose.material3.adaptive.layout.PaneExpansionAnchor.Offset fromEnd(float offset);
+ method public androidx.compose.material3.adaptive.layout.PaneExpansionAnchor.Offset fromStart(float offset);
+ }
+
+ @kotlin.jvm.JvmInline public static final value class PaneExpansionAnchor.Offset.Direction {
+ field public static final androidx.compose.material3.adaptive.layout.PaneExpansionAnchor.Offset.Direction.Companion Companion;
+ }
+
+ public static final class PaneExpansionAnchor.Offset.Direction.Companion {
+ method public int getFromEnd();
+ method public int getFromStart();
+ property public final int FromEnd;
+ property public final int FromStart;
}
public static final class PaneExpansionAnchor.Proportion extends androidx.compose.material3.adaptive.layout.PaneExpansionAnchor {
@@ -131,10 +147,13 @@
}
@SuppressCompatibility @androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi @androidx.compose.runtime.Stable public final class PaneExpansionState {
+ method public suspend Object? animateTo(androidx.compose.material3.adaptive.layout.PaneExpansionAnchor anchor, optional float initialVelocity, kotlin.coroutines.Continuation<? super kotlin.Unit>);
method public void clear();
+ method public androidx.compose.material3.adaptive.layout.PaneExpansionAnchor? getCurrentAnchor();
method public boolean isUnspecified();
method public void setFirstPaneProportion(@FloatRange(from=0.0, to=1.0) float firstPaneProportion);
method public void setFirstPaneWidth(int firstPaneWidth);
+ property public final androidx.compose.material3.adaptive.layout.PaneExpansionAnchor? currentAnchor;
field public static final androidx.compose.material3.adaptive.layout.PaneExpansionState.Companion Companion;
field public static final int Unspecified = -1; // 0xffffffff
}
@@ -168,12 +187,8 @@
property @SuppressCompatibility @androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveComponentOverrideApi public static final androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.material3.adaptive.layout.AnimatedPaneOverride> LocalAnimatedPaneOverride;
}
- @SuppressCompatibility @androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi public interface PaneMotion {
- method public androidx.compose.animation.EnterTransition getEnterTransition(androidx.compose.material3.adaptive.layout.PaneScaffoldMotionDataProvider<? extends java.lang.Object?>);
- method public androidx.compose.animation.ExitTransition getExitTransition(androidx.compose.material3.adaptive.layout.PaneScaffoldMotionDataProvider<? extends java.lang.Object?>);
+ @SuppressCompatibility @androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi @androidx.compose.runtime.Stable public sealed interface PaneMotion {
method public int getType();
- property public androidx.compose.animation.EnterTransition enterTransition;
- property public androidx.compose.animation.ExitTransition exitTransition;
property public abstract int type;
field public static final androidx.compose.material3.adaptive.layout.PaneMotion.Companion Companion;
}
@@ -248,6 +263,8 @@
}
public final class PaneMotionKt {
+ method @SuppressCompatibility @androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi public static <Role> androidx.compose.animation.EnterTransition calculateDefaultEnterTransition(androidx.compose.material3.adaptive.layout.PaneScaffoldMotionDataProvider<Role>, Role role);
+ method @SuppressCompatibility @androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi public static <Role> androidx.compose.animation.ExitTransition calculateDefaultExitTransition(androidx.compose.material3.adaptive.layout.PaneScaffoldMotionDataProvider<Role>, Role role);
method @SuppressCompatibility @androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi public static inline <Role> void forEach(androidx.compose.material3.adaptive.layout.PaneScaffoldMotionDataProvider<Role>, kotlin.jvm.functions.Function2<? super Role,? super androidx.compose.material3.adaptive.layout.PaneMotionData,kotlin.Unit> action);
method @SuppressCompatibility @androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi public static inline <Role> void forEachReversed(androidx.compose.material3.adaptive.layout.PaneScaffoldMotionDataProvider<Role>, kotlin.jvm.functions.Function2<? super Role,? super androidx.compose.material3.adaptive.layout.PaneMotionData,kotlin.Unit> action);
}
@@ -276,8 +293,8 @@
}
public final class PaneScaffoldDirectiveKt {
- method @SuppressCompatibility @androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi public static androidx.compose.material3.adaptive.layout.PaneScaffoldDirective calculatePaneScaffoldDirective(androidx.compose.material3.adaptive.WindowAdaptiveInfo windowAdaptiveInfo, optional int verticalHingePolicy);
- method @SuppressCompatibility @androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi public static androidx.compose.material3.adaptive.layout.PaneScaffoldDirective calculatePaneScaffoldDirectiveWithTwoPanesOnMediumWidth(androidx.compose.material3.adaptive.WindowAdaptiveInfo windowAdaptiveInfo, optional int verticalHingePolicy);
+ method public static androidx.compose.material3.adaptive.layout.PaneScaffoldDirective calculatePaneScaffoldDirective(androidx.compose.material3.adaptive.WindowAdaptiveInfo windowAdaptiveInfo, optional int verticalHingePolicy);
+ method public static androidx.compose.material3.adaptive.layout.PaneScaffoldDirective calculatePaneScaffoldDirectiveWithTwoPanesOnMediumWidth(androidx.compose.material3.adaptive.WindowAdaptiveInfo windowAdaptiveInfo, optional int verticalHingePolicy);
}
@SuppressCompatibility @androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi public sealed interface PaneScaffoldMotionDataProvider<Role> {
@@ -312,13 +329,9 @@
}
@SuppressCompatibility @androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi public sealed interface PaneScaffoldTransitionScope<Role, ScaffoldValue extends androidx.compose.material3.adaptive.layout.PaneScaffoldValue<Role>> {
- method public default androidx.compose.animation.EnterTransition getEnterTransition(androidx.compose.material3.adaptive.layout.PaneMotion);
- method public default androidx.compose.animation.ExitTransition getExitTransition(androidx.compose.material3.adaptive.layout.PaneMotion);
method public androidx.compose.material3.adaptive.layout.PaneScaffoldMotionDataProvider<Role> getMotionDataProvider();
method @FloatRange(from=0.0, to=1.0) public float getMotionProgress();
method public androidx.compose.animation.core.Transition<ScaffoldValue> getScaffoldStateTransition();
- property public androidx.compose.animation.EnterTransition enterTransition;
- property public androidx.compose.animation.ExitTransition exitTransition;
property public abstract androidx.compose.material3.adaptive.layout.PaneScaffoldMotionDataProvider<Role> motionDataProvider;
property @FloatRange(from=0.0, to=1.0) public abstract float motionProgress;
property public abstract androidx.compose.animation.core.Transition<ScaffoldValue> scaffoldStateTransition;
@@ -338,7 +351,7 @@
method @SuppressCompatibility @androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi @androidx.compose.runtime.Composable public static void SupportingPaneScaffold(androidx.compose.material3.adaptive.layout.PaneScaffoldDirective directive, androidx.compose.material3.adaptive.layout.ThreePaneScaffoldValue value, kotlin.jvm.functions.Function1<? super androidx.compose.material3.adaptive.layout.ThreePaneScaffoldPaneScope,kotlin.Unit> mainPane, kotlin.jvm.functions.Function1<? super androidx.compose.material3.adaptive.layout.ThreePaneScaffoldPaneScope,kotlin.Unit> supportingPane, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function1<? super androidx.compose.material3.adaptive.layout.ThreePaneScaffoldPaneScope,kotlin.Unit>? extraPane, optional kotlin.jvm.functions.Function2<? super androidx.compose.material3.adaptive.layout.ThreePaneScaffoldScope,? super androidx.compose.material3.adaptive.layout.PaneExpansionState,kotlin.Unit>? paneExpansionDragHandle, optional androidx.compose.material3.adaptive.layout.PaneExpansionState? paneExpansionState);
}
- @SuppressCompatibility @androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi public final class SupportingPaneScaffoldRole {
+ public final class SupportingPaneScaffoldRole {
method public androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole getExtra();
method public androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole getMain();
method public androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole getSupporting();
@@ -363,7 +376,7 @@
method public operator androidx.compose.material3.adaptive.layout.AdaptStrategy get(androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole role);
}
- @SuppressCompatibility @androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi public final class ThreePaneScaffoldDestinationItem<T> {
+ public final class ThreePaneScaffoldDestinationItem<T> {
ctor public ThreePaneScaffoldDestinationItem(androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole pane, optional T? contentKey);
method public T? getContentKey();
method public androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole getPane();
@@ -434,7 +447,7 @@
property public abstract androidx.compose.material3.adaptive.layout.ThreePaneScaffoldValue targetState;
}
- @SuppressCompatibility @androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi @androidx.compose.runtime.Immutable public final class ThreePaneScaffoldValue implements androidx.compose.material3.adaptive.layout.PaneExpansionStateKeyProvider androidx.compose.material3.adaptive.layout.PaneScaffoldValue<androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole> {
+ @androidx.compose.runtime.Immutable public final class ThreePaneScaffoldValue implements androidx.compose.material3.adaptive.layout.PaneExpansionStateKeyProvider androidx.compose.material3.adaptive.layout.PaneScaffoldValue<androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole> {
ctor public ThreePaneScaffoldValue(String primary, String secondary, String tertiary);
method public operator String get(androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole role);
method public androidx.compose.material3.adaptive.layout.PaneExpansionStateKey getPaneExpansionStateKey();
diff --git a/compose/material3/adaptive/adaptive-layout/api/restricted_current.ignore b/compose/material3/adaptive/adaptive-layout/api/restricted_current.ignore
new file mode 100644
index 0000000..6562a08
--- /dev/null
+++ b/compose/material3/adaptive/adaptive-layout/api/restricted_current.ignore
@@ -0,0 +1,3 @@
+// Baseline format: 1.0
+ParameterNameChange: androidx.compose.material3.adaptive.layout.ThreePaneScaffoldDestinationItem#ThreePaneScaffoldDestinationItem(androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole, T) parameter #1:
+ Attempted to change parameter name from content to contentKey in constructor androidx.compose.material3.adaptive.layout.ThreePaneScaffoldDestinationItem
diff --git a/compose/material3/adaptive/adaptive-layout/api/restricted_current.txt b/compose/material3/adaptive/adaptive-layout/api/restricted_current.txt
index cbaeb22..b4cd5c9 100644
--- a/compose/material3/adaptive/adaptive-layout/api/restricted_current.txt
+++ b/compose/material3/adaptive/adaptive-layout/api/restricted_current.txt
@@ -94,7 +94,7 @@
property public androidx.compose.material3.adaptive.layout.ThreePaneScaffoldValue targetState;
}
- @SuppressCompatibility @androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi @kotlin.jvm.JvmInline public final value class PaneAdaptedValue {
+ @kotlin.jvm.JvmInline public final value class PaneAdaptedValue {
field public static final androidx.compose.material3.adaptive.layout.PaneAdaptedValue.Companion Companion;
}
@@ -110,12 +110,28 @@
property @androidx.compose.runtime.Composable public abstract String description;
}
- public static final class PaneExpansionAnchor.Offset extends androidx.compose.material3.adaptive.layout.PaneExpansionAnchor {
- ctor public PaneExpansionAnchor.Offset(float offset);
- method @androidx.compose.runtime.Composable public String getDescription();
- method public float getOffset();
- property @androidx.compose.runtime.Composable public String description;
+ public abstract static class PaneExpansionAnchor.Offset extends androidx.compose.material3.adaptive.layout.PaneExpansionAnchor {
+ method public final int getDirection();
+ method public final float getOffset();
+ property public final int direction;
property public final float offset;
+ field public static final androidx.compose.material3.adaptive.layout.PaneExpansionAnchor.Offset.Companion Companion;
+ }
+
+ public static final class PaneExpansionAnchor.Offset.Companion {
+ method public androidx.compose.material3.adaptive.layout.PaneExpansionAnchor.Offset fromEnd(float offset);
+ method public androidx.compose.material3.adaptive.layout.PaneExpansionAnchor.Offset fromStart(float offset);
+ }
+
+ @kotlin.jvm.JvmInline public static final value class PaneExpansionAnchor.Offset.Direction {
+ field public static final androidx.compose.material3.adaptive.layout.PaneExpansionAnchor.Offset.Direction.Companion Companion;
+ }
+
+ public static final class PaneExpansionAnchor.Offset.Direction.Companion {
+ method public int getFromEnd();
+ method public int getFromStart();
+ property public final int FromEnd;
+ property public final int FromStart;
}
public static final class PaneExpansionAnchor.Proportion extends androidx.compose.material3.adaptive.layout.PaneExpansionAnchor {
@@ -131,10 +147,13 @@
}
@SuppressCompatibility @androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi @androidx.compose.runtime.Stable public final class PaneExpansionState {
+ method public suspend Object? animateTo(androidx.compose.material3.adaptive.layout.PaneExpansionAnchor anchor, optional float initialVelocity, kotlin.coroutines.Continuation<? super kotlin.Unit>);
method public void clear();
+ method public androidx.compose.material3.adaptive.layout.PaneExpansionAnchor? getCurrentAnchor();
method public boolean isUnspecified();
method public void setFirstPaneProportion(@FloatRange(from=0.0, to=1.0) float firstPaneProportion);
method public void setFirstPaneWidth(int firstPaneWidth);
+ property public final androidx.compose.material3.adaptive.layout.PaneExpansionAnchor? currentAnchor;
field public static final androidx.compose.material3.adaptive.layout.PaneExpansionState.Companion Companion;
field public static final int Unspecified = -1; // 0xffffffff
}
@@ -168,12 +187,8 @@
property @SuppressCompatibility @androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveComponentOverrideApi public static final androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.material3.adaptive.layout.AnimatedPaneOverride> LocalAnimatedPaneOverride;
}
- @SuppressCompatibility @androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi public interface PaneMotion {
- method public androidx.compose.animation.EnterTransition getEnterTransition(androidx.compose.material3.adaptive.layout.PaneScaffoldMotionDataProvider<? extends java.lang.Object?>);
- method public androidx.compose.animation.ExitTransition getExitTransition(androidx.compose.material3.adaptive.layout.PaneScaffoldMotionDataProvider<? extends java.lang.Object?>);
+ @SuppressCompatibility @androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi @androidx.compose.runtime.Stable public sealed interface PaneMotion {
method public int getType();
- property public androidx.compose.animation.EnterTransition enterTransition;
- property public androidx.compose.animation.ExitTransition exitTransition;
property public abstract int type;
field public static final androidx.compose.material3.adaptive.layout.PaneMotion.Companion Companion;
}
@@ -248,6 +263,8 @@
}
public final class PaneMotionKt {
+ method @SuppressCompatibility @androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi public static <Role> androidx.compose.animation.EnterTransition calculateDefaultEnterTransition(androidx.compose.material3.adaptive.layout.PaneScaffoldMotionDataProvider<Role>, Role role);
+ method @SuppressCompatibility @androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi public static <Role> androidx.compose.animation.ExitTransition calculateDefaultExitTransition(androidx.compose.material3.adaptive.layout.PaneScaffoldMotionDataProvider<Role>, Role role);
method @SuppressCompatibility @androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi public static inline <Role> void forEach(androidx.compose.material3.adaptive.layout.PaneScaffoldMotionDataProvider<Role>, kotlin.jvm.functions.Function2<? super Role,? super androidx.compose.material3.adaptive.layout.PaneMotionData,kotlin.Unit> action);
method @SuppressCompatibility @androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi public static inline <Role> void forEachReversed(androidx.compose.material3.adaptive.layout.PaneScaffoldMotionDataProvider<Role>, kotlin.jvm.functions.Function2<? super Role,? super androidx.compose.material3.adaptive.layout.PaneMotionData,kotlin.Unit> action);
}
@@ -276,8 +293,8 @@
}
public final class PaneScaffoldDirectiveKt {
- method @SuppressCompatibility @androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi public static androidx.compose.material3.adaptive.layout.PaneScaffoldDirective calculatePaneScaffoldDirective(androidx.compose.material3.adaptive.WindowAdaptiveInfo windowAdaptiveInfo, optional int verticalHingePolicy);
- method @SuppressCompatibility @androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi public static androidx.compose.material3.adaptive.layout.PaneScaffoldDirective calculatePaneScaffoldDirectiveWithTwoPanesOnMediumWidth(androidx.compose.material3.adaptive.WindowAdaptiveInfo windowAdaptiveInfo, optional int verticalHingePolicy);
+ method public static androidx.compose.material3.adaptive.layout.PaneScaffoldDirective calculatePaneScaffoldDirective(androidx.compose.material3.adaptive.WindowAdaptiveInfo windowAdaptiveInfo, optional int verticalHingePolicy);
+ method public static androidx.compose.material3.adaptive.layout.PaneScaffoldDirective calculatePaneScaffoldDirectiveWithTwoPanesOnMediumWidth(androidx.compose.material3.adaptive.WindowAdaptiveInfo windowAdaptiveInfo, optional int verticalHingePolicy);
}
@SuppressCompatibility @androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi public sealed interface PaneScaffoldMotionDataProvider<Role> {
@@ -312,13 +329,9 @@
}
@SuppressCompatibility @androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi public sealed interface PaneScaffoldTransitionScope<Role, ScaffoldValue extends androidx.compose.material3.adaptive.layout.PaneScaffoldValue<Role>> {
- method public default androidx.compose.animation.EnterTransition getEnterTransition(androidx.compose.material3.adaptive.layout.PaneMotion);
- method public default androidx.compose.animation.ExitTransition getExitTransition(androidx.compose.material3.adaptive.layout.PaneMotion);
method public androidx.compose.material3.adaptive.layout.PaneScaffoldMotionDataProvider<Role> getMotionDataProvider();
method @FloatRange(from=0.0, to=1.0) public float getMotionProgress();
method public androidx.compose.animation.core.Transition<ScaffoldValue> getScaffoldStateTransition();
- property public androidx.compose.animation.EnterTransition enterTransition;
- property public androidx.compose.animation.ExitTransition exitTransition;
property public abstract androidx.compose.material3.adaptive.layout.PaneScaffoldMotionDataProvider<Role> motionDataProvider;
property @FloatRange(from=0.0, to=1.0) public abstract float motionProgress;
property public abstract androidx.compose.animation.core.Transition<ScaffoldValue> scaffoldStateTransition;
@@ -338,7 +351,7 @@
method @SuppressCompatibility @androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi @androidx.compose.runtime.Composable public static void SupportingPaneScaffold(androidx.compose.material3.adaptive.layout.PaneScaffoldDirective directive, androidx.compose.material3.adaptive.layout.ThreePaneScaffoldValue value, kotlin.jvm.functions.Function1<? super androidx.compose.material3.adaptive.layout.ThreePaneScaffoldPaneScope,kotlin.Unit> mainPane, kotlin.jvm.functions.Function1<? super androidx.compose.material3.adaptive.layout.ThreePaneScaffoldPaneScope,kotlin.Unit> supportingPane, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function1<? super androidx.compose.material3.adaptive.layout.ThreePaneScaffoldPaneScope,kotlin.Unit>? extraPane, optional kotlin.jvm.functions.Function2<? super androidx.compose.material3.adaptive.layout.ThreePaneScaffoldScope,? super androidx.compose.material3.adaptive.layout.PaneExpansionState,kotlin.Unit>? paneExpansionDragHandle, optional androidx.compose.material3.adaptive.layout.PaneExpansionState? paneExpansionState);
}
- @SuppressCompatibility @androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi public final class SupportingPaneScaffoldRole {
+ public final class SupportingPaneScaffoldRole {
method public androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole getExtra();
method public androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole getMain();
method public androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole getSupporting();
@@ -363,7 +376,7 @@
method public operator androidx.compose.material3.adaptive.layout.AdaptStrategy get(androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole role);
}
- @SuppressCompatibility @androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi public final class ThreePaneScaffoldDestinationItem<T> {
+ public final class ThreePaneScaffoldDestinationItem<T> {
ctor public ThreePaneScaffoldDestinationItem(androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole pane, optional T? contentKey);
method public T? getContentKey();
method public androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole getPane();
@@ -434,7 +447,7 @@
property public abstract androidx.compose.material3.adaptive.layout.ThreePaneScaffoldValue targetState;
}
- @SuppressCompatibility @androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi @androidx.compose.runtime.Immutable public final class ThreePaneScaffoldValue implements androidx.compose.material3.adaptive.layout.PaneExpansionStateKeyProvider androidx.compose.material3.adaptive.layout.PaneScaffoldValue<androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole> {
+ @androidx.compose.runtime.Immutable public final class ThreePaneScaffoldValue implements androidx.compose.material3.adaptive.layout.PaneExpansionStateKeyProvider androidx.compose.material3.adaptive.layout.PaneScaffoldValue<androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole> {
ctor public ThreePaneScaffoldValue(String primary, String secondary, String tertiary);
method public operator String get(androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole role);
method public androidx.compose.material3.adaptive.layout.PaneExpansionStateKey getPaneExpansionStateKey();
diff --git a/compose/material3/adaptive/adaptive-layout/src/androidInstrumentedTest/kotlin/androidx/compose/material3/adaptive/layout/PaneExpansionStateTest.kt b/compose/material3/adaptive/adaptive-layout/src/androidInstrumentedTest/kotlin/androidx/compose/material3/adaptive/layout/PaneExpansionStateTest.kt
index 681cae4..344c0f6 100644
--- a/compose/material3/adaptive/adaptive-layout/src/androidInstrumentedTest/kotlin/androidx/compose/material3/adaptive/layout/PaneExpansionStateTest.kt
+++ b/compose/material3/adaptive/adaptive-layout/src/androidInstrumentedTest/kotlin/androidx/compose/material3/adaptive/layout/PaneExpansionStateTest.kt
@@ -233,7 +233,7 @@
ThreePaneScaffoldRole.Secondary,
ThreePaneScaffoldRole.Tertiary
),
- PaneExpansionStateData(7, 0.8F, 9, PaneExpansionAnchor.Offset(200.dp))
+ PaneExpansionStateData(7, 0.8F, 9, PaneExpansionAnchor.Offset.fromStart(200.dp))
),
Pair(
TwoPaneExpansionStateKeyImpl(
@@ -271,12 +271,12 @@
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
private val MockAnchor0 = PaneExpansionAnchor.Proportion(0f)
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
-private val MockAnchor1 = PaneExpansionAnchor.Offset(200.dp)
+private val MockAnchor1 = PaneExpansionAnchor.Offset.fromStart(200.dp)
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
private val MockAnchor2 = PaneExpansionAnchor.Proportion(0.5f)
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
-private val MockAnchor3 = PaneExpansionAnchor.Offset((-200).dp)
+private val MockAnchor3 = PaneExpansionAnchor.Offset.fromEnd(200.dp)
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
private val MockAnchor4 = PaneExpansionAnchor.Proportion(1f)
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
-private val MockAnchor5 = PaneExpansionAnchor.Offset(500.dp)
+private val MockAnchor5 = PaneExpansionAnchor.Offset.fromStart(500.dp)
diff --git a/compose/material3/adaptive/adaptive-layout/src/androidInstrumentedTest/kotlin/androidx/compose/material3/adaptive/layout/ThreePaneScaffoldTest.kt b/compose/material3/adaptive/adaptive-layout/src/androidInstrumentedTest/kotlin/androidx/compose/material3/adaptive/layout/ThreePaneScaffoldTest.kt
index 591c322..3677863 100644
--- a/compose/material3/adaptive/adaptive-layout/src/androidInstrumentedTest/kotlin/androidx/compose/material3/adaptive/layout/ThreePaneScaffoldTest.kt
+++ b/compose/material3/adaptive/adaptive-layout/src/androidInstrumentedTest/kotlin/androidx/compose/material3/adaptive/layout/ThreePaneScaffoldTest.kt
@@ -34,6 +34,7 @@
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import com.google.common.truth.Truth.assertThat
+import kotlin.test.assertFailsWith
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import org.junit.Rule
@@ -178,7 +179,7 @@
mockPaneExpansionState = rememberPaneExpansionState(anchors = MockPaneExpansionAnchors)
mockDraggingPx = with(LocalDensity.current) { 200.dp.toPx() }
expectedSettledOffsetPx =
- with(LocalDensity.current) { MockPaneExpansionMiddleAnchor.toPx().toInt() }
+ with(LocalDensity.current) { MockPaneExpansionMiddleAnchor.roundToPx() }
SampleThreePaneScaffoldWithPaneExpansion(mockPaneExpansionState) { MockDragHandle(it) }
}
@@ -302,12 +303,12 @@
anchors =
listOf(
PaneExpansionAnchor.Proportion(0f),
- PaneExpansionAnchor.Offset(MockPaneExpansionMiddleAnchor)
+ PaneExpansionAnchor.Offset.fromStart(MockPaneExpansionMiddleAnchor)
)
)
mockDraggingPx = with(LocalDensity.current) { 200.dp.toPx() }
expectedSettledOffsetPx =
- with(LocalDensity.current) { MockPaneExpansionMiddleAnchor.toPx().toInt() }
+ with(LocalDensity.current) { MockPaneExpansionMiddleAnchor.roundToPx() }
SampleThreePaneScaffoldWithPaneExpansion(mockPaneExpansionState) { MockDragHandle(it) }
}
@@ -321,6 +322,83 @@
.isEqualTo(expectedSettledOffsetPx)
}
}
+
+ @Test
+ fun threePaneScaffold_paneExpansionWithDragHandle_animateToAnchor() {
+ var expectedSettledOffsetPx = 0
+ lateinit var mockPaneExpansionState: PaneExpansionState
+ lateinit var scope: CoroutineScope
+
+ rule.setContentWithSimulatedSize(simulatedWidth = 1024.dp, simulatedHeight = 800.dp) {
+ scope = rememberCoroutineScope()
+ mockPaneExpansionState = rememberPaneExpansionState(anchors = MockPaneExpansionAnchors)
+ expectedSettledOffsetPx =
+ with(LocalDensity.current) { MockPaneExpansionMiddleAnchor.roundToPx() }
+ SampleThreePaneScaffoldWithPaneExpansion(mockPaneExpansionState) { MockDragHandle(it) }
+ }
+
+ rule.runOnIdle {
+ scope.launch {
+ mockPaneExpansionState.animateTo(
+ PaneExpansionAnchor.Offset.fromStart(MockPaneExpansionMiddleAnchor)
+ )
+ }
+ }
+
+ rule.runOnIdle {
+ assertThat(mockPaneExpansionState.currentMeasuredDraggingOffset)
+ .isEqualTo(expectedSettledOffsetPx)
+ }
+ }
+
+ @Test
+ fun threePaneScaffold_paneExpansionWithDragHandle_animateToAnchorWithVelocity() {
+ var expectedSettledOffsetPx = 0
+ lateinit var mockPaneExpansionState: PaneExpansionState
+ lateinit var scope: CoroutineScope
+
+ rule.setContentWithSimulatedSize(simulatedWidth = 1024.dp, simulatedHeight = 800.dp) {
+ scope = rememberCoroutineScope()
+ mockPaneExpansionState = rememberPaneExpansionState(anchors = MockPaneExpansionAnchors)
+ expectedSettledOffsetPx =
+ with(LocalDensity.current) { MockPaneExpansionMiddleAnchor.roundToPx() }
+ SampleThreePaneScaffoldWithPaneExpansion(mockPaneExpansionState) { MockDragHandle(it) }
+ }
+
+ rule.runOnIdle {
+ scope.launch {
+ mockPaneExpansionState.animateTo(
+ PaneExpansionAnchor.Offset.fromStart(MockPaneExpansionMiddleAnchor),
+ 200F
+ )
+ }
+ }
+
+ rule.runOnIdle {
+ assertThat(mockPaneExpansionState.currentMeasuredDraggingOffset)
+ .isEqualTo(expectedSettledOffsetPx)
+ }
+ }
+
+ @Test
+ fun threePaneScaffold_paneExpansionWithDragHandle_animateToNonExistAnchorThrows() {
+ lateinit var mockPaneExpansionState: PaneExpansionState
+ lateinit var scope: CoroutineScope
+
+ rule.setContentWithSimulatedSize(simulatedWidth = 1024.dp, simulatedHeight = 800.dp) {
+ scope = rememberCoroutineScope()
+ mockPaneExpansionState = rememberPaneExpansionState(anchors = MockPaneExpansionAnchors)
+ SampleThreePaneScaffoldWithPaneExpansion(mockPaneExpansionState) { MockDragHandle(it) }
+ }
+
+ rule.runOnIdle {
+ scope.launch {
+ assertFailsWith<IllegalArgumentException> {
+ mockPaneExpansionState.animateTo(PaneExpansionAnchor.Offset.fromStart(10.dp))
+ }
+ }
+ }
+ }
}
private val MockScaffoldDirective = PaneScaffoldDirective.Default
@@ -333,7 +411,7 @@
private val MockPaneExpansionAnchors =
listOf(
PaneExpansionAnchor.Proportion(0f),
- PaneExpansionAnchor.Offset(MockPaneExpansionMiddleAnchor),
+ PaneExpansionAnchor.Offset.fromStart(MockPaneExpansionMiddleAnchor),
PaneExpansionAnchor.Proportion(1f),
)
diff --git a/compose/material3/adaptive/adaptive-layout/src/androidMain/kotlin/androidx/compose/material3/adaptive/layout/Strings.android.kt b/compose/material3/adaptive/adaptive-layout/src/androidMain/kotlin/androidx/compose/material3/adaptive/layout/Strings.android.kt
index a563fe9..5bab1c8 100644
--- a/compose/material3/adaptive/adaptive-layout/src/androidMain/kotlin/androidx/compose/material3/adaptive/layout/Strings.android.kt
+++ b/compose/material3/adaptive/adaptive-layout/src/androidMain/kotlin/androidx/compose/material3/adaptive/layout/Strings.android.kt
@@ -59,7 +59,12 @@
get() =
Strings(R.string.m3_adaptive_default_pane_expansion_proportion_anchor_description)
- actual inline val defaultPaneExpansionOffsetAnchorDescription
- get() = Strings(R.string.m3_adaptive_default_pane_expansion_offset_anchor_description)
+ actual inline val defaultPaneExpansionStartOffsetAnchorDescription
+ get() =
+ Strings(R.string.m3_adaptive_default_pane_expansion_start_offset_anchor_description)
+
+ actual inline val defaultPaneExpansionEndOffsetAnchorDescription
+ get() =
+ Strings(R.string.m3_adaptive_default_pane_expansion_end_offset_anchor_description)
}
}
diff --git a/compose/material3/adaptive/adaptive-layout/src/androidMain/res/values-as/strings.xml b/compose/material3/adaptive/adaptive-layout/src/androidMain/res/values-as/strings.xml
index d550f15..120b08a 100644
--- a/compose/material3/adaptive/adaptive-layout/src/androidMain/res/values-as/strings.xml
+++ b/compose/material3/adaptive/adaptive-layout/src/androidMain/res/values-as/strings.xml
@@ -20,5 +20,4 @@
<string name="m3_adaptive_default_pane_expansion_drag_handle_content_description" msgid="9058489142432490820">"পে’ন সম্প্ৰসাৰণ কৰিবলৈ টনা হেণ্ডেল"</string>
<string name="m3_adaptive_default_pane_expansion_drag_handle_action_description" msgid="9031621431415014327">"পে’নৰ বিভাজন %sলৈ সলনি কৰক"</string>
<string name="m3_adaptive_default_pane_expansion_proportion_anchor_description" msgid="1205294531112795522">"%d শতাংশ"</string>
- <string name="m3_adaptive_default_pane_expansion_offset_anchor_description" msgid="8189074525698747223">"%d DPs"</string>
</resources>
diff --git a/compose/material3/adaptive/adaptive-layout/src/androidMain/res/values-en-rCA/strings.xml b/compose/material3/adaptive/adaptive-layout/src/androidMain/res/values-en-rCA/strings.xml
index 33e0970..c1e08b3 100644
--- a/compose/material3/adaptive/adaptive-layout/src/androidMain/res/values-en-rCA/strings.xml
+++ b/compose/material3/adaptive/adaptive-layout/src/androidMain/res/values-en-rCA/strings.xml
@@ -20,5 +20,4 @@
<string name="m3_adaptive_default_pane_expansion_drag_handle_content_description" msgid="9058489142432490820">"Pane expansion drag handle"</string>
<string name="m3_adaptive_default_pane_expansion_drag_handle_action_description" msgid="9031621431415014327">"Change pane split to %s"</string>
<string name="m3_adaptive_default_pane_expansion_proportion_anchor_description" msgid="1205294531112795522">"%d percent"</string>
- <string name="m3_adaptive_default_pane_expansion_offset_anchor_description" msgid="8189074525698747223">"%d DPs"</string>
</resources>
diff --git a/compose/material3/adaptive/adaptive-layout/src/androidMain/res/values-hi/strings.xml b/compose/material3/adaptive/adaptive-layout/src/androidMain/res/values-hi/strings.xml
index d25df6b..f333cf2 100644
--- a/compose/material3/adaptive/adaptive-layout/src/androidMain/res/values-hi/strings.xml
+++ b/compose/material3/adaptive/adaptive-layout/src/androidMain/res/values-hi/strings.xml
@@ -20,5 +20,4 @@
<string name="m3_adaptive_default_pane_expansion_drag_handle_content_description" msgid="9058489142432490820">"पैनल को बड़ा करने के लिए, खींचकर छोड़ने वाला हैंडल"</string>
<string name="m3_adaptive_default_pane_expansion_drag_handle_action_description" msgid="9031621431415014327">"पैनल स्प्लिट को %s में बदलें"</string>
<string name="m3_adaptive_default_pane_expansion_proportion_anchor_description" msgid="1205294531112795522">"%d प्रतिशत"</string>
- <string name="m3_adaptive_default_pane_expansion_offset_anchor_description" msgid="8189074525698747223">"%d डीपी"</string>
</resources>
diff --git a/compose/material3/adaptive/adaptive-layout/src/androidMain/res/values-ja/strings.xml b/compose/material3/adaptive/adaptive-layout/src/androidMain/res/values-ja/strings.xml
index c287ba9..bfbceb0 100644
--- a/compose/material3/adaptive/adaptive-layout/src/androidMain/res/values-ja/strings.xml
+++ b/compose/material3/adaptive/adaptive-layout/src/androidMain/res/values-ja/strings.xml
@@ -20,5 +20,4 @@
<string name="m3_adaptive_default_pane_expansion_drag_handle_content_description" msgid="9058489142432490820">"ペインの展開のドラッグ ハンドル"</string>
<string name="m3_adaptive_default_pane_expansion_drag_handle_action_description" msgid="9031621431415014327">"ペインの分割を %s に変更"</string>
<string name="m3_adaptive_default_pane_expansion_proportion_anchor_description" msgid="1205294531112795522">"%d パーセント"</string>
- <string name="m3_adaptive_default_pane_expansion_offset_anchor_description" msgid="8189074525698747223">"%d DP"</string>
</resources>
diff --git a/compose/material3/adaptive/adaptive-layout/src/androidMain/res/values-ka/strings.xml b/compose/material3/adaptive/adaptive-layout/src/androidMain/res/values-ka/strings.xml
index d5cbb1b..f9f4e65 100644
--- a/compose/material3/adaptive/adaptive-layout/src/androidMain/res/values-ka/strings.xml
+++ b/compose/material3/adaptive/adaptive-layout/src/androidMain/res/values-ka/strings.xml
@@ -20,5 +20,4 @@
<string name="m3_adaptive_default_pane_expansion_drag_handle_content_description" msgid="9058489142432490820">"არეს გაფართოების სახელური ჩავლებისთვის"</string>
<string name="m3_adaptive_default_pane_expansion_drag_handle_action_description" msgid="9031621431415014327">"არეს გაყოფის შეცვლა %s-ით"</string>
<string name="m3_adaptive_default_pane_expansion_proportion_anchor_description" msgid="1205294531112795522">"%d პროცენტი"</string>
- <string name="m3_adaptive_default_pane_expansion_offset_anchor_description" msgid="8189074525698747223">"%d DPs"</string>
</resources>
diff --git a/compose/material3/adaptive/adaptive-layout/src/androidMain/res/values-ml/strings.xml b/compose/material3/adaptive/adaptive-layout/src/androidMain/res/values-ml/strings.xml
index 0747fcb..2b63179c 100644
--- a/compose/material3/adaptive/adaptive-layout/src/androidMain/res/values-ml/strings.xml
+++ b/compose/material3/adaptive/adaptive-layout/src/androidMain/res/values-ml/strings.xml
@@ -20,5 +20,4 @@
<string name="m3_adaptive_default_pane_expansion_drag_handle_content_description" msgid="9058489142432490820">"പെയിൻ വികസിപ്പിക്കാനായി വലിച്ചിടുന്നതിനുള്ള ഹാൻഡിൽ"</string>
<string name="m3_adaptive_default_pane_expansion_drag_handle_action_description" msgid="9031621431415014327">"പെയിൻ വിഭജനം %s ആയി മാറ്റുക"</string>
<string name="m3_adaptive_default_pane_expansion_proportion_anchor_description" msgid="1205294531112795522">"%d ശതമാനം"</string>
- <string name="m3_adaptive_default_pane_expansion_offset_anchor_description" msgid="8189074525698747223">"%d DP-കൾ"</string>
</resources>
diff --git a/compose/material3/adaptive/adaptive-layout/src/androidMain/res/values-ms/strings.xml b/compose/material3/adaptive/adaptive-layout/src/androidMain/res/values-ms/strings.xml
index 81f4fb8..889db66e 100644
--- a/compose/material3/adaptive/adaptive-layout/src/androidMain/res/values-ms/strings.xml
+++ b/compose/material3/adaptive/adaptive-layout/src/androidMain/res/values-ms/strings.xml
@@ -20,5 +20,4 @@
<string name="m3_adaptive_default_pane_expansion_drag_handle_content_description" msgid="9058489142432490820">"Pemegang seret pengembangan anak tetingkap"</string>
<string name="m3_adaptive_default_pane_expansion_drag_handle_action_description" msgid="9031621431415014327">"Tukar anak tetingkap terpisah kepada %s"</string>
<string name="m3_adaptive_default_pane_expansion_proportion_anchor_description" msgid="1205294531112795522">"%d peratus"</string>
- <string name="m3_adaptive_default_pane_expansion_offset_anchor_description" msgid="8189074525698747223">"%d DP"</string>
</resources>
diff --git a/compose/material3/adaptive/adaptive-layout/src/androidMain/res/values-pl/strings.xml b/compose/material3/adaptive/adaptive-layout/src/androidMain/res/values-pl/strings.xml
index 19a0cd0..ec85843 100644
--- a/compose/material3/adaptive/adaptive-layout/src/androidMain/res/values-pl/strings.xml
+++ b/compose/material3/adaptive/adaptive-layout/src/androidMain/res/values-pl/strings.xml
@@ -20,5 +20,4 @@
<string name="m3_adaptive_default_pane_expansion_drag_handle_content_description" msgid="9058489142432490820">"Uchwyt do przeciągania panelu"</string>
<string name="m3_adaptive_default_pane_expansion_drag_handle_action_description" msgid="9031621431415014327">"Zmień podział panelu na %s"</string>
<string name="m3_adaptive_default_pane_expansion_proportion_anchor_description" msgid="1205294531112795522">"%d procent"</string>
- <string name="m3_adaptive_default_pane_expansion_offset_anchor_description" msgid="8189074525698747223">"%d DP"</string>
</resources>
diff --git a/compose/material3/adaptive/adaptive-layout/src/androidMain/res/values-pt-rPT/strings.xml b/compose/material3/adaptive/adaptive-layout/src/androidMain/res/values-pt-rPT/strings.xml
index 2dae797..115f3c0 100644
--- a/compose/material3/adaptive/adaptive-layout/src/androidMain/res/values-pt-rPT/strings.xml
+++ b/compose/material3/adaptive/adaptive-layout/src/androidMain/res/values-pt-rPT/strings.xml
@@ -20,5 +20,4 @@
<string name="m3_adaptive_default_pane_expansion_drag_handle_content_description" msgid="9058489142432490820">"Indicador para arrastar de expansão do painel"</string>
<string name="m3_adaptive_default_pane_expansion_drag_handle_action_description" msgid="9031621431415014327">"Altere a divisão do painel para %s"</string>
<string name="m3_adaptive_default_pane_expansion_proportion_anchor_description" msgid="1205294531112795522">"%d por cento"</string>
- <string name="m3_adaptive_default_pane_expansion_offset_anchor_description" msgid="8189074525698747223">"%d DPs"</string>
</resources>
diff --git a/compose/material3/adaptive/adaptive-layout/src/androidMain/res/values/strings.xml b/compose/material3/adaptive/adaptive-layout/src/androidMain/res/values/strings.xml
index 38b807f..94e3ffa 100644
--- a/compose/material3/adaptive/adaptive-layout/src/androidMain/res/values/strings.xml
+++ b/compose/material3/adaptive/adaptive-layout/src/androidMain/res/values/strings.xml
@@ -28,7 +28,14 @@
<string name="m3_adaptive_default_pane_expansion_proportion_anchor_description">
%d percent
</string>
- <!-- Spoken description of a pane expansion anchor point based on offset in DPs a user can
- anchor the pane expansion to. -->
- <string name="m3_adaptive_default_pane_expansion_offset_anchor_description">%d DPs</string>
+ <!-- Spoken description of a pane expansion anchor point based on offset from start in DPs a
+ user can anchor the pane expansion to. -->
+ <string name="m3_adaptive_default_pane_expansion_start_offset_anchor_description">
+ %d DPs from start
+ </string>
+ <!-- Spoken description of a pane expansion anchor point based on offset from end in DPs a user
+ can anchor the pane expansion to. -->
+ <string name="m3_adaptive_default_pane_expansion_end_offset_anchor_description">
+ %d DPs from end
+ </string>
</resources>
diff --git a/compose/material3/adaptive/adaptive-layout/src/androidUnitTest/kotlin/androidx/compose/material3/adaptive/layout/PaneMotionTest.kt b/compose/material3/adaptive/adaptive-layout/src/androidUnitTest/kotlin/androidx/compose/material3/adaptive/layout/PaneMotionTest.kt
index a32b7ec..0355530 100644
--- a/compose/material3/adaptive/adaptive-layout/src/androidUnitTest/kotlin/androidx/compose/material3/adaptive/layout/PaneMotionTest.kt
+++ b/compose/material3/adaptive/adaptive-layout/src/androidUnitTest/kotlin/androidx/compose/material3/adaptive/layout/PaneMotionTest.kt
@@ -83,12 +83,21 @@
expectedEnterTransition: EnterTransition,
expectedExitTransition: ExitTransition
) {
+ mockPaneScaffoldMotionDataProvider.updateMotions(this, NoMotion, NoMotion)
// Can't compare equality directly because of lambda. Check string representation instead
assertWithMessage("Enter transition of $this: ")
- .that(mockPaneScaffoldMotionDataProvider.enterTransition.toString())
+ .that(
+ mockPaneScaffoldMotionDataProvider
+ .calculateDefaultEnterTransition(ThreePaneScaffoldRole.Primary)
+ .toString()
+ )
.isEqualTo(expectedEnterTransition.toString())
assertWithMessage("Exit transition of $this: ")
- .that(mockPaneScaffoldMotionDataProvider.exitTransition.toString())
+ .that(
+ mockPaneScaffoldMotionDataProvider
+ .calculateDefaultExitTransition(ThreePaneScaffoldRole.Primary)
+ .toString()
+ )
.isEqualTo(expectedExitTransition.toString())
}
@@ -274,6 +283,32 @@
mockPaneScaffoldMotionDataProvider[1].currentLeft
)
}
+
+ @Test
+ fun hiddenPaneCurrentLeft_useRightEdgeOfLeftShownPane() {
+ mockPaneScaffoldMotionDataProvider.updateMotions(
+ ExitToLeft,
+ EnterFromRight,
+ EnterWithExpand
+ )
+ assertThat(
+ mockPaneScaffoldMotionDataProvider.getHiddenPaneCurrentLeft(
+ ThreePaneScaffoldRole.Tertiary
+ )
+ )
+ .isEqualTo(mockPaneScaffoldMotionDataProvider[0].currentRight)
+ }
+
+ @Test
+ fun hidingPaneTargetLeft_useRightEdgeOfLeftShowingPane() {
+ mockPaneScaffoldMotionDataProvider.updateMotions(EnterFromLeft, ExitToRight, ExitWithShrink)
+ assertThat(
+ mockPaneScaffoldMotionDataProvider.getHidingPaneTargetLeft(
+ ThreePaneScaffoldRole.Tertiary
+ )
+ )
+ .isEqualTo(mockPaneScaffoldMotionDataProvider[0].targetRight)
+ }
}
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@@ -471,8 +506,10 @@
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
private val mockEnterWithExpandTransition =
- expandHorizontally(PaneMotionDefaults.SizeAnimationSpec, Alignment.CenterHorizontally)
+ expandHorizontally(PaneMotionDefaults.SizeAnimationSpec, Alignment.CenterHorizontally) +
+ slideInHorizontally(PaneMotionDefaults.OffsetAnimationSpec)
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
private val mockExitWithShrinkTransition =
- shrinkHorizontally(PaneMotionDefaults.SizeAnimationSpec, Alignment.CenterHorizontally)
+ shrinkHorizontally(PaneMotionDefaults.SizeAnimationSpec, Alignment.CenterHorizontally) +
+ slideOutHorizontally(PaneMotionDefaults.OffsetAnimationSpec)
diff --git a/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/Pane.kt b/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/Pane.kt
index 862c541..49dd66a 100644
--- a/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/Pane.kt
+++ b/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/Pane.kt
@@ -93,8 +93,8 @@
@Composable
fun <S, T : PaneScaffoldValue<S>> ExtendedPaneScaffoldPaneScope<S, T>.AnimatedPane(
modifier: Modifier = Modifier,
- enterTransition: EnterTransition = paneMotion.enterTransition,
- exitTransition: ExitTransition = paneMotion.exitTransition,
+ enterTransition: EnterTransition = motionDataProvider.calculateDefaultEnterTransition(paneRole),
+ exitTransition: ExitTransition = motionDataProvider.calculateDefaultExitTransition(paneRole),
boundsAnimationSpec: FiniteAnimationSpec<IntRect> = PaneMotionDefaults.AnimationSpec,
content: (@Composable AnimatedPaneScope.() -> Unit),
) {
diff --git a/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/PaneAdaptedValue.kt b/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/PaneAdaptedValue.kt
index 00deed0..080903b 100644
--- a/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/PaneAdaptedValue.kt
+++ b/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/PaneAdaptedValue.kt
@@ -16,13 +16,10 @@
package androidx.compose.material3.adaptive.layout
-import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
-
/**
* The adapted state of a pane. It gives clues to pane scaffolds about if a certain pane should be
* composed and how.
*/
-@ExperimentalMaterial3AdaptiveApi
@JvmInline
value class PaneAdaptedValue private constructor(private val description: String) {
companion object {
diff --git a/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/PaneExpansionState.kt b/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/PaneExpansionState.kt
index 910b42e7..733dc1f 100644
--- a/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/PaneExpansionState.kt
+++ b/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/PaneExpansionState.kt
@@ -222,8 +222,14 @@
currentMeasuredDraggingOffset = coercedValue
}
- @VisibleForTesting
- internal var currentAnchor
+ /**
+ * The current anchor that pane expansion has been settled or is settling to. Note that this
+ * field might be `null` if:
+ * 1. No anchors have been set to the state.
+ * 2. Pane expansion is set directly via [setFirstPaneWidth] or set [setFirstPaneProportion].
+ * 3. Pane expansion is in its initial state without an initial anchor provided.
+ */
+ var currentAnchor
get() = data.currentAnchorState
private set(value) {
data.currentAnchorState = value
@@ -335,6 +341,7 @@
data.firstPaneProportionState = Float.NaN
data.currentDraggingOffsetState = Unspecified
data.firstPaneWidthState = firstPaneWidth
+ currentAnchor = null
}
/**
@@ -351,6 +358,24 @@
data.firstPaneWidthState = Unspecified
data.currentDraggingOffsetState = Unspecified
data.firstPaneProportionState = firstPaneProportion
+ currentAnchor = null
+ }
+
+ /**
+ * Animate the pane expansion to the given [PaneExpansionAnchor]. Note that the given anchor
+ * must be one of the provided anchor when creating the state with [rememberPaneExpansionState];
+ * otherwise the function throws.
+ *
+ * @param anchor the anchor to animate to
+ * @param initialVelocity the initial velocity of the animation
+ */
+ suspend fun animateTo(anchor: PaneExpansionAnchor, initialVelocity: Float = 0F) {
+ require(anchors.contains(anchor)) { "The provided $anchor is not in the anchor list!" }
+ currentAnchor = anchor
+ measuredDensity?.apply {
+ val position = anchor.positionIn(maxExpansionWidth, this)
+ animateToInternal(position, initialVelocity)
+ }
}
/**
@@ -377,11 +402,11 @@
anchors.toPositions(
// When maxExpansionWidth is updated, the anchor positions will be
// recalculated.
- Snapshot.withoutReadObservation { maxExpansionWidth },
+ maxExpansionWidth,
it
)
}
- if (!anchors.contains(Snapshot.withoutReadObservation { currentAnchor })) {
+ if (!anchors.contains(currentAnchor)) {
currentAnchor = null
}
this.anchoringAnimationSpec = anchoringAnimationSpec
@@ -426,30 +451,39 @@
}
dragMutex.mutate(MutatePriority.PreventUserInput) {
- isSettling = true
- val leftVelocity = flingBehavior.run { dragScope.performFling(velocity) }
- val anchorPosition =
- measuredAnchorPositions.getPositionOfTheClosestAnchor(
- currentMeasuredDraggingOffset,
- leftVelocity
- )
try {
+ isSettling = true
+ val leftVelocity = flingBehavior.run { dragScope.performFling(velocity) }
+ val anchorPosition =
+ measuredAnchorPositions.getPositionOfTheClosestAnchor(
+ currentMeasuredDraggingOffset,
+ leftVelocity
+ )
currentAnchor = anchors[anchorPosition.index]
- animate(
- currentMeasuredDraggingOffset.toFloat(),
- anchorPosition.position.toFloat(),
- leftVelocity,
- anchoringAnimationSpec,
- ) { value, _ ->
- currentDraggingOffset = value.toInt()
- }
+ animateToInternal(anchorPosition.position, leftVelocity)
} finally {
- currentDraggingOffset = anchorPosition.position
isSettling = false
}
}
}
+ private suspend fun animateToInternal(offset: Int, initialVelocity: Float) {
+ try {
+ isSettling = true
+ animate(
+ currentMeasuredDraggingOffset.toFloat(),
+ offset.toFloat(),
+ initialVelocity,
+ anchoringAnimationSpec,
+ ) { value, _ ->
+ currentDraggingOffset = value.toInt()
+ }
+ } finally {
+ currentDraggingOffset = offset
+ isSettling = false
+ }
+ }
+
private fun IndexedAnchorPositionList.getPositionOfTheClosestAnchor(
currentPosition: Int,
velocity: Float
@@ -554,7 +588,10 @@
* [PaneExpansionAnchor] implementation that specifies the anchor position in the proportion of
* the total size of the layout at the start side of the anchor.
*
- * @property proportion the proportion of the layout at the start side of the anchor. layout.
+ * @param proportion the proportion of the layout at the start side of the anchor. For example,
+ * if the current layout from the start to the end is list-detail, when the proportion value
+ * is 0.3 and this anchor is used, the list pane will occupy 30% of the layout and the detail
+ * pane will occupy 70% of it.
*/
class Proportion(@FloatRange(0.0, 1.0) val proportion: Float) : PaneExpansionAnchor() {
override val type = ProportionType
@@ -582,40 +619,107 @@
}
/**
- * [PaneExpansionAnchor] implementation that specifies the anchor position in the offset in
- * [Dp]. If a positive value is provided, the offset will be treated as a start offset, on the
- * other hand, if a negative value is provided, the absolute value of the provided offset will
- * be used as an end offset. For example, if -150.dp is provided, the resulted anchor will be at
- * the position that is 150dp away from the end side of the associated layout.
+ * [PaneExpansionAnchor] implementation that specifies the anchor position based on the offset
+ * in [Dp].
*
* @property offset the offset of the anchor in [Dp].
*/
- class Offset(val offset: Dp) : PaneExpansionAnchor() {
- override val type = OffsetType
-
- override val description
- @Composable
- get() =
- getString(Strings.defaultPaneExpansionOffsetAnchorDescription, offset.value.toInt())
-
- override fun positionIn(totalSizePx: Int, density: Density) =
- with(density) { offset.toPx() }.toInt().let { if (it < 0) totalSizePx + it else it }
+ abstract class Offset internal constructor(val offset: Dp, override internal val type: Int) :
+ PaneExpansionAnchor() {
+ /**
+ * Indicates the direction of the offset.
+ *
+ * @see Direction.FromStart
+ * @see Direction.FromEnd
+ */
+ val direction: Direction = Direction(type)
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is Offset) return false
- return offset == other.offset
+ return offset == other.offset && direction == other.direction
}
override fun hashCode(): Int {
- return offset.hashCode()
+ return offset.hashCode() * 31 + direction.hashCode()
+ }
+
+ /** Represents the direction from where the offset will be calculated. */
+ @JvmInline
+ value class Direction internal constructor(internal val value: Int) {
+ companion object {
+ /**
+ * Indicates the offset will be calculated from the start. For example, if the
+ * offset is 150.dp, the resulted anchor will be at the position that is 150dp away
+ * from the start side of the associated layout.
+ */
+ val FromStart = Direction(OffsetFromStartType)
+
+ /**
+ * Indicates the offset will be calculated from the end. For example, if the offset
+ * is 150.dp, the resulted anchor will be at the position that is 150dp away from
+ * the end side of the associated layout.
+ */
+ val FromEnd = Direction(OffsetFromEndType)
+ }
+ }
+
+ private class StartOffset(offset: Dp) : Offset(offset, OffsetFromStartType) {
+ override val description
+ @Composable
+ get() =
+ getString(
+ Strings.defaultPaneExpansionStartOffsetAnchorDescription,
+ offset.value.toInt()
+ )
+
+ override fun positionIn(totalSizePx: Int, density: Density) =
+ with(density) { offset.roundToPx() }
+ }
+
+ private class EndOffset(offset: Dp) : Offset(offset, OffsetFromEndType) {
+ override val description
+ @Composable
+ get() =
+ getString(
+ Strings.defaultPaneExpansionEndOffsetAnchorDescription,
+ offset.value.toInt()
+ )
+
+ override fun positionIn(totalSizePx: Int, density: Density) =
+ totalSizePx - with(density) { offset.roundToPx() }
+ }
+
+ companion object {
+ /**
+ * Create an [androidx.compose.material3.adaptive.layout.PaneExpansionAnchor.Offset]
+ * anchor from the start side of the layout.
+ *
+ * @param offset offset to be used in [Dp].
+ */
+ fun fromStart(offset: Dp): Offset {
+ require(offset >= 0.dp) { "Offset must larger than or equal to 0 dp." }
+ return StartOffset(offset)
+ }
+
+ /**
+ * Create an [androidx.compose.material3.adaptive.layout.PaneExpansionAnchor.Offset]
+ * anchor from the end side of the layout.
+ *
+ * @param offset offset to be used in [Dp].
+ */
+ fun fromEnd(offset: Dp): Offset {
+ require(offset >= 0.dp) { "Offset must larger than or equal to 0 dp." }
+ return EndOffset(offset)
+ }
}
}
internal companion object {
internal const val UnspecifiedType = 0
internal const val ProportionType = 1
- internal const val OffsetType = 2
+ internal const val OffsetFromStartType = 2
+ internal const val OffsetFromEndType = 3
}
}
@@ -691,8 +795,10 @@
when (currentAnchorType) {
PaneExpansionAnchor.ProportionType ->
PaneExpansionAnchor.Proportion(it[6] as Float)
- PaneExpansionAnchor.OffsetType ->
- PaneExpansionAnchor.Offset((it[6] as Float).dp)
+ PaneExpansionAnchor.OffsetFromStartType ->
+ PaneExpansionAnchor.Offset.fromStart((it[6] as Float).dp)
+ PaneExpansionAnchor.OffsetFromEndType ->
+ PaneExpansionAnchor.Offset.fromEnd((it[6] as Float).dp)
else -> null
}
object : Map.Entry<PaneExpansionStateKey, PaneExpansionStateData> {
diff --git a/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/PaneMotion.kt b/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/PaneMotion.kt
index d1c2452..984a3f89 100644
--- a/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/PaneMotion.kt
+++ b/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/PaneMotion.kt
@@ -31,6 +31,8 @@
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.Stable
import androidx.compose.ui.Alignment
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntRect
@@ -350,15 +352,101 @@
return 0
}
+@OptIn(ExperimentalMaterial3AdaptiveApi::class)
+@VisibleForTesting
+internal fun <Role> PaneScaffoldMotionDataProvider<Role>.getHiddenPaneCurrentLeft(role: Role): Int {
+ var currentLeft = 0
+ forEach { paneRole, data ->
+ // Find the right edge of the shown pane next to the left.
+ if (paneRole == role) {
+ return currentLeft
+ }
+ if (
+ data.motion.type == PaneMotion.Type.Shown || data.motion.type == PaneMotion.Type.Exiting
+ ) {
+ currentLeft = data.currentRight
+ }
+ }
+ return currentLeft
+}
+
+@OptIn(ExperimentalMaterial3AdaptiveApi::class)
+@VisibleForTesting
+internal fun <Role> PaneScaffoldMotionDataProvider<Role>.getHidingPaneTargetLeft(role: Role): Int {
+ var targetLeft = 0
+ forEach { paneRole, data ->
+ // Find the right edge of the shown pane next to the left.
+ if (paneRole == role) {
+ return targetLeft
+ }
+ if (
+ data.motion.type == PaneMotion.Type.Shown ||
+ data.motion.type == PaneMotion.Type.Entering
+ ) {
+ targetLeft = data.targetRight
+ }
+ }
+ return targetLeft
+}
+
+/**
+ * Calculates the default [EnterTransition] of the pane associated to the given role when it's
+ * showing. The [PaneMotion] and pane measurement data provided by [PaneScaffoldMotionDataProvider]
+ * will be used to decide the transition type and relevant values like sliding offsets.
+ *
+ * @param role the role of the pane that is supposed to perform the [EnterTransition] when showing.
+ */
+@ExperimentalMaterial3AdaptiveApi
+fun <Role> PaneScaffoldMotionDataProvider<Role>.calculateDefaultEnterTransition(role: Role) =
+ when (this[role].motion) {
+ PaneMotion.EnterFromLeft ->
+ slideInHorizontally(PaneMotionDefaults.OffsetAnimationSpec) { slideInFromLeftOffset }
+ PaneMotion.EnterFromLeftDelayed ->
+ slideInHorizontally(PaneMotionDefaults.DelayedOffsetAnimationSpec) {
+ slideInFromLeftOffset
+ }
+ PaneMotion.EnterFromRight ->
+ slideInHorizontally(PaneMotionDefaults.OffsetAnimationSpec) { slideInFromRightOffset }
+ PaneMotion.EnterFromRightDelayed ->
+ slideInHorizontally(PaneMotionDefaults.DelayedOffsetAnimationSpec) {
+ slideInFromRightOffset
+ }
+ PaneMotion.EnterWithExpand -> {
+ expandHorizontally(PaneMotionDefaults.SizeAnimationSpec, Alignment.CenterHorizontally) +
+ slideInHorizontally(PaneMotionDefaults.OffsetAnimationSpec) {
+ getHiddenPaneCurrentLeft(role) - this[role].targetLeft
+ }
+ }
+ else -> EnterTransition.None
+ }
+
+/**
+ * Calculates the default [ExitTransition] of the pane associated to the given role when it's
+ * hiding. The [PaneMotion] and pane measurement data provided by [PaneScaffoldMotionDataProvider]
+ * will be used to decide the transition type and relevant values like sliding offsets.
+ *
+ * @param role the role of the pane that is supposed to perform the [ExitTransition] when hiding.
+ */
+@ExperimentalMaterial3AdaptiveApi
+fun <Role> PaneScaffoldMotionDataProvider<Role>.calculateDefaultExitTransition(role: Role) =
+ when (this[role].motion) {
+ PaneMotion.ExitToLeft ->
+ slideOutHorizontally(PaneMotionDefaults.OffsetAnimationSpec) { slideOutToLeftOffset }
+ PaneMotion.ExitToRight ->
+ slideOutHorizontally(PaneMotionDefaults.OffsetAnimationSpec) { slideOutToRightOffset }
+ PaneMotion.ExitWithShrink -> {
+ shrinkHorizontally(PaneMotionDefaults.SizeAnimationSpec, Alignment.CenterHorizontally) +
+ slideOutHorizontally(PaneMotionDefaults.OffsetAnimationSpec) {
+ getHidingPaneTargetLeft(role) - this[role].currentLeft
+ }
+ }
+ else -> ExitTransition.None
+ }
+
/** Interface to specify a custom pane enter/exit motion when a pane's visibility changes. */
@ExperimentalMaterial3AdaptiveApi
-interface PaneMotion {
- /** The [EnterTransition] of a pane under the given [PaneScaffoldMotionDataProvider]. */
- val PaneScaffoldMotionDataProvider<*>.enterTransition: EnterTransition
-
- /** The [ExitTransition] of a pane under the given [PaneScaffoldMotionDataProvider]. */
- val PaneScaffoldMotionDataProvider<*>.exitTransition: ExitTransition
-
+@Stable
+sealed interface PaneMotion {
/** The type of the motion, like exiting, entering, etc. See [Type]. */
val type: Type
@@ -405,138 +493,73 @@
}
}
- private abstract class DefaultImpl(val name: String, override val type: Type) : PaneMotion {
- override val PaneScaffoldMotionDataProvider<*>.enterTransition
- get() = EnterTransition.None
-
- override val PaneScaffoldMotionDataProvider<*>.exitTransition
- get() = ExitTransition.None
-
+ @Immutable
+ private class DefaultImpl(val name: String, override val type: Type) : PaneMotion {
override fun toString() = name
}
companion object {
/** The default pane motion that no animation will be performed. */
- val NoMotion: PaneMotion = object : DefaultImpl("NoMotion", Type.Hidden) {}
+ val NoMotion: PaneMotion = DefaultImpl("NoMotion", Type.Hidden)
/**
* The default pane motion that will animate panes bounds with the given animation specs
* during motion. Note that this should only be used when the associated pane is keeping
* showing during the motion.
*/
- val AnimateBounds: PaneMotion = object : DefaultImpl("AnimateBounds", Type.Shown) {}
+ val AnimateBounds: PaneMotion = DefaultImpl("AnimateBounds", Type.Shown)
/**
* The default pane motion that will slide panes in from left. Note that this should only be
* used when the associated pane is entering - i.e. becoming visible from a hidden state.
*/
- val EnterFromLeft: PaneMotion =
- object : DefaultImpl("EnterFromLeft", Type.Entering) {
- override val PaneScaffoldMotionDataProvider<*>.enterTransition
- get() =
- slideInHorizontally(PaneMotionDefaults.OffsetAnimationSpec) {
- slideInFromLeftOffset
- }
- }
+ val EnterFromLeft: PaneMotion = DefaultImpl("EnterFromLeft", Type.Entering)
/**
* The default pane motion that will slide panes in from right. Note that this should only
* be used when the associated pane is entering - i.e. becoming visible from a hidden state.
*/
- val EnterFromRight: PaneMotion =
- object : DefaultImpl("EnterFromRight", Type.Entering) {
- override val PaneScaffoldMotionDataProvider<*>.enterTransition
- get() =
- slideInHorizontally(PaneMotionDefaults.OffsetAnimationSpec) {
- slideInFromRightOffset
- }
- }
+ val EnterFromRight: PaneMotion = DefaultImpl("EnterFromRight", Type.Entering)
/**
* The default pane motion that will slide panes in from left with a delay, usually to avoid
* the interference of other exiting panes. Note that this should only be used when the
* associated pane is entering - i.e. becoming visible from a hidden state.
*/
- val EnterFromLeftDelayed: PaneMotion =
- object : DefaultImpl("EnterFromLeftDelayed", Type.Entering) {
- override val PaneScaffoldMotionDataProvider<*>.enterTransition
- get() =
- slideInHorizontally(PaneMotionDefaults.DelayedOffsetAnimationSpec) {
- slideInFromLeftOffset
- }
- }
+ val EnterFromLeftDelayed: PaneMotion = DefaultImpl("EnterFromLeftDelayed", Type.Entering)
/**
* The default pane motion that will slide panes in from right with a delay, usually to
* avoid the interference of other exiting panes. Note that this should only be used when
* the associated pane is entering - i.e. becoming visible from a hidden state.
*/
- val EnterFromRightDelayed: PaneMotion =
- object : DefaultImpl("EnterFromRightDelayed", Type.Entering) {
- override val PaneScaffoldMotionDataProvider<*>.enterTransition
- get() =
- slideInHorizontally(PaneMotionDefaults.DelayedOffsetAnimationSpec) {
- slideInFromRightOffset
- }
- }
+ val EnterFromRightDelayed: PaneMotion = DefaultImpl("EnterFromRightDelayed", Type.Entering)
/**
* The default pane motion that will slide panes out to left. Note that this should only be
* used when the associated pane is exiting - i.e. becoming hidden from a visible state.
*/
- val ExitToLeft: PaneMotion =
- object : DefaultImpl("ExitToLeft", Type.Exiting) {
- override val PaneScaffoldMotionDataProvider<*>.exitTransition
- get() =
- slideOutHorizontally(PaneMotionDefaults.OffsetAnimationSpec) {
- slideOutToLeftOffset
- }
- }
+ val ExitToLeft: PaneMotion = DefaultImpl("ExitToLeft", Type.Exiting)
/**
* The default pane motion that will slide panes out to right. Note that this should only be
* used when the associated pane is exiting - i.e. becoming hidden from a visible state.
*/
- val ExitToRight: PaneMotion =
- object : DefaultImpl("ExitToRight", Type.Exiting) {
- override val PaneScaffoldMotionDataProvider<*>.exitTransition
- get() =
- slideOutHorizontally(PaneMotionDefaults.OffsetAnimationSpec) {
- slideOutToRightOffset
- }
- }
+ val ExitToRight: PaneMotion = DefaultImpl("ExitToRight", Type.Exiting)
/**
* The default pane motion that will expand panes from a zero size. Note that this should
* only be used when the associated pane is entering - i.e. becoming visible from a hidden
* state.
*/
- val EnterWithExpand: PaneMotion =
- object : DefaultImpl("EnterWithExpand", Type.Entering) {
- // TODO(conradchen): Expand with position change
- override val PaneScaffoldMotionDataProvider<*>.enterTransition
- get() =
- expandHorizontally(
- PaneMotionDefaults.SizeAnimationSpec,
- Alignment.CenterHorizontally
- )
- }
+ val EnterWithExpand: PaneMotion = DefaultImpl("EnterWithExpand", Type.Entering)
/**
* The default pane motion that will shrink panes until it's gone. Note that this should
* only be used when the associated pane is exiting - i.e. becoming hidden from a visible
* state.
*/
- val ExitWithShrink: PaneMotion =
- object : DefaultImpl("ExitWithShrink", Type.Exiting) {
- // TODO(conradchen): Shrink with position change
- override val PaneScaffoldMotionDataProvider<*>.exitTransition
- get() =
- shrinkHorizontally(
- PaneMotionDefaults.SizeAnimationSpec,
- Alignment.CenterHorizontally
- )
- }
+ val ExitWithShrink: PaneMotion = DefaultImpl("ExitWithShrink", Type.Exiting)
}
}
diff --git a/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/PaneScaffold.kt b/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/PaneScaffold.kt
index df9e946..0607f9f 100644
--- a/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/PaneScaffold.kt
+++ b/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/PaneScaffold.kt
@@ -17,7 +17,6 @@
package androidx.compose.material3.adaptive.layout
import androidx.annotation.FloatRange
-import androidx.compose.animation.EnterTransition
import androidx.compose.animation.core.Transition
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
@@ -32,7 +31,7 @@
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
-import androidx.compose.ui.util.fastMaxOfOrNull
+import androidx.compose.ui.unit.isSpecified
/**
* Extended scope for the panes of pane scaffolds. All pane scaffolds will implement this interface
@@ -125,24 +124,6 @@
* behavior.
*/
val motionDataProvider: PaneScaffoldMotionDataProvider<Role>
-
- /**
- * A convenient function to get the given [PaneMotion]'s [EnterTransition] under the context of
- * the current [PaneScaffoldTransitionScope].
- *
- * @see [PaneMotion.enterTransition]
- */
- val PaneMotion.enterTransition
- get() = with(this) { motionDataProvider.enterTransition }
-
- /**
- * A convenient function to get the given [PaneMotion]'s [EnterTransition] under the context of
- * the current [PaneScaffoldTransitionScope].
- *
- * @see [PaneMotion.exitTransition]
- */
- val PaneMotion.exitTransition
- get() = with(this) { motionDataProvider.exitTransition }
}
/**
@@ -240,17 +221,11 @@
}
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
-internal val List<Measurable>.minTouchTargetSize: Dp
- get() =
- fastMaxOfOrNull {
- val size =
- (it.parentData as? PaneScaffoldParentData)?.minTouchTargetSize ?: Dp.Unspecified
- if (size == Dp.Unspecified) {
- 0.dp
- } else {
- size
- }
- } ?: 0.dp
+internal val Measurable.minTouchTargetSize: Dp
+ get() {
+ val size = (parentData as? PaneScaffoldParentData)?.minTouchTargetSize ?: Dp.Unspecified
+ return if (size.isSpecified) size else 0.dp
+ }
/**
* The parent data passed to pane scaffolds by their contents like panes and drag handles.
diff --git a/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/PaneScaffoldDirective.kt b/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/PaneScaffoldDirective.kt
index a2a06c7..8c32cd7 100644
--- a/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/PaneScaffoldDirective.kt
+++ b/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/PaneScaffoldDirective.kt
@@ -18,7 +18,6 @@
package androidx.compose.material3.adaptive.layout
-import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.Posture
import androidx.compose.material3.adaptive.WindowAdaptiveInfo
import androidx.compose.material3.adaptive.allVerticalHingeBounds
@@ -44,7 +43,6 @@
* vertical hinges.
* @return an [PaneScaffoldDirective] to be used to decide adaptive layout states.
*/
-@ExperimentalMaterial3AdaptiveApi
@Suppress("DEPRECATION") // WindowWidthSizeClass is deprecated
fun calculatePaneScaffoldDirective(
windowAdaptiveInfo: WindowAdaptiveInfo,
@@ -69,7 +67,6 @@
val maxVerticalPartitions: Int
val verticalPartitionSpacerSize: Dp
- // TODO(conradchen): Confirm the table top mode settings
if (windowAdaptiveInfo.windowPosture.isTabletop) {
maxVerticalPartitions = 2
verticalPartitionSpacerSize = 24.dp
@@ -109,7 +106,6 @@
* vertical hinges.
* @return an [PaneScaffoldDirective] to be used to decide adaptive layout states.
*/
-@ExperimentalMaterial3AdaptiveApi
@Suppress("DEPRECATION") // WindowWidthSizeClass is deprecated
fun calculatePaneScaffoldDirectiveWithTwoPanesOnMediumWidth(
windowAdaptiveInfo: WindowAdaptiveInfo,
diff --git a/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/Strings.kt b/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/Strings.kt
index 4ff2966..e11af66 100644
--- a/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/Strings.kt
+++ b/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/Strings.kt
@@ -27,7 +27,8 @@
val defaultPaneExpansionDragHandleContentDescription: Strings
val defaultPaneExpansionDragHandleActionDescription: Strings
val defaultPaneExpansionProportionAnchorDescription: Strings
- val defaultPaneExpansionOffsetAnchorDescription: Strings
+ val defaultPaneExpansionStartOffsetAnchorDescription: Strings
+ val defaultPaneExpansionEndOffsetAnchorDescription: Strings
}
}
diff --git a/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/SupportingPaneScaffold.kt b/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/SupportingPaneScaffold.kt
index 1fae8c8..2764c13 100644
--- a/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/SupportingPaneScaffold.kt
+++ b/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/SupportingPaneScaffold.kt
@@ -177,7 +177,6 @@
* three pane scaffolds. We suggest you to use the values defined here instead of the raw
* [ThreePaneScaffoldRole] under the context of [SupportingPaneScaffold] for better code clarity.
*/
-@ExperimentalMaterial3AdaptiveApi
object SupportingPaneScaffoldRole {
/**
* The main pane of [SupportingPaneScaffold], which is supposed to hold the major content of an
diff --git a/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/ThreePaneScaffold.kt b/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/ThreePaneScaffold.kt
index e3477c7..1355351 100644
--- a/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/ThreePaneScaffold.kt
+++ b/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/ThreePaneScaffold.kt
@@ -52,7 +52,6 @@
import androidx.compose.ui.unit.roundToIntRect
import androidx.compose.ui.util.fastForEach
import androidx.compose.ui.util.fastForEachIndexed
-import androidx.compose.ui.util.fastMap
import kotlin.math.max
import kotlin.math.min
@@ -307,10 +306,10 @@
measurables: List<List<Measurable>>,
constraints: Constraints
): MeasureResult {
- val primaryMeasurables = measurables[0]
- val secondaryMeasurables = measurables[1]
- val tertiaryMeasurables = measurables[2]
- val dragHandleMeasurables = measurables[3]
+ val primaryMeasurable = measurables[0].firstOrNull()
+ val secondaryMeasurable = measurables[1].firstOrNull()
+ val tertiaryMeasurable = measurables[2].firstOrNull()
+ val dragHandleMeasurable = measurables[3].firstOrNull()
return layout(constraints.maxWidth, constraints.maxHeight) {
if (coordinates == null) {
return@layout
@@ -319,10 +318,10 @@
val visiblePanes =
getPanesMeasurables(
paneOrder = paneOrder,
- primaryMeasurables = primaryMeasurables,
+ primaryMeasurable = primaryMeasurable,
scaffoldValue = scaffoldValue,
- secondaryMeasurables = secondaryMeasurables,
- tertiaryMeasurables = tertiaryMeasurables
+ secondaryMeasurable = secondaryMeasurable,
+ tertiaryMeasurable = tertiaryMeasurable
) {
it != PaneAdaptedValue.Hidden
}
@@ -330,10 +329,10 @@
val hiddenPanes =
getPanesMeasurables(
paneOrder = paneOrder,
- primaryMeasurables = primaryMeasurables,
+ primaryMeasurable = primaryMeasurable,
scaffoldValue = scaffoldValue,
- secondaryMeasurables = secondaryMeasurables,
- tertiaryMeasurables = tertiaryMeasurables
+ secondaryMeasurable = secondaryMeasurable,
+ tertiaryMeasurable = tertiaryMeasurable
) {
it == PaneAdaptedValue.Hidden
}
@@ -529,7 +528,7 @@
)
}
- if (visiblePanes.size == 2 && dragHandleMeasurables.isNotEmpty()) {
+ if (visiblePanes.size == 2 && dragHandleMeasurable != null) {
val handleOffsetX =
if (
!paneExpansionState.isDraggingOrSettling ||
@@ -546,11 +545,11 @@
paneExpansionState.currentDraggingOffset
}
measureAndPlaceDragHandleIfNeeded(
- measurables = dragHandleMeasurables,
+ measurable = dragHandleMeasurable,
constraints = constraints,
contentBounds = outerBounds,
minHorizontalMargin = verticalSpacerSize / 2,
- minTouchTargetSize = dragHandleMeasurables.minTouchTargetSize.roundToPx(),
+ minTouchTargetSize = dragHandleMeasurable.minTouchTargetSize.roundToPx(),
offsetX = handleOffsetX
)
} else if (!isLookingAhead) {
@@ -568,10 +567,10 @@
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
private fun MeasureScope.getPanesMeasurables(
paneOrder: ThreePaneScaffoldHorizontalOrder,
- primaryMeasurables: List<Measurable>,
+ primaryMeasurable: Measurable?,
scaffoldValue: ThreePaneScaffoldValue,
- secondaryMeasurables: List<Measurable>,
- tertiaryMeasurables: List<Measurable>,
+ secondaryMeasurable: Measurable?,
+ tertiaryMeasurable: Measurable?,
predicate: (PaneAdaptedValue) -> Boolean
): List<PaneMeasurable> {
return buildList {
@@ -580,7 +579,7 @@
when (role) {
ThreePaneScaffoldRole.Primary -> {
createPaneMeasurableIfNeeded(
- primaryMeasurables,
+ primaryMeasurable,
ThreePaneScaffoldDefaults.PrimaryPanePriority,
role,
scaffoldDirective.defaultPanePreferredWidth.roundToPx(),
@@ -589,7 +588,7 @@
}
ThreePaneScaffoldRole.Secondary -> {
createPaneMeasurableIfNeeded(
- secondaryMeasurables,
+ secondaryMeasurable,
ThreePaneScaffoldDefaults.SecondaryPanePriority,
role,
scaffoldDirective.defaultPanePreferredWidth.roundToPx(),
@@ -598,7 +597,7 @@
}
ThreePaneScaffoldRole.Tertiary -> {
createPaneMeasurableIfNeeded(
- tertiaryMeasurables,
+ tertiaryMeasurable,
ThreePaneScaffoldDefaults.TertiaryPanePriority,
role,
scaffoldDirective.defaultPanePreferredWidth.roundToPx(),
@@ -612,14 +611,14 @@
}
private fun MutableList<PaneMeasurable>.createPaneMeasurableIfNeeded(
- measurables: List<Measurable>,
+ measurable: Measurable?,
priority: Int,
role: ThreePaneScaffoldRole,
defaultPreferredWidth: Int,
density: Density
) {
- if (measurables.isNotEmpty()) {
- add(PaneMeasurable(measurables[0], priority, role, defaultPreferredWidth, density))
+ if (measurable != null) {
+ add(PaneMeasurable(measurable, priority, role, defaultPreferredWidth, density))
}
}
@@ -747,7 +746,7 @@
}
private fun Placeable.PlacementScope.measureAndPlaceDragHandleIfNeeded(
- measurables: List<Measurable>,
+ measurable: Measurable,
constraints: Constraints,
contentBounds: IntRect,
minHorizontalMargin: Int,
@@ -778,15 +777,14 @@
} else {
minTouchTargetSize
}
- val placeables =
- measurables.fastMap {
- it.measure(
- Constraints(minWidth = minDragHandleWidth, maxHeight = contentBounds.height)
- )
- }
- placeables.fastForEach {
- it.place(clampedOffsetX - it.width / 2, (constraints.maxHeight - it.height) / 2)
- }
+ val placeable =
+ measurable.measure(
+ Constraints(minWidth = minDragHandleWidth, maxHeight = contentBounds.height)
+ )
+ placeable.place(
+ x = clampedOffsetX - placeable.width / 2,
+ y = (constraints.maxHeight - placeable.height) / 2
+ )
}
private fun getSpacerMiddleOffsetX(paneLeft: PaneMeasurable, paneRight: PaneMeasurable): Int {
diff --git a/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/ThreePaneScaffoldDestinationItem.kt b/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/ThreePaneScaffoldDestinationItem.kt
index 8ebafe4..fc1dfd9 100644
--- a/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/ThreePaneScaffoldDestinationItem.kt
+++ b/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/ThreePaneScaffoldDestinationItem.kt
@@ -16,8 +16,6 @@
package androidx.compose.material3.adaptive.layout
-import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
-
/**
* An item representing a navigation destination in a [ThreePaneScaffold].
*
@@ -25,7 +23,6 @@
* @param contentKey the optional key or id representing the content of the destination. The type
* [T] must be storable in a Bundle.
*/
-@ExperimentalMaterial3AdaptiveApi
class ThreePaneScaffoldDestinationItem<out T>(
val pane: ThreePaneScaffoldRole,
val contentKey: T? = null,
diff --git a/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/ThreePaneScaffoldValue.kt b/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/ThreePaneScaffoldValue.kt
index d119518..71c42c2 100644
--- a/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/ThreePaneScaffoldValue.kt
+++ b/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/ThreePaneScaffoldValue.kt
@@ -22,7 +22,6 @@
import androidx.compose.runtime.saveable.listSaver
import androidx.compose.ui.util.fastForEachReversed
-@ExperimentalMaterial3AdaptiveApi
private inline fun buildThreePaneScaffoldValue(
buildAction: (ThreePaneScaffoldRole) -> PaneAdaptedValue
): ThreePaneScaffoldValue {
@@ -168,7 +167,7 @@
* @param tertiary [PaneAdaptedValue] of the tertiary pane of [ThreePaneScaffold]
* @constructor create an instance of [ThreePaneScaffoldValue]
*/
-@ExperimentalMaterial3AdaptiveApi
+@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Immutable
class ThreePaneScaffoldValue(
val primary: PaneAdaptedValue,
diff --git a/compose/material3/adaptive/adaptive-layout/src/jvmStubsMain/kotlin/androidx/compose/material3/adaptive/layout/Strings.jvmStubs.kt b/compose/material3/adaptive/adaptive-layout/src/jvmStubsMain/kotlin/androidx/compose/material3/adaptive/layout/Strings.jvmStubs.kt
index a419136..11184b6 100644
--- a/compose/material3/adaptive/adaptive-layout/src/jvmStubsMain/kotlin/androidx/compose/material3/adaptive/layout/Strings.jvmStubs.kt
+++ b/compose/material3/adaptive/adaptive-layout/src/jvmStubsMain/kotlin/androidx/compose/material3/adaptive/layout/Strings.jvmStubs.kt
@@ -39,7 +39,9 @@
implementedInJetBrainsFork()
actual val defaultPaneExpansionProportionAnchorDescription: Strings =
implementedInJetBrainsFork()
- actual val defaultPaneExpansionOffsetAnchorDescription: Strings =
+ actual val defaultPaneExpansionStartOffsetAnchorDescription: Strings =
+ implementedInJetBrainsFork()
+ actual val defaultPaneExpansionEndOffsetAnchorDescription: Strings =
implementedInJetBrainsFork()
}
}
diff --git a/compose/material3/adaptive/adaptive-navigation/api/current.txt b/compose/material3/adaptive/adaptive-navigation/api/current.txt
index 0e485ac..ba0712b 100644
--- a/compose/material3/adaptive/adaptive-navigation/api/current.txt
+++ b/compose/material3/adaptive/adaptive-navigation/api/current.txt
@@ -6,7 +6,7 @@
method @SuppressCompatibility @androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi @androidx.compose.runtime.Composable public static <T> void NavigableSupportingPaneScaffold(androidx.compose.material3.adaptive.navigation.ThreePaneScaffoldNavigator<T> navigator, kotlin.jvm.functions.Function1<? super androidx.compose.material3.adaptive.layout.ThreePaneScaffoldPaneScope,kotlin.Unit> mainPane, kotlin.jvm.functions.Function1<? super androidx.compose.material3.adaptive.layout.ThreePaneScaffoldPaneScope,kotlin.Unit> supportingPane, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function1<? super androidx.compose.material3.adaptive.layout.ThreePaneScaffoldPaneScope,kotlin.Unit>? extraPane, optional String defaultBackBehavior, optional kotlin.jvm.functions.Function2<? super androidx.compose.material3.adaptive.layout.ThreePaneScaffoldScope,? super androidx.compose.material3.adaptive.layout.PaneExpansionState,kotlin.Unit>? paneExpansionDragHandle, optional androidx.compose.material3.adaptive.layout.PaneExpansionState? paneExpansionState);
}
- @SuppressCompatibility @androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi @kotlin.jvm.JvmInline public final value class BackNavigationBehavior {
+ @kotlin.jvm.JvmInline public final value class BackNavigationBehavior {
field public static final androidx.compose.material3.adaptive.navigation.BackNavigationBehavior.Companion Companion;
}
diff --git a/compose/material3/adaptive/adaptive-navigation/api/restricted_current.txt b/compose/material3/adaptive/adaptive-navigation/api/restricted_current.txt
index 0e485ac..ba0712b 100644
--- a/compose/material3/adaptive/adaptive-navigation/api/restricted_current.txt
+++ b/compose/material3/adaptive/adaptive-navigation/api/restricted_current.txt
@@ -6,7 +6,7 @@
method @SuppressCompatibility @androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi @androidx.compose.runtime.Composable public static <T> void NavigableSupportingPaneScaffold(androidx.compose.material3.adaptive.navigation.ThreePaneScaffoldNavigator<T> navigator, kotlin.jvm.functions.Function1<? super androidx.compose.material3.adaptive.layout.ThreePaneScaffoldPaneScope,kotlin.Unit> mainPane, kotlin.jvm.functions.Function1<? super androidx.compose.material3.adaptive.layout.ThreePaneScaffoldPaneScope,kotlin.Unit> supportingPane, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function1<? super androidx.compose.material3.adaptive.layout.ThreePaneScaffoldPaneScope,kotlin.Unit>? extraPane, optional String defaultBackBehavior, optional kotlin.jvm.functions.Function2<? super androidx.compose.material3.adaptive.layout.ThreePaneScaffoldScope,? super androidx.compose.material3.adaptive.layout.PaneExpansionState,kotlin.Unit>? paneExpansionDragHandle, optional androidx.compose.material3.adaptive.layout.PaneExpansionState? paneExpansionState);
}
- @SuppressCompatibility @androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi @kotlin.jvm.JvmInline public final value class BackNavigationBehavior {
+ @kotlin.jvm.JvmInline public final value class BackNavigationBehavior {
field public static final androidx.compose.material3.adaptive.navigation.BackNavigationBehavior.Companion Companion;
}
diff --git a/compose/material3/adaptive/adaptive-navigation/src/commonMain/kotlin/androidx/compose/material3/adaptive/navigation/BackNavigationBehavior.kt b/compose/material3/adaptive/adaptive-navigation/src/commonMain/kotlin/androidx/compose/material3/adaptive/navigation/BackNavigationBehavior.kt
index 66f554d7..37c8d7f 100644
--- a/compose/material3/adaptive/adaptive-navigation/src/commonMain/kotlin/androidx/compose/material3/adaptive/navigation/BackNavigationBehavior.kt
+++ b/compose/material3/adaptive/adaptive-navigation/src/commonMain/kotlin/androidx/compose/material3/adaptive/navigation/BackNavigationBehavior.kt
@@ -16,13 +16,11 @@
package androidx.compose.material3.adaptive.navigation
-import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.layout.PaneAdaptedValue
import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldDestinationItem
import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole
/** A class to control how back navigation should behave in a [ThreePaneScaffoldNavigator]. */
-@ExperimentalMaterial3AdaptiveApi
@JvmInline
value class BackNavigationBehavior private constructor(private val description: String) {
override fun toString(): String = this.description
diff --git a/compose/material3/adaptive/adaptive/api/current.txt b/compose/material3/adaptive/adaptive/api/current.txt
index 7dc7be8..a93216b 100644
--- a/compose/material3/adaptive/adaptive/api/current.txt
+++ b/compose/material3/adaptive/adaptive/api/current.txt
@@ -2,7 +2,7 @@
package androidx.compose.material3.adaptive {
public final class AndroidPosture_androidKt {
- method @SuppressCompatibility @androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi public static androidx.compose.material3.adaptive.Posture calculatePosture(java.util.List<? extends androidx.window.layout.FoldingFeature> foldingFeatures);
+ method public static androidx.compose.material3.adaptive.Posture calculatePosture(java.util.List<? extends androidx.window.layout.FoldingFeature> foldingFeatures);
}
public final class AndroidWindowAdaptiveInfo_androidKt {
diff --git a/compose/material3/adaptive/adaptive/api/restricted_current.txt b/compose/material3/adaptive/adaptive/api/restricted_current.txt
index 7dc7be8..a93216b 100644
--- a/compose/material3/adaptive/adaptive/api/restricted_current.txt
+++ b/compose/material3/adaptive/adaptive/api/restricted_current.txt
@@ -2,7 +2,7 @@
package androidx.compose.material3.adaptive {
public final class AndroidPosture_androidKt {
- method @SuppressCompatibility @androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi public static androidx.compose.material3.adaptive.Posture calculatePosture(java.util.List<? extends androidx.window.layout.FoldingFeature> foldingFeatures);
+ method public static androidx.compose.material3.adaptive.Posture calculatePosture(java.util.List<? extends androidx.window.layout.FoldingFeature> foldingFeatures);
}
public final class AndroidWindowAdaptiveInfo_androidKt {
diff --git a/compose/material3/adaptive/adaptive/src/androidMain/kotlin/androidx/compose/material3/adaptive/AndroidPosture.android.kt b/compose/material3/adaptive/adaptive/src/androidMain/kotlin/androidx/compose/material3/adaptive/AndroidPosture.android.kt
index 60b16e60..1ace339 100644
--- a/compose/material3/adaptive/adaptive/src/androidMain/kotlin/androidx/compose/material3/adaptive/AndroidPosture.android.kt
+++ b/compose/material3/adaptive/adaptive/src/androidMain/kotlin/androidx/compose/material3/adaptive/AndroidPosture.android.kt
@@ -23,7 +23,6 @@
* Calculates the [Posture] for a given list of [FoldingFeature]s. This methods converts framework
* folding info into the Material-opinionated posture info.
*/
-@ExperimentalMaterial3AdaptiveApi
fun calculatePosture(foldingFeatures: List<FoldingFeature>): Posture {
var isTableTop = false
val hingeList = mutableListOf<HingeInfo>()
diff --git a/compose/material3/adaptive/samples/src/main/java/androidx/compose/material3/adaptive/samples/ThreePaneScaffoldSample.kt b/compose/material3/adaptive/samples/src/main/java/androidx/compose/material3/adaptive/samples/ThreePaneScaffoldSample.kt
index 798e57e..5181bf1 100644
--- a/compose/material3/adaptive/samples/src/main/java/androidx/compose/material3/adaptive/samples/ThreePaneScaffoldSample.kt
+++ b/compose/material3/adaptive/samples/src/main/java/androidx/compose/material3/adaptive/samples/ThreePaneScaffoldSample.kt
@@ -438,8 +438,8 @@
private val PaneExpansionAnchors =
listOf(
PaneExpansionAnchor.Proportion(0f),
- PaneExpansionAnchor.Proportion(0.25f),
+ PaneExpansionAnchor.Offset.fromStart(360.dp),
PaneExpansionAnchor.Proportion(0.5f),
- PaneExpansionAnchor.Proportion(0.75f),
+ PaneExpansionAnchor.Offset.fromEnd(360.dp),
PaneExpansionAnchor.Proportion(1f),
)
diff --git a/compose/material3/material3/api/current.txt b/compose/material3/material3/api/current.txt
index a3501ba..273f29f 100644
--- a/compose/material3/material3/api/current.txt
+++ b/compose/material3/material3/api/current.txt
@@ -211,6 +211,7 @@
method public float getMinWidth();
method @Deprecated @androidx.compose.runtime.Composable public androidx.compose.foundation.BorderStroke getOutlinedButtonBorder();
method @androidx.compose.runtime.Composable public androidx.compose.ui.graphics.Shape getOutlinedShape();
+ method @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @androidx.compose.runtime.Composable public androidx.compose.ui.graphics.Shape getPressedShape();
method @androidx.compose.runtime.Composable public androidx.compose.ui.graphics.Shape getShape();
method public androidx.compose.foundation.layout.PaddingValues getSmallButtonContentPadding();
method @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @androidx.compose.runtime.Composable public androidx.compose.ui.graphics.Shape getSquareShape();
@@ -228,6 +229,7 @@
method @androidx.compose.runtime.Composable public androidx.compose.foundation.BorderStroke outlinedButtonBorder(optional boolean enabled);
method @androidx.compose.runtime.Composable public androidx.compose.material3.ButtonColors outlinedButtonColors();
method @androidx.compose.runtime.Composable public androidx.compose.material3.ButtonColors outlinedButtonColors(optional long containerColor, optional long contentColor, optional long disabledContainerColor, optional long disabledContentColor);
+ method @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @androidx.compose.runtime.Composable public androidx.compose.material3.ButtonShapes shapes();
method @androidx.compose.runtime.Composable public androidx.compose.material3.ButtonColors textButtonColors();
method @androidx.compose.runtime.Composable public androidx.compose.material3.ButtonColors textButtonColors(optional long containerColor, optional long contentColor, optional long disabledContainerColor, optional long disabledContentColor);
property public final androidx.compose.foundation.layout.PaddingValues ButtonWithIconContentPadding;
@@ -259,6 +261,7 @@
property @androidx.compose.runtime.Composable public final androidx.compose.ui.graphics.Shape filledTonalShape;
property @Deprecated @androidx.compose.runtime.Composable public final androidx.compose.foundation.BorderStroke outlinedButtonBorder;
property @androidx.compose.runtime.Composable public final androidx.compose.ui.graphics.Shape outlinedShape;
+ property @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @androidx.compose.runtime.Composable public final androidx.compose.ui.graphics.Shape pressedShape;
property @androidx.compose.runtime.Composable public final androidx.compose.ui.graphics.Shape shape;
property @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @androidx.compose.runtime.Composable public final androidx.compose.ui.graphics.Shape squareShape;
property @androidx.compose.runtime.Composable public final androidx.compose.ui.graphics.Shape textShape;
@@ -269,25 +272,25 @@
}
@SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi public final class ButtonGroupDefaults {
- method public float getAnimateFraction();
- method public androidx.compose.ui.graphics.Shape getConnectedLeadingButtonPressShape();
- method public androidx.compose.ui.graphics.Shape getConnectedLeadingButtonShape();
+ method @androidx.compose.runtime.Composable public androidx.compose.ui.graphics.Shape getConnectedLeadingButtonPressShape();
+ method @androidx.compose.runtime.Composable public androidx.compose.ui.graphics.Shape getConnectedLeadingButtonShape();
method public float getConnectedSpaceBetween();
- method public androidx.compose.ui.graphics.Shape getConnectedTrailingButtonPressShape();
- method public androidx.compose.ui.graphics.Shape getConnectedTrailingButtonShape();
- method public float getSpaceBetween();
- property public final float animateFraction;
- property public final androidx.compose.ui.graphics.Shape connectedLeadingButtonPressShape;
- property public final androidx.compose.ui.graphics.Shape connectedLeadingButtonShape;
- property public final float connectedSpaceBetween;
- property public final androidx.compose.ui.graphics.Shape connectedTrailingButtonPressShape;
- property public final androidx.compose.ui.graphics.Shape connectedTrailingButtonShape;
- property public final float spaceBetween;
+ method @androidx.compose.runtime.Composable public androidx.compose.ui.graphics.Shape getConnectedTrailingButtonPressShape();
+ method @androidx.compose.runtime.Composable public androidx.compose.ui.graphics.Shape getConnectedTrailingButtonShape();
+ method public float getExpandedRatio();
+ method public androidx.compose.foundation.layout.Arrangement.Horizontal getHorizontalArrangement();
+ property public final float ConnectedSpaceBetween;
+ property public final float ExpandedRatio;
+ property public final androidx.compose.foundation.layout.Arrangement.Horizontal HorizontalArrangement;
+ property @androidx.compose.runtime.Composable public final androidx.compose.ui.graphics.Shape connectedLeadingButtonPressShape;
+ property @androidx.compose.runtime.Composable public final androidx.compose.ui.graphics.Shape connectedLeadingButtonShape;
+ property @androidx.compose.runtime.Composable public final androidx.compose.ui.graphics.Shape connectedTrailingButtonPressShape;
+ property @androidx.compose.runtime.Composable public final androidx.compose.ui.graphics.Shape connectedTrailingButtonShape;
field public static final androidx.compose.material3.ButtonGroupDefaults INSTANCE;
}
public final class ButtonGroupKt {
- method @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @androidx.compose.runtime.Composable public static void ButtonGroup(optional androidx.compose.ui.Modifier modifier, optional @FloatRange(from=0.0) float animateFraction, optional androidx.compose.foundation.layout.Arrangement.Horizontal horizontalArrangement, kotlin.jvm.functions.Function1<? super androidx.compose.material3.ButtonGroupScope,kotlin.Unit> content);
+ method @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @androidx.compose.runtime.Composable public static void ButtonGroup(optional androidx.compose.ui.Modifier modifier, optional @FloatRange(from=0.0) float expandedRatio, optional androidx.compose.foundation.layout.Arrangement.Horizontal horizontalArrangement, kotlin.jvm.functions.Function1<? super androidx.compose.material3.ButtonGroupScope,kotlin.Unit> content);
}
@SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi public interface ButtonGroupScope {
@@ -295,20 +298,23 @@
}
public final class ButtonKt {
+ method @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @androidx.compose.runtime.Composable public static void Button(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, androidx.compose.material3.ButtonShapes shapes, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional androidx.compose.material3.ButtonColors colors, optional androidx.compose.material3.ButtonElevation? elevation, optional androidx.compose.foundation.BorderStroke? border, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.RowScope,kotlin.Unit> content);
method @androidx.compose.runtime.Composable public static void Button(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional androidx.compose.ui.graphics.Shape shape, optional androidx.compose.material3.ButtonColors colors, optional androidx.compose.material3.ButtonElevation? elevation, optional androidx.compose.foundation.BorderStroke? border, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.RowScope,kotlin.Unit> content);
+ method @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @androidx.compose.runtime.Composable public static void ElevatedButton(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, androidx.compose.material3.ButtonShapes shapes, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional androidx.compose.material3.ButtonColors colors, optional androidx.compose.material3.ButtonElevation? elevation, optional androidx.compose.foundation.BorderStroke? border, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.RowScope,kotlin.Unit> content);
method @androidx.compose.runtime.Composable public static void ElevatedButton(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional androidx.compose.ui.graphics.Shape shape, optional androidx.compose.material3.ButtonColors colors, optional androidx.compose.material3.ButtonElevation? elevation, optional androidx.compose.foundation.BorderStroke? border, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.RowScope,kotlin.Unit> content);
+ method @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @androidx.compose.runtime.Composable public static void FilledTonalButton(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, androidx.compose.material3.ButtonShapes shapes, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional androidx.compose.material3.ButtonColors colors, optional androidx.compose.material3.ButtonElevation? elevation, optional androidx.compose.foundation.BorderStroke? border, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.RowScope,kotlin.Unit> content);
method @androidx.compose.runtime.Composable public static void FilledTonalButton(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional androidx.compose.ui.graphics.Shape shape, optional androidx.compose.material3.ButtonColors colors, optional androidx.compose.material3.ButtonElevation? elevation, optional androidx.compose.foundation.BorderStroke? border, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.RowScope,kotlin.Unit> content);
+ method @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @androidx.compose.runtime.Composable public static void OutlinedButton(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, androidx.compose.material3.ButtonShapes shapes, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional androidx.compose.material3.ButtonColors colors, optional androidx.compose.material3.ButtonElevation? elevation, optional androidx.compose.foundation.BorderStroke? border, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.RowScope,kotlin.Unit> content);
method @androidx.compose.runtime.Composable public static void OutlinedButton(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional androidx.compose.ui.graphics.Shape shape, optional androidx.compose.material3.ButtonColors colors, optional androidx.compose.material3.ButtonElevation? elevation, optional androidx.compose.foundation.BorderStroke? border, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.RowScope,kotlin.Unit> content);
+ method @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @androidx.compose.runtime.Composable public static void TextButton(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, androidx.compose.material3.ButtonShapes shapes, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional androidx.compose.material3.ButtonColors colors, optional androidx.compose.material3.ButtonElevation? elevation, optional androidx.compose.foundation.BorderStroke? border, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.RowScope,kotlin.Unit> content);
method @androidx.compose.runtime.Composable public static void TextButton(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional androidx.compose.ui.graphics.Shape shape, optional androidx.compose.material3.ButtonColors colors, optional androidx.compose.material3.ButtonElevation? elevation, optional androidx.compose.foundation.BorderStroke? border, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.RowScope,kotlin.Unit> content);
}
- public final class ButtonShapes {
- ctor public ButtonShapes(androidx.compose.ui.graphics.Shape shape, androidx.compose.ui.graphics.Shape pressedShape, androidx.compose.ui.graphics.Shape checkedShape);
- method public androidx.compose.material3.ButtonShapes copy(optional androidx.compose.ui.graphics.Shape? shape, optional androidx.compose.ui.graphics.Shape? pressedShape, optional androidx.compose.ui.graphics.Shape? checkedShape);
- method public androidx.compose.ui.graphics.Shape getCheckedShape();
+ @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @androidx.compose.runtime.Immutable public final class ButtonShapes {
+ ctor public ButtonShapes(androidx.compose.ui.graphics.Shape shape, androidx.compose.ui.graphics.Shape pressedShape);
+ method public androidx.compose.material3.ButtonShapes copy(optional androidx.compose.ui.graphics.Shape? shape, optional androidx.compose.ui.graphics.Shape? pressedShape);
method public androidx.compose.ui.graphics.Shape getPressedShape();
method public androidx.compose.ui.graphics.Shape getShape();
- property public final androidx.compose.ui.graphics.Shape checkedShape;
property public final androidx.compose.ui.graphics.Shape pressedShape;
property public final androidx.compose.ui.graphics.Shape shape;
}
@@ -2206,10 +2212,12 @@
method public int getSteps();
method public float getValue();
method public kotlin.ranges.ClosedFloatingPointRange<java.lang.Float> getValueRange();
+ method public boolean isDragging();
method public void setOnValueChange(kotlin.jvm.functions.Function1<? super java.lang.Float,kotlin.Unit>?);
method public void setOnValueChangeFinished(kotlin.jvm.functions.Function0<kotlin.Unit>?);
method public void setValue(float);
property public final float coercedValueAsFraction;
+ property public final boolean isDragging;
property public final kotlin.jvm.functions.Function1<java.lang.Float,kotlin.Unit>? onValueChange;
property public final kotlin.jvm.functions.Function0<kotlin.Unit>? onValueChangeFinished;
property @IntRange(from=0L) public final int steps;
@@ -2787,8 +2795,8 @@
method @androidx.compose.runtime.Composable public androidx.compose.ui.graphics.Shape getXSmallSquareShape();
method @androidx.compose.runtime.Composable public androidx.compose.material3.ToggleButtonColors outlinedToggleButtonColors();
method @androidx.compose.runtime.Composable public androidx.compose.material3.ToggleButtonColors outlinedToggleButtonColors(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.ButtonShapes shapes();
- method @androidx.compose.runtime.Composable public androidx.compose.material3.ButtonShapes shapes(optional androidx.compose.ui.graphics.Shape? shape, optional androidx.compose.ui.graphics.Shape? pressedShape, optional androidx.compose.ui.graphics.Shape? checkedShape);
+ method @androidx.compose.runtime.Composable public androidx.compose.material3.ToggleButtonShapes shapes();
+ method @androidx.compose.runtime.Composable public androidx.compose.material3.ToggleButtonShapes shapes(optional androidx.compose.ui.graphics.Shape? shape, optional androidx.compose.ui.graphics.Shape? pressedShape, optional androidx.compose.ui.graphics.Shape? checkedShape);
method @androidx.compose.runtime.Composable public androidx.compose.material3.ToggleButtonColors toggleButtonColors();
method @androidx.compose.runtime.Composable public androidx.compose.material3.ToggleButtonColors toggleButtonColors(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.ToggleButtonColors tonalToggleButtonColors();
@@ -2818,10 +2826,21 @@
}
public final class ToggleButtonKt {
- method @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @androidx.compose.runtime.Composable public static void ElevatedToggleButton(boolean checked, kotlin.jvm.functions.Function1<? super java.lang.Boolean,kotlin.Unit> onCheckedChange, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional androidx.compose.material3.ButtonShapes shapes, optional androidx.compose.material3.ToggleButtonColors colors, optional androidx.compose.material3.ButtonElevation? elevation, optional androidx.compose.foundation.BorderStroke? border, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.RowScope,kotlin.Unit> content);
- method @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @androidx.compose.runtime.Composable public static void OutlinedToggleButton(boolean checked, kotlin.jvm.functions.Function1<? super java.lang.Boolean,kotlin.Unit> onCheckedChange, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional androidx.compose.material3.ButtonShapes shapes, optional androidx.compose.material3.ToggleButtonColors colors, optional androidx.compose.material3.ButtonElevation? elevation, optional androidx.compose.foundation.BorderStroke? border, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.RowScope,kotlin.Unit> content);
- method @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @androidx.compose.runtime.Composable public static void ToggleButton(boolean checked, kotlin.jvm.functions.Function1<? super java.lang.Boolean,kotlin.Unit> onCheckedChange, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional androidx.compose.material3.ButtonShapes shapes, optional androidx.compose.material3.ToggleButtonColors colors, optional androidx.compose.material3.ButtonElevation? elevation, optional androidx.compose.foundation.BorderStroke? border, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.RowScope,kotlin.Unit> content);
- method @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @androidx.compose.runtime.Composable public static void TonalToggleButton(boolean checked, kotlin.jvm.functions.Function1<? super java.lang.Boolean,kotlin.Unit> onCheckedChange, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional androidx.compose.material3.ButtonShapes shapes, optional androidx.compose.material3.ToggleButtonColors colors, optional androidx.compose.material3.ButtonElevation? elevation, optional androidx.compose.foundation.BorderStroke? border, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.RowScope,kotlin.Unit> content);
+ method @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @androidx.compose.runtime.Composable public static void ElevatedToggleButton(boolean checked, kotlin.jvm.functions.Function1<? super java.lang.Boolean,kotlin.Unit> onCheckedChange, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional androidx.compose.material3.ToggleButtonShapes shapes, optional androidx.compose.material3.ToggleButtonColors colors, optional androidx.compose.material3.ButtonElevation? elevation, optional androidx.compose.foundation.BorderStroke? border, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.RowScope,kotlin.Unit> content);
+ method @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @androidx.compose.runtime.Composable public static void OutlinedToggleButton(boolean checked, kotlin.jvm.functions.Function1<? super java.lang.Boolean,kotlin.Unit> onCheckedChange, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional androidx.compose.material3.ToggleButtonShapes shapes, optional androidx.compose.material3.ToggleButtonColors colors, optional androidx.compose.material3.ButtonElevation? elevation, optional androidx.compose.foundation.BorderStroke? border, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.RowScope,kotlin.Unit> content);
+ method @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @androidx.compose.runtime.Composable public static void ToggleButton(boolean checked, kotlin.jvm.functions.Function1<? super java.lang.Boolean,kotlin.Unit> onCheckedChange, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional androidx.compose.material3.ToggleButtonShapes shapes, optional androidx.compose.material3.ToggleButtonColors colors, optional androidx.compose.material3.ButtonElevation? elevation, optional androidx.compose.foundation.BorderStroke? border, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.RowScope,kotlin.Unit> content);
+ method @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @androidx.compose.runtime.Composable public static void TonalToggleButton(boolean checked, kotlin.jvm.functions.Function1<? super java.lang.Boolean,kotlin.Unit> onCheckedChange, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional androidx.compose.material3.ToggleButtonShapes shapes, optional androidx.compose.material3.ToggleButtonColors colors, optional androidx.compose.material3.ButtonElevation? elevation, optional androidx.compose.foundation.BorderStroke? border, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.RowScope,kotlin.Unit> content);
+ }
+
+ @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @androidx.compose.runtime.Immutable public final class ToggleButtonShapes {
+ ctor public ToggleButtonShapes(androidx.compose.ui.graphics.Shape shape, androidx.compose.ui.graphics.Shape pressedShape, androidx.compose.ui.graphics.Shape checkedShape);
+ method public androidx.compose.material3.ToggleButtonShapes copy(optional androidx.compose.ui.graphics.Shape? shape, optional androidx.compose.ui.graphics.Shape? pressedShape, optional androidx.compose.ui.graphics.Shape? checkedShape);
+ method public androidx.compose.ui.graphics.Shape getCheckedShape();
+ method public androidx.compose.ui.graphics.Shape getPressedShape();
+ method public androidx.compose.ui.graphics.Shape getShape();
+ property public final androidx.compose.ui.graphics.Shape checkedShape;
+ property public final androidx.compose.ui.graphics.Shape pressedShape;
+ property public final androidx.compose.ui.graphics.Shape shape;
}
@SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi public final class ToggleFloatingActionButtonDefaults {
diff --git a/compose/material3/material3/api/restricted_current.txt b/compose/material3/material3/api/restricted_current.txt
index a3501ba..273f29f 100644
--- a/compose/material3/material3/api/restricted_current.txt
+++ b/compose/material3/material3/api/restricted_current.txt
@@ -211,6 +211,7 @@
method public float getMinWidth();
method @Deprecated @androidx.compose.runtime.Composable public androidx.compose.foundation.BorderStroke getOutlinedButtonBorder();
method @androidx.compose.runtime.Composable public androidx.compose.ui.graphics.Shape getOutlinedShape();
+ method @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @androidx.compose.runtime.Composable public androidx.compose.ui.graphics.Shape getPressedShape();
method @androidx.compose.runtime.Composable public androidx.compose.ui.graphics.Shape getShape();
method public androidx.compose.foundation.layout.PaddingValues getSmallButtonContentPadding();
method @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @androidx.compose.runtime.Composable public androidx.compose.ui.graphics.Shape getSquareShape();
@@ -228,6 +229,7 @@
method @androidx.compose.runtime.Composable public androidx.compose.foundation.BorderStroke outlinedButtonBorder(optional boolean enabled);
method @androidx.compose.runtime.Composable public androidx.compose.material3.ButtonColors outlinedButtonColors();
method @androidx.compose.runtime.Composable public androidx.compose.material3.ButtonColors outlinedButtonColors(optional long containerColor, optional long contentColor, optional long disabledContainerColor, optional long disabledContentColor);
+ method @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @androidx.compose.runtime.Composable public androidx.compose.material3.ButtonShapes shapes();
method @androidx.compose.runtime.Composable public androidx.compose.material3.ButtonColors textButtonColors();
method @androidx.compose.runtime.Composable public androidx.compose.material3.ButtonColors textButtonColors(optional long containerColor, optional long contentColor, optional long disabledContainerColor, optional long disabledContentColor);
property public final androidx.compose.foundation.layout.PaddingValues ButtonWithIconContentPadding;
@@ -259,6 +261,7 @@
property @androidx.compose.runtime.Composable public final androidx.compose.ui.graphics.Shape filledTonalShape;
property @Deprecated @androidx.compose.runtime.Composable public final androidx.compose.foundation.BorderStroke outlinedButtonBorder;
property @androidx.compose.runtime.Composable public final androidx.compose.ui.graphics.Shape outlinedShape;
+ property @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @androidx.compose.runtime.Composable public final androidx.compose.ui.graphics.Shape pressedShape;
property @androidx.compose.runtime.Composable public final androidx.compose.ui.graphics.Shape shape;
property @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @androidx.compose.runtime.Composable public final androidx.compose.ui.graphics.Shape squareShape;
property @androidx.compose.runtime.Composable public final androidx.compose.ui.graphics.Shape textShape;
@@ -269,25 +272,25 @@
}
@SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi public final class ButtonGroupDefaults {
- method public float getAnimateFraction();
- method public androidx.compose.ui.graphics.Shape getConnectedLeadingButtonPressShape();
- method public androidx.compose.ui.graphics.Shape getConnectedLeadingButtonShape();
+ method @androidx.compose.runtime.Composable public androidx.compose.ui.graphics.Shape getConnectedLeadingButtonPressShape();
+ method @androidx.compose.runtime.Composable public androidx.compose.ui.graphics.Shape getConnectedLeadingButtonShape();
method public float getConnectedSpaceBetween();
- method public androidx.compose.ui.graphics.Shape getConnectedTrailingButtonPressShape();
- method public androidx.compose.ui.graphics.Shape getConnectedTrailingButtonShape();
- method public float getSpaceBetween();
- property public final float animateFraction;
- property public final androidx.compose.ui.graphics.Shape connectedLeadingButtonPressShape;
- property public final androidx.compose.ui.graphics.Shape connectedLeadingButtonShape;
- property public final float connectedSpaceBetween;
- property public final androidx.compose.ui.graphics.Shape connectedTrailingButtonPressShape;
- property public final androidx.compose.ui.graphics.Shape connectedTrailingButtonShape;
- property public final float spaceBetween;
+ method @androidx.compose.runtime.Composable public androidx.compose.ui.graphics.Shape getConnectedTrailingButtonPressShape();
+ method @androidx.compose.runtime.Composable public androidx.compose.ui.graphics.Shape getConnectedTrailingButtonShape();
+ method public float getExpandedRatio();
+ method public androidx.compose.foundation.layout.Arrangement.Horizontal getHorizontalArrangement();
+ property public final float ConnectedSpaceBetween;
+ property public final float ExpandedRatio;
+ property public final androidx.compose.foundation.layout.Arrangement.Horizontal HorizontalArrangement;
+ property @androidx.compose.runtime.Composable public final androidx.compose.ui.graphics.Shape connectedLeadingButtonPressShape;
+ property @androidx.compose.runtime.Composable public final androidx.compose.ui.graphics.Shape connectedLeadingButtonShape;
+ property @androidx.compose.runtime.Composable public final androidx.compose.ui.graphics.Shape connectedTrailingButtonPressShape;
+ property @androidx.compose.runtime.Composable public final androidx.compose.ui.graphics.Shape connectedTrailingButtonShape;
field public static final androidx.compose.material3.ButtonGroupDefaults INSTANCE;
}
public final class ButtonGroupKt {
- method @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @androidx.compose.runtime.Composable public static void ButtonGroup(optional androidx.compose.ui.Modifier modifier, optional @FloatRange(from=0.0) float animateFraction, optional androidx.compose.foundation.layout.Arrangement.Horizontal horizontalArrangement, kotlin.jvm.functions.Function1<? super androidx.compose.material3.ButtonGroupScope,kotlin.Unit> content);
+ method @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @androidx.compose.runtime.Composable public static void ButtonGroup(optional androidx.compose.ui.Modifier modifier, optional @FloatRange(from=0.0) float expandedRatio, optional androidx.compose.foundation.layout.Arrangement.Horizontal horizontalArrangement, kotlin.jvm.functions.Function1<? super androidx.compose.material3.ButtonGroupScope,kotlin.Unit> content);
}
@SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi public interface ButtonGroupScope {
@@ -295,20 +298,23 @@
}
public final class ButtonKt {
+ method @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @androidx.compose.runtime.Composable public static void Button(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, androidx.compose.material3.ButtonShapes shapes, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional androidx.compose.material3.ButtonColors colors, optional androidx.compose.material3.ButtonElevation? elevation, optional androidx.compose.foundation.BorderStroke? border, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.RowScope,kotlin.Unit> content);
method @androidx.compose.runtime.Composable public static void Button(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional androidx.compose.ui.graphics.Shape shape, optional androidx.compose.material3.ButtonColors colors, optional androidx.compose.material3.ButtonElevation? elevation, optional androidx.compose.foundation.BorderStroke? border, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.RowScope,kotlin.Unit> content);
+ method @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @androidx.compose.runtime.Composable public static void ElevatedButton(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, androidx.compose.material3.ButtonShapes shapes, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional androidx.compose.material3.ButtonColors colors, optional androidx.compose.material3.ButtonElevation? elevation, optional androidx.compose.foundation.BorderStroke? border, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.RowScope,kotlin.Unit> content);
method @androidx.compose.runtime.Composable public static void ElevatedButton(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional androidx.compose.ui.graphics.Shape shape, optional androidx.compose.material3.ButtonColors colors, optional androidx.compose.material3.ButtonElevation? elevation, optional androidx.compose.foundation.BorderStroke? border, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.RowScope,kotlin.Unit> content);
+ method @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @androidx.compose.runtime.Composable public static void FilledTonalButton(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, androidx.compose.material3.ButtonShapes shapes, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional androidx.compose.material3.ButtonColors colors, optional androidx.compose.material3.ButtonElevation? elevation, optional androidx.compose.foundation.BorderStroke? border, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.RowScope,kotlin.Unit> content);
method @androidx.compose.runtime.Composable public static void FilledTonalButton(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional androidx.compose.ui.graphics.Shape shape, optional androidx.compose.material3.ButtonColors colors, optional androidx.compose.material3.ButtonElevation? elevation, optional androidx.compose.foundation.BorderStroke? border, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.RowScope,kotlin.Unit> content);
+ method @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @androidx.compose.runtime.Composable public static void OutlinedButton(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, androidx.compose.material3.ButtonShapes shapes, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional androidx.compose.material3.ButtonColors colors, optional androidx.compose.material3.ButtonElevation? elevation, optional androidx.compose.foundation.BorderStroke? border, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.RowScope,kotlin.Unit> content);
method @androidx.compose.runtime.Composable public static void OutlinedButton(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional androidx.compose.ui.graphics.Shape shape, optional androidx.compose.material3.ButtonColors colors, optional androidx.compose.material3.ButtonElevation? elevation, optional androidx.compose.foundation.BorderStroke? border, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.RowScope,kotlin.Unit> content);
+ method @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @androidx.compose.runtime.Composable public static void TextButton(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, androidx.compose.material3.ButtonShapes shapes, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional androidx.compose.material3.ButtonColors colors, optional androidx.compose.material3.ButtonElevation? elevation, optional androidx.compose.foundation.BorderStroke? border, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.RowScope,kotlin.Unit> content);
method @androidx.compose.runtime.Composable public static void TextButton(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional androidx.compose.ui.graphics.Shape shape, optional androidx.compose.material3.ButtonColors colors, optional androidx.compose.material3.ButtonElevation? elevation, optional androidx.compose.foundation.BorderStroke? border, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.RowScope,kotlin.Unit> content);
}
- public final class ButtonShapes {
- ctor public ButtonShapes(androidx.compose.ui.graphics.Shape shape, androidx.compose.ui.graphics.Shape pressedShape, androidx.compose.ui.graphics.Shape checkedShape);
- method public androidx.compose.material3.ButtonShapes copy(optional androidx.compose.ui.graphics.Shape? shape, optional androidx.compose.ui.graphics.Shape? pressedShape, optional androidx.compose.ui.graphics.Shape? checkedShape);
- method public androidx.compose.ui.graphics.Shape getCheckedShape();
+ @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @androidx.compose.runtime.Immutable public final class ButtonShapes {
+ ctor public ButtonShapes(androidx.compose.ui.graphics.Shape shape, androidx.compose.ui.graphics.Shape pressedShape);
+ method public androidx.compose.material3.ButtonShapes copy(optional androidx.compose.ui.graphics.Shape? shape, optional androidx.compose.ui.graphics.Shape? pressedShape);
method public androidx.compose.ui.graphics.Shape getPressedShape();
method public androidx.compose.ui.graphics.Shape getShape();
- property public final androidx.compose.ui.graphics.Shape checkedShape;
property public final androidx.compose.ui.graphics.Shape pressedShape;
property public final androidx.compose.ui.graphics.Shape shape;
}
@@ -2206,10 +2212,12 @@
method public int getSteps();
method public float getValue();
method public kotlin.ranges.ClosedFloatingPointRange<java.lang.Float> getValueRange();
+ method public boolean isDragging();
method public void setOnValueChange(kotlin.jvm.functions.Function1<? super java.lang.Float,kotlin.Unit>?);
method public void setOnValueChangeFinished(kotlin.jvm.functions.Function0<kotlin.Unit>?);
method public void setValue(float);
property public final float coercedValueAsFraction;
+ property public final boolean isDragging;
property public final kotlin.jvm.functions.Function1<java.lang.Float,kotlin.Unit>? onValueChange;
property public final kotlin.jvm.functions.Function0<kotlin.Unit>? onValueChangeFinished;
property @IntRange(from=0L) public final int steps;
@@ -2787,8 +2795,8 @@
method @androidx.compose.runtime.Composable public androidx.compose.ui.graphics.Shape getXSmallSquareShape();
method @androidx.compose.runtime.Composable public androidx.compose.material3.ToggleButtonColors outlinedToggleButtonColors();
method @androidx.compose.runtime.Composable public androidx.compose.material3.ToggleButtonColors outlinedToggleButtonColors(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.ButtonShapes shapes();
- method @androidx.compose.runtime.Composable public androidx.compose.material3.ButtonShapes shapes(optional androidx.compose.ui.graphics.Shape? shape, optional androidx.compose.ui.graphics.Shape? pressedShape, optional androidx.compose.ui.graphics.Shape? checkedShape);
+ method @androidx.compose.runtime.Composable public androidx.compose.material3.ToggleButtonShapes shapes();
+ method @androidx.compose.runtime.Composable public androidx.compose.material3.ToggleButtonShapes shapes(optional androidx.compose.ui.graphics.Shape? shape, optional androidx.compose.ui.graphics.Shape? pressedShape, optional androidx.compose.ui.graphics.Shape? checkedShape);
method @androidx.compose.runtime.Composable public androidx.compose.material3.ToggleButtonColors toggleButtonColors();
method @androidx.compose.runtime.Composable public androidx.compose.material3.ToggleButtonColors toggleButtonColors(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.ToggleButtonColors tonalToggleButtonColors();
@@ -2818,10 +2826,21 @@
}
public final class ToggleButtonKt {
- method @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @androidx.compose.runtime.Composable public static void ElevatedToggleButton(boolean checked, kotlin.jvm.functions.Function1<? super java.lang.Boolean,kotlin.Unit> onCheckedChange, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional androidx.compose.material3.ButtonShapes shapes, optional androidx.compose.material3.ToggleButtonColors colors, optional androidx.compose.material3.ButtonElevation? elevation, optional androidx.compose.foundation.BorderStroke? border, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.RowScope,kotlin.Unit> content);
- method @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @androidx.compose.runtime.Composable public static void OutlinedToggleButton(boolean checked, kotlin.jvm.functions.Function1<? super java.lang.Boolean,kotlin.Unit> onCheckedChange, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional androidx.compose.material3.ButtonShapes shapes, optional androidx.compose.material3.ToggleButtonColors colors, optional androidx.compose.material3.ButtonElevation? elevation, optional androidx.compose.foundation.BorderStroke? border, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.RowScope,kotlin.Unit> content);
- method @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @androidx.compose.runtime.Composable public static void ToggleButton(boolean checked, kotlin.jvm.functions.Function1<? super java.lang.Boolean,kotlin.Unit> onCheckedChange, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional androidx.compose.material3.ButtonShapes shapes, optional androidx.compose.material3.ToggleButtonColors colors, optional androidx.compose.material3.ButtonElevation? elevation, optional androidx.compose.foundation.BorderStroke? border, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.RowScope,kotlin.Unit> content);
- method @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @androidx.compose.runtime.Composable public static void TonalToggleButton(boolean checked, kotlin.jvm.functions.Function1<? super java.lang.Boolean,kotlin.Unit> onCheckedChange, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional androidx.compose.material3.ButtonShapes shapes, optional androidx.compose.material3.ToggleButtonColors colors, optional androidx.compose.material3.ButtonElevation? elevation, optional androidx.compose.foundation.BorderStroke? border, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.RowScope,kotlin.Unit> content);
+ method @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @androidx.compose.runtime.Composable public static void ElevatedToggleButton(boolean checked, kotlin.jvm.functions.Function1<? super java.lang.Boolean,kotlin.Unit> onCheckedChange, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional androidx.compose.material3.ToggleButtonShapes shapes, optional androidx.compose.material3.ToggleButtonColors colors, optional androidx.compose.material3.ButtonElevation? elevation, optional androidx.compose.foundation.BorderStroke? border, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.RowScope,kotlin.Unit> content);
+ method @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @androidx.compose.runtime.Composable public static void OutlinedToggleButton(boolean checked, kotlin.jvm.functions.Function1<? super java.lang.Boolean,kotlin.Unit> onCheckedChange, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional androidx.compose.material3.ToggleButtonShapes shapes, optional androidx.compose.material3.ToggleButtonColors colors, optional androidx.compose.material3.ButtonElevation? elevation, optional androidx.compose.foundation.BorderStroke? border, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.RowScope,kotlin.Unit> content);
+ method @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @androidx.compose.runtime.Composable public static void ToggleButton(boolean checked, kotlin.jvm.functions.Function1<? super java.lang.Boolean,kotlin.Unit> onCheckedChange, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional androidx.compose.material3.ToggleButtonShapes shapes, optional androidx.compose.material3.ToggleButtonColors colors, optional androidx.compose.material3.ButtonElevation? elevation, optional androidx.compose.foundation.BorderStroke? border, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.RowScope,kotlin.Unit> content);
+ method @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @androidx.compose.runtime.Composable public static void TonalToggleButton(boolean checked, kotlin.jvm.functions.Function1<? super java.lang.Boolean,kotlin.Unit> onCheckedChange, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional androidx.compose.material3.ToggleButtonShapes shapes, optional androidx.compose.material3.ToggleButtonColors colors, optional androidx.compose.material3.ButtonElevation? elevation, optional androidx.compose.foundation.BorderStroke? border, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.RowScope,kotlin.Unit> content);
+ }
+
+ @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @androidx.compose.runtime.Immutable public final class ToggleButtonShapes {
+ ctor public ToggleButtonShapes(androidx.compose.ui.graphics.Shape shape, androidx.compose.ui.graphics.Shape pressedShape, androidx.compose.ui.graphics.Shape checkedShape);
+ method public androidx.compose.material3.ToggleButtonShapes copy(optional androidx.compose.ui.graphics.Shape? shape, optional androidx.compose.ui.graphics.Shape? pressedShape, optional androidx.compose.ui.graphics.Shape? checkedShape);
+ method public androidx.compose.ui.graphics.Shape getCheckedShape();
+ method public androidx.compose.ui.graphics.Shape getPressedShape();
+ method public androidx.compose.ui.graphics.Shape getShape();
+ property public final androidx.compose.ui.graphics.Shape checkedShape;
+ property public final androidx.compose.ui.graphics.Shape pressedShape;
+ property public final androidx.compose.ui.graphics.Shape shape;
}
@SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi public final class ToggleFloatingActionButtonDefaults {
diff --git a/compose/material3/material3/integration-tests/material3-catalog/src/main/java/androidx/compose/material3/catalog/library/model/Examples.kt b/compose/material3/material3/integration-tests/material3-catalog/src/main/java/androidx/compose/material3/catalog/library/model/Examples.kt
index 6d3b4e5..6ab1a76 100644
--- a/compose/material3/material3/integration-tests/material3-catalog/src/main/java/androidx/compose/material3/catalog/library/model/Examples.kt
+++ b/compose/material3/material3/integration-tests/material3-catalog/src/main/java/androidx/compose/material3/catalog/library/model/Examples.kt
@@ -38,6 +38,7 @@
import androidx.compose.material3.samples.BottomSheetScaffoldNestedScrollSample
import androidx.compose.material3.samples.ButtonGroupSample
import androidx.compose.material3.samples.ButtonSample
+import androidx.compose.material3.samples.ButtonWithAnimatedShapeSample
import androidx.compose.material3.samples.ButtonWithIconSample
import androidx.compose.material3.samples.CardSample
import androidx.compose.material3.samples.CenteredHorizontalFloatingToolbarWithFabSample
@@ -67,6 +68,7 @@
import androidx.compose.material3.samples.EditableExposedDropdownMenuSample
import androidx.compose.material3.samples.ElevatedAssistChipSample
import androidx.compose.material3.samples.ElevatedButtonSample
+import androidx.compose.material3.samples.ElevatedButtonWithAnimatedShapeSample
import androidx.compose.material3.samples.ElevatedCardSample
import androidx.compose.material3.samples.ElevatedFilterChipSample
import androidx.compose.material3.samples.ElevatedSplitButtonSample
@@ -96,6 +98,7 @@
import androidx.compose.material3.samples.FilledIconToggleButtonSample
import androidx.compose.material3.samples.FilledSplitButtonSample
import androidx.compose.material3.samples.FilledTonalButtonSample
+import androidx.compose.material3.samples.FilledTonalButtonWithAnimatedShapeSample
import androidx.compose.material3.samples.FilledTonalIconButtonSample
import androidx.compose.material3.samples.FilledTonalIconToggleButtonSample
import androidx.compose.material3.samples.FilterChipSample
@@ -148,6 +151,7 @@
import androidx.compose.material3.samples.NavigationRailWithOnlySelectedLabelsSample
import androidx.compose.material3.samples.OneLineListItem
import androidx.compose.material3.samples.OutlinedButtonSample
+import androidx.compose.material3.samples.OutlinedButtonWithAnimatedShapeSample
import androidx.compose.material3.samples.OutlinedCardSample
import androidx.compose.material3.samples.OutlinedIconButtonSample
import androidx.compose.material3.samples.OutlinedIconToggleButtonSample
@@ -225,6 +229,7 @@
import androidx.compose.material3.samples.TextAndIconTabs
import androidx.compose.material3.samples.TextArea
import androidx.compose.material3.samples.TextButtonSample
+import androidx.compose.material3.samples.TextButtonWithAnimatedShapeSample
import androidx.compose.material3.samples.TextFieldWithErrorState
import androidx.compose.material3.samples.TextFieldWithHideKeyboardOnImeAction
import androidx.compose.material3.samples.TextFieldWithIcons
@@ -346,6 +351,13 @@
ButtonSample()
},
Example(
+ name = "ButtonWithAnimatedShapeSample",
+ description = ButtonsExampleDescription,
+ sourceUrl = ButtonsExampleSourceUrl,
+ ) {
+ ButtonWithAnimatedShapeSample()
+ },
+ Example(
name = "SquareButtonSample",
description = ButtonsExampleDescription,
sourceUrl = ButtonsExampleSourceUrl,
@@ -367,6 +379,13 @@
ElevatedButtonSample()
},
Example(
+ name = "ElevatedButtonWithAnimatedShapeSample",
+ description = ButtonsExampleDescription,
+ sourceUrl = ButtonsExampleSourceUrl,
+ ) {
+ ElevatedButtonWithAnimatedShapeSample()
+ },
+ Example(
name = "FilledTonalButtonSample",
description = ButtonsExampleDescription,
sourceUrl = ButtonsExampleSourceUrl,
@@ -374,6 +393,13 @@
FilledTonalButtonSample()
},
Example(
+ name = "FilledTonalButtonWithAnimatedShapeSample",
+ description = ButtonsExampleDescription,
+ sourceUrl = ButtonsExampleSourceUrl,
+ ) {
+ FilledTonalButtonWithAnimatedShapeSample()
+ },
+ Example(
name = "OutlinedButtonSample",
description = ButtonsExampleDescription,
sourceUrl = ButtonsExampleSourceUrl,
@@ -381,6 +407,13 @@
OutlinedButtonSample()
},
Example(
+ name = "OutlinedButtonWithAnimatedShapeSample",
+ description = ButtonsExampleDescription,
+ sourceUrl = ButtonsExampleSourceUrl,
+ ) {
+ OutlinedButtonWithAnimatedShapeSample()
+ },
+ Example(
name = "TextButtonSample",
description = ButtonsExampleDescription,
sourceUrl = ButtonsExampleSourceUrl,
@@ -388,6 +421,13 @@
TextButtonSample()
},
Example(
+ name = "TextButtonWithAnimatedShapeSample",
+ description = ButtonsExampleDescription,
+ sourceUrl = ButtonsExampleSourceUrl,
+ ) {
+ TextButtonWithAnimatedShapeSample()
+ },
+ Example(
name = "ButtonWithIconSample",
description = ButtonsExampleDescription,
sourceUrl = ButtonsExampleSourceUrl,
diff --git a/compose/material3/material3/lint-baseline.xml b/compose/material3/material3/lint-baseline.xml
index 36d385f..2c56cb3 100644
--- a/compose/material3/material3/lint-baseline.xml
+++ b/compose/material3/material3/lint-baseline.xml
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
-<issues format="6" by="lint 8.7.0-alpha02" type="baseline" client="gradle" dependencies="false" name="AGP (8.7.0-alpha02)" variant="all" version="8.7.0-alpha02">
+<issues format="6" by="lint 8.9.0-alpha06" type="baseline" client="gradle" dependencies="false" name="AGP (8.9.0-alpha06)" variant="all" version="8.9.0-alpha06">
<issue
id="BanThreadSleep"
@@ -52,6 +52,96 @@
errorLine1=" Thread.sleep(300)"
errorLine2=" ~~~~~">
<location
+ file="src/androidInstrumentedTest/kotlin/androidx/compose/material3/ButtonScreenshotTest.kt"/>
+ </issue>
+
+ <issue
+ id="BanThreadSleep"
+ message="Uses Thread.sleep()"
+ errorLine1=" Thread.sleep(300)"
+ errorLine2=" ~~~~~">
+ <location
+ file="src/androidInstrumentedTest/kotlin/androidx/compose/material3/ButtonScreenshotTest.kt"/>
+ </issue>
+
+ <issue
+ id="BanThreadSleep"
+ message="Uses Thread.sleep()"
+ errorLine1=" Thread.sleep(300)"
+ errorLine2=" ~~~~~">
+ <location
+ file="src/androidInstrumentedTest/kotlin/androidx/compose/material3/ButtonScreenshotTest.kt"/>
+ </issue>
+
+ <issue
+ id="BanThreadSleep"
+ message="Uses Thread.sleep()"
+ errorLine1=" Thread.sleep(300)"
+ errorLine2=" ~~~~~">
+ <location
+ file="src/androidInstrumentedTest/kotlin/androidx/compose/material3/ButtonScreenshotTest.kt"/>
+ </issue>
+
+ <issue
+ id="BanThreadSleep"
+ message="Uses Thread.sleep()"
+ errorLine1=" Thread.sleep(300)"
+ errorLine2=" ~~~~~">
+ <location
+ file="src/androidInstrumentedTest/kotlin/androidx/compose/material3/ButtonScreenshotTest.kt"/>
+ </issue>
+
+ <issue
+ id="BanThreadSleep"
+ message="Uses Thread.sleep()"
+ errorLine1=" Thread.sleep(300)"
+ errorLine2=" ~~~~~">
+ <location
+ file="src/androidInstrumentedTest/kotlin/androidx/compose/material3/ButtonScreenshotTest.kt"/>
+ </issue>
+
+ <issue
+ id="BanThreadSleep"
+ message="Uses Thread.sleep()"
+ errorLine1=" Thread.sleep(300)"
+ errorLine2=" ~~~~~">
+ <location
+ file="src/androidInstrumentedTest/kotlin/androidx/compose/material3/ButtonScreenshotTest.kt"/>
+ </issue>
+
+ <issue
+ id="BanThreadSleep"
+ message="Uses Thread.sleep()"
+ errorLine1=" Thread.sleep(300)"
+ errorLine2=" ~~~~~">
+ <location
+ file="src/androidInstrumentedTest/kotlin/androidx/compose/material3/ButtonScreenshotTest.kt"/>
+ </issue>
+
+ <issue
+ id="BanThreadSleep"
+ message="Uses Thread.sleep()"
+ errorLine1=" Thread.sleep(300)"
+ errorLine2=" ~~~~~">
+ <location
+ file="src/androidInstrumentedTest/kotlin/androidx/compose/material3/ButtonScreenshotTest.kt"/>
+ </issue>
+
+ <issue
+ id="BanThreadSleep"
+ message="Uses Thread.sleep()"
+ errorLine1=" Thread.sleep(300)"
+ errorLine2=" ~~~~~">
+ <location
+ file="src/androidInstrumentedTest/kotlin/androidx/compose/material3/ButtonScreenshotTest.kt"/>
+ </issue>
+
+ <issue
+ id="BanThreadSleep"
+ message="Uses Thread.sleep()"
+ errorLine1=" Thread.sleep(300)"
+ errorLine2=" ~~~~~">
+ <location
file="src/androidInstrumentedTest/kotlin/androidx/compose/material3/CardScreenshotTest.kt"/>
</issue>
diff --git a/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/ButtonGroupSamples.kt b/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/ButtonGroupSamples.kt
index d9aa02d..aedab65 100644
--- a/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/ButtonGroupSamples.kt
+++ b/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/ButtonGroupSamples.kt
@@ -31,13 +31,13 @@
import androidx.compose.material.icons.outlined.Work
import androidx.compose.material3.ButtonGroup
import androidx.compose.material3.ButtonGroupDefaults
-import androidx.compose.material3.ButtonShapes
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.ShapeDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.ToggleButton
import androidx.compose.material3.ToggleButtonDefaults
+import androidx.compose.material3.ToggleButtonShapes
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
@@ -81,19 +81,19 @@
@Composable
fun SingleSelectConnectedButtonGroupSample() {
val startButtonShapes =
- ButtonShapes(
+ ToggleButtonShapes(
shape = ButtonGroupDefaults.connectedLeadingButtonShape,
pressedShape = ButtonGroupDefaults.connectedLeadingButtonPressShape,
checkedShape = ToggleButtonDefaults.checkedShape
)
val middleButtonShapes =
- ButtonShapes(
+ ToggleButtonShapes(
shape = ShapeDefaults.Small,
pressedShape = ToggleButtonDefaults.pressedShape,
checkedShape = ToggleButtonDefaults.checkedShape
)
val endButtonShapes =
- ButtonShapes(
+ ToggleButtonShapes(
shape = ButtonGroupDefaults.connectedTrailingButtonShape,
pressedShape = ButtonGroupDefaults.connectedTrailingButtonPressShape,
checkedShape = ToggleButtonDefaults.checkedShape
@@ -107,8 +107,8 @@
ButtonGroup(
modifier = Modifier.padding(horizontal = 8.dp),
- horizontalArrangement = Arrangement.spacedBy(ButtonGroupDefaults.connectedSpaceBetween),
- animateFraction = 0f
+ horizontalArrangement = Arrangement.spacedBy(ButtonGroupDefaults.ConnectedSpaceBetween),
+ expandedRatio = 0f
) {
options.forEachIndexed { index, label ->
ToggleButton(
@@ -132,19 +132,19 @@
@Composable
fun MultiSelectConnectedButtonGroupSample() {
val startButtonShapes =
- ButtonShapes(
+ ToggleButtonShapes(
shape = ButtonGroupDefaults.connectedLeadingButtonShape,
pressedShape = ButtonGroupDefaults.connectedLeadingButtonPressShape,
checkedShape = ToggleButtonDefaults.checkedShape
)
val middleButtonShapes =
- ButtonShapes(
+ ToggleButtonShapes(
shape = ShapeDefaults.Small,
pressedShape = ToggleButtonDefaults.pressedShape,
checkedShape = ToggleButtonDefaults.checkedShape
)
val endButtonShapes =
- ButtonShapes(
+ ToggleButtonShapes(
shape = ButtonGroupDefaults.connectedTrailingButtonShape,
pressedShape = ButtonGroupDefaults.connectedTrailingButtonPressShape,
checkedShape = ToggleButtonDefaults.checkedShape
@@ -158,8 +158,8 @@
ButtonGroup(
modifier = Modifier.padding(horizontal = 8.dp),
- horizontalArrangement = Arrangement.spacedBy(ButtonGroupDefaults.connectedSpaceBetween),
- animateFraction = 0f
+ horizontalArrangement = Arrangement.spacedBy(ButtonGroupDefaults.ConnectedSpaceBetween),
+ expandedRatio = 0f
) {
options.forEachIndexed { index, label ->
ToggleButton(
diff --git a/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/ButtonSamples.kt b/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/ButtonSamples.kt
index 3b04b270..3f92b08 100644
--- a/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/ButtonSamples.kt
+++ b/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/ButtonSamples.kt
@@ -41,7 +41,15 @@
@Sampled
@Composable
fun ButtonSample() {
- Button(onClick = { /* Do something! */ }) { Text("Button") }
+ Button(onClick = {}) { Text("Button") }
+}
+
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Preview
+@Sampled
+@Composable
+fun ButtonWithAnimatedShapeSample() {
+ Button(onClick = {}, shapes = ButtonDefaults.shapes()) { Text("Button") }
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@@ -72,6 +80,14 @@
ElevatedButton(onClick = { /* Do something! */ }) { Text("Elevated Button") }
}
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Preview
+@Sampled
+@Composable
+fun ElevatedButtonWithAnimatedShapeSample() {
+ ElevatedButton(onClick = {}, shapes = ButtonDefaults.shapes()) { Text("Elevated Button") }
+}
+
@Preview
@Sampled
@Composable
@@ -79,6 +95,16 @@
FilledTonalButton(onClick = { /* Do something! */ }) { Text("Filled Tonal Button") }
}
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Preview
+@Sampled
+@Composable
+fun FilledTonalButtonWithAnimatedShapeSample() {
+ FilledTonalButton(onClick = {}, shapes = ButtonDefaults.shapes()) {
+ Text("Filled Tonal Button")
+ }
+}
+
@Preview
@Sampled
@Composable
@@ -86,6 +112,14 @@
OutlinedButton(onClick = { /* Do something! */ }) { Text("Outlined Button") }
}
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Preview
+@Sampled
+@Composable
+fun OutlinedButtonWithAnimatedShapeSample() {
+ OutlinedButton(onClick = {}, shapes = ButtonDefaults.shapes()) { Text("Outlined Button") }
+}
+
@Preview
@Sampled
@Composable
@@ -93,6 +127,14 @@
TextButton(onClick = { /* Do something! */ }) { Text("Text Button") }
}
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Preview
+@Sampled
+@Composable
+fun TextButtonWithAnimatedShapeSample() {
+ TextButton(onClick = {}, shapes = ButtonDefaults.shapes()) { Text("Text Button") }
+}
+
@Preview
@Sampled
@Composable
diff --git a/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/SliderSamples.kt b/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/SliderSamples.kt
index 5a8230df..a71addb 100644
--- a/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/SliderSamples.kt
+++ b/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/SliderSamples.kt
@@ -17,6 +17,7 @@
package androidx.compose.material3.samples
import androidx.annotation.Sampled
+import androidx.compose.animation.core.animate
import androidx.compose.foundation.indication
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Column
@@ -39,6 +40,7 @@
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.Label
+import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PlainTooltip
import androidx.compose.material3.RangeSlider
import androidx.compose.material3.RangeSliderState
@@ -50,8 +52,10 @@
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -69,6 +73,8 @@
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import kotlin.math.roundToInt
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.launch
@Preview
@Sampled
@@ -308,6 +314,7 @@
@Sampled
@Composable
fun VerticalSliderSample() {
+ val coroutineScope = rememberCoroutineScope()
val sliderState = remember {
SliderState(
valueRange = 0f..100f,
@@ -317,6 +324,29 @@
}
)
}
+ val snapAnimationSpec = MaterialTheme.motionScheme.fastEffectsSpec<Float>()
+ var currentValue by remember { mutableFloatStateOf(sliderState.value) }
+ var animateJob: Job? by remember { mutableStateOf(null) }
+ sliderState.onValueChange = { newValue ->
+ currentValue = newValue
+ // only update the sliderState instantly if dragging
+ if (sliderState.isDragging) {
+ animateJob?.cancel()
+ sliderState.value = newValue
+ }
+ }
+ sliderState.onValueChangeFinished = {
+ animateJob =
+ coroutineScope.launch {
+ animate(
+ initialValue = sliderState.value,
+ targetValue = currentValue,
+ animationSpec = snapAnimationSpec
+ ) { value, _ ->
+ sliderState.value = value
+ }
+ }
+ }
val interactionSource = remember { MutableInteractionSource() }
Column(modifier = Modifier.padding(horizontal = 16.dp)) {
Text(
diff --git a/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/TimePickerSamples.kt b/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/TimePickerSamples.kt
index 98b620c..b051fcd 100644
--- a/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/TimePickerSamples.kt
+++ b/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/TimePickerSamples.kt
@@ -222,7 +222,6 @@
tonalElevation = 6.dp,
modifier =
Modifier.width(IntrinsicSize.Min)
- .height(IntrinsicSize.Min)
.background(
shape = MaterialTheme.shapes.extraLarge,
color = MaterialTheme.colorScheme.surface
diff --git a/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/ToggleButtonSamples.kt b/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/ToggleButtonSamples.kt
index 5006587..49b797d 100644
--- a/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/ToggleButtonSamples.kt
+++ b/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/ToggleButtonSamples.kt
@@ -24,7 +24,6 @@
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material3.ButtonDefaults
-import androidx.compose.material3.ButtonShapes
import androidx.compose.material3.ElevatedToggleButton
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
@@ -33,6 +32,7 @@
import androidx.compose.material3.Text
import androidx.compose.material3.ToggleButton
import androidx.compose.material3.ToggleButtonDefaults
+import androidx.compose.material3.ToggleButtonShapes
import androidx.compose.material3.TonalToggleButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -58,7 +58,7 @@
fun SquareToggleButtonSample() {
var checked by remember { mutableStateOf(false) }
val shapes =
- ButtonShapes(
+ ToggleButtonShapes(
shape = ToggleButtonDefaults.squareShape,
pressedShape = ToggleButtonDefaults.pressedShape,
checkedShape = ToggleButtonDefaults.roundShape
diff --git a/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/ButtonGroupTest.kt b/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/ButtonGroupTest.kt
index fb0b3e3..156c847 100644
--- a/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/ButtonGroupTest.kt
+++ b/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/ButtonGroupTest.kt
@@ -101,9 +101,9 @@
@Test
fun default_firstPressed_buttonSizing() {
val width = 75.dp
- val animateFraction = 0.15f
- val expectedExpandWidth = width + (width * animateFraction)
- val expectedCompressWidth = width - (width * animateFraction)
+ val expandedWeight = 0.15f
+ val expectedExpandWidth = width + (width * expandedWeight)
+ val expectedCompressWidth = width - (width * expandedWeight)
rule.setMaterialContent(lightColorScheme()) {
Box(Modifier.testTag(wrapperTestTag)) {
@@ -147,9 +147,9 @@
@Test
fun default_secondPressed_buttonSizing() {
val width = 75.dp
- val animateFraction = 0.15f
- val expectedExpandWidth = width + (width * animateFraction)
- val expectedCompressWidth = width - (width * (animateFraction / 2f))
+ val expandedWeight = 0.15f
+ val expectedExpandWidth = width + (width * expandedWeight)
+ val expectedCompressWidth = width - (width * (expandedWeight / 2f))
rule.setMaterialContent(lightColorScheme()) {
Box(Modifier.testTag(wrapperTestTag)) {
@@ -193,9 +193,9 @@
@Test
fun default_thirdPressed_buttonSizing() {
val width = 75.dp
- val animateFraction = 0.15f
- val expectedExpandWidth = width + (width * animateFraction)
- val expectedCompressWidth = width - (width * (animateFraction / 2f))
+ val expandedWeight = 0.15f
+ val expectedExpandWidth = width + (width * expandedWeight)
+ val expectedCompressWidth = width - (width * (expandedWeight / 2f))
rule.setMaterialContent(lightColorScheme()) {
Box(Modifier.testTag(wrapperTestTag)) {
@@ -239,9 +239,9 @@
@Test
fun default_fourthPressed_buttonSizing() {
val width = 75.dp
- val animateFraction = 0.15f
- val expectedExpandWidth = width + (width * animateFraction)
- val expectedCompressWidth = width - (width * animateFraction)
+ val expandedWeight = 0.15f
+ val expectedExpandWidth = width + (width * expandedWeight)
+ val expectedCompressWidth = width - (width * expandedWeight)
rule.setMaterialContent(lightColorScheme()) {
Box(Modifier.testTag(wrapperTestTag)) {
@@ -285,13 +285,13 @@
@Test
fun customAnimateFraction_firstPressed_buttonSizing() {
val width = 75.dp
- val animateFraction = 0.3f
- val expectedExpandWidth = width + (width * animateFraction)
- val expectedCompressWidth = width - (width * animateFraction)
+ val expandedWeight = 0.3f
+ val expectedExpandWidth = width + (width * expandedWeight)
+ val expectedCompressWidth = width - (width * expandedWeight)
rule.setMaterialContent(lightColorScheme()) {
Box(Modifier.testTag(wrapperTestTag)) {
- ButtonGroup(animateFraction = animateFraction) {
+ ButtonGroup(expandedRatio = expandedWeight) {
Button(modifier = Modifier.width(width).testTag(aButton), onClick = {}) {
Text("A")
}
@@ -331,13 +331,13 @@
@Test
fun customAnimateFraction_secondPressed_buttonSizing() {
val width = 75.dp
- val animateFraction = 0.3f
- val expectedExpandWidth = width + (width * animateFraction)
- val expectedCompressWidth = width - (width * animateFraction / 2f)
+ val expandedWeight = 0.3f
+ val expectedExpandWidth = width + (width * expandedWeight)
+ val expectedCompressWidth = width - (width * expandedWeight / 2f)
rule.setMaterialContent(lightColorScheme()) {
Box(Modifier.testTag(wrapperTestTag)) {
- ButtonGroup(animateFraction = animateFraction) {
+ ButtonGroup(expandedRatio = expandedWeight) {
Button(modifier = Modifier.width(width).testTag(aButton), onClick = {}) {
Text("A")
}
@@ -377,13 +377,13 @@
@Test
fun customAnimateFraction_thirdPressed_buttonSizing() {
val width = 75.dp
- val animateFraction = 0.3f
- val expectedExpandWidth = width + (width * animateFraction)
- val expectedCompressWidth = width - (width * animateFraction / 2f)
+ val expandedWeight = 0.3f
+ val expectedExpandWidth = width + (width * expandedWeight)
+ val expectedCompressWidth = width - (width * expandedWeight / 2f)
rule.setMaterialContent(lightColorScheme()) {
Box(Modifier.testTag(wrapperTestTag)) {
- ButtonGroup(animateFraction = animateFraction) {
+ ButtonGroup(expandedRatio = expandedWeight) {
Button(modifier = Modifier.width(width).testTag(aButton), onClick = {}) {
Text("A")
}
@@ -423,13 +423,13 @@
@Test
fun customAnimateFraction_fourthPressed_buttonSizing() {
val width = 75.dp
- val animateFraction = 0.3f
- val expectedExpandWidth = width + (width * animateFraction)
- val expectedCompressWidth = width - (width * animateFraction)
+ val expandedWeight = 0.3f
+ val expectedExpandWidth = width + (width * expandedWeight)
+ val expectedCompressWidth = width - (width * expandedWeight)
rule.setMaterialContent(lightColorScheme()) {
Box(Modifier.testTag(wrapperTestTag)) {
- ButtonGroup(animateFraction = animateFraction) {
+ ButtonGroup(expandedRatio = expandedWeight) {
Button(modifier = Modifier.width(width).testTag(aButton), onClick = {}) {
Text("A")
}
diff --git a/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/ButtonScreenshotTest.kt b/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/ButtonScreenshotTest.kt
index c192fc5..4f2f782 100644
--- a/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/ButtonScreenshotTest.kt
+++ b/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/ButtonScreenshotTest.kt
@@ -31,6 +31,7 @@
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
@@ -49,6 +50,8 @@
@get:Rule val screenshotRule = AndroidXScreenshotTestRule(GOLDEN_MATERIAL3)
+ private val buttonTestTag = "button"
+
@Test
fun default_button_light_theme() {
rule.setMaterialContent(lightColorScheme()) { Button(onClick = {}) { Text("Button") } }
@@ -143,14 +146,14 @@
FilledTonalButton(
onClick = {},
enabled = false,
- modifier = Modifier.testTag("button")
+ modifier = Modifier.testTag(buttonTestTag)
) {
Text("Filled tonal Button")
}
}
rule
- .onNodeWithTag("button")
+ .onNodeWithTag(buttonTestTag)
.captureToImage()
.assertAgainstGolden(screenshotRule, "filled_tonal_button_disabled_light_theme")
}
@@ -170,13 +173,17 @@
@Test
fun disabled_outlined_button_lightTheme() {
rule.setMaterialContent(lightColorScheme()) {
- OutlinedButton(onClick = {}, enabled = false, modifier = Modifier.testTag("button")) {
+ OutlinedButton(
+ onClick = {},
+ enabled = false,
+ modifier = Modifier.testTag(buttonTestTag)
+ ) {
Text("Outlined Button")
}
}
rule
- .onNodeWithTag("button")
+ .onNodeWithTag(buttonTestTag)
.captureToImage()
.assertAgainstGolden(screenshotRule, "outlined_button_disabled_light_theme")
}
@@ -196,13 +203,13 @@
@Test
fun disabled_text_button_lightTheme() {
rule.setMaterialContent(lightColorScheme()) {
- TextButton(onClick = {}, enabled = false, modifier = Modifier.testTag("button")) {
+ TextButton(onClick = {}, enabled = false, modifier = Modifier.testTag(buttonTestTag)) {
Text("Text Button")
}
}
rule
- .onNodeWithTag("button")
+ .onNodeWithTag(buttonTestTag)
.captureToImage()
.assertAgainstGolden(screenshotRule, "text_button_disabled_light_theme")
}
@@ -237,7 +244,7 @@
onClick = { /* Do something! */ },
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
enabled = false,
- modifier = Modifier.testTag("button")
+ modifier = Modifier.testTag(buttonTestTag)
) {
Icon(
Icons.Filled.Favorite,
@@ -250,7 +257,7 @@
}
rule
- .onNodeWithTag("button")
+ .onNodeWithTag(buttonTestTag)
.captureToImage()
.assertAgainstGolden(screenshotRule, "button_withIcon_disabled_lightTheme")
}
@@ -285,7 +292,7 @@
onClick = { /* Do something! */ },
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
enabled = false,
- modifier = Modifier.testTag("button")
+ modifier = Modifier.testTag(buttonTestTag)
) {
Icon(
Icons.Filled.Favorite,
@@ -298,8 +305,534 @@
}
rule
- .onNodeWithTag("button")
+ .onNodeWithTag(buttonTestTag)
.captureToImage()
.assertAgainstGolden(screenshotRule, "button_withIcon_disabled_darkTheme")
}
+
+ @OptIn(ExperimentalMaterial3ExpressiveApi::class)
+ @Test
+ fun button_withAnimatedShape_default_lightTheme() {
+ rule.setMaterialContent(lightColorScheme()) {
+ Button(
+ onClick = {},
+ shapes = ButtonDefaults.shapes(),
+ modifier = Modifier.testTag(buttonTestTag)
+ ) {
+ Text("Button")
+ }
+ }
+
+ rule
+ .onNodeWithTag(buttonTestTag)
+ .captureToImage()
+ .assertAgainstGolden(screenshotRule, "button_withAnimatedShape_default_lightTheme")
+ }
+
+ @OptIn(ExperimentalMaterial3ExpressiveApi::class)
+ @Test
+ fun button_withAnimatedShape_default_darkTheme() {
+ rule.setMaterialContent(darkColorScheme()) {
+ Button(
+ onClick = {},
+ shapes = ButtonDefaults.shapes(),
+ modifier = Modifier.testTag(buttonTestTag)
+ ) {
+ Text("Button")
+ }
+ }
+
+ rule
+ .onNodeWithTag(buttonTestTag)
+ .captureToImage()
+ .assertAgainstGolden(screenshotRule, "button_withAnimatedShape_default_darkTheme")
+ }
+
+ @OptIn(ExperimentalMaterial3ExpressiveApi::class)
+ @Test
+ fun elevatedButton_withAnimatedShape_default_lightTheme() {
+ rule.setMaterialContent(lightColorScheme()) {
+ ElevatedButton(
+ onClick = {},
+ shapes = ButtonDefaults.shapes(),
+ modifier = Modifier.testTag(buttonTestTag)
+ ) {
+ Text("Button")
+ }
+ }
+
+ rule
+ .onNodeWithTag(buttonTestTag)
+ .captureToImage()
+ .assertAgainstGolden(
+ screenshotRule,
+ "elevatedButton_withAnimatedShape_default_lightTheme"
+ )
+ }
+
+ @OptIn(ExperimentalMaterial3ExpressiveApi::class)
+ @Test
+ fun elevatedButton_withAnimatedShape_default_darkTheme() {
+ rule.setMaterialContent(darkColorScheme()) {
+ ElevatedButton(
+ onClick = {},
+ shapes = ButtonDefaults.shapes(),
+ modifier = Modifier.testTag(buttonTestTag)
+ ) {
+ Text("Button")
+ }
+ }
+
+ rule
+ .onNodeWithTag(buttonTestTag)
+ .captureToImage()
+ .assertAgainstGolden(
+ screenshotRule,
+ "elevatedButton_withAnimatedShape_default_darkTheme"
+ )
+ }
+
+ @OptIn(ExperimentalMaterial3ExpressiveApi::class)
+ @Test
+ fun filledTonalButton_withAnimatedShape_default_lightTheme() {
+ rule.setMaterialContent(lightColorScheme()) {
+ FilledTonalButton(
+ onClick = {},
+ shapes = ButtonDefaults.shapes(),
+ modifier = Modifier.testTag(buttonTestTag)
+ ) {
+ Text("Button")
+ }
+ }
+
+ rule
+ .onNodeWithTag(buttonTestTag)
+ .captureToImage()
+ .assertAgainstGolden(
+ screenshotRule,
+ "filledTonalButton_withAnimatedShape_default_lightTheme"
+ )
+ }
+
+ @OptIn(ExperimentalMaterial3ExpressiveApi::class)
+ @Test
+ fun filledTonalButton_withAnimatedShape_default_darkTheme() {
+ rule.setMaterialContent(darkColorScheme()) {
+ Button(
+ onClick = {},
+ shapes = ButtonDefaults.shapes(),
+ modifier = Modifier.testTag(buttonTestTag)
+ ) {
+ Text("Button")
+ }
+ }
+
+ rule
+ .onNodeWithTag(buttonTestTag)
+ .captureToImage()
+ .assertAgainstGolden(
+ screenshotRule,
+ "filledTonalButton_withAnimatedShape_default_darkTheme"
+ )
+ }
+
+ @OptIn(ExperimentalMaterial3ExpressiveApi::class)
+ @Test
+ fun outlinedButton_withAnimatedShape_default_lightTheme() {
+ rule.setMaterialContent(lightColorScheme()) {
+ OutlinedButton(
+ onClick = {},
+ shapes = ButtonDefaults.shapes(),
+ modifier = Modifier.testTag(buttonTestTag)
+ ) {
+ Text("Button")
+ }
+ }
+
+ rule
+ .onNodeWithTag(buttonTestTag)
+ .captureToImage()
+ .assertAgainstGolden(
+ screenshotRule,
+ "outlinedButton_withAnimatedShape_default_lightTheme"
+ )
+ }
+
+ @OptIn(ExperimentalMaterial3ExpressiveApi::class)
+ @Test
+ fun outlinedButton_withAnimatedShape_default_darkTheme() {
+ rule.setMaterialContent(darkColorScheme()) {
+ OutlinedButton(
+ onClick = {},
+ shapes = ButtonDefaults.shapes(),
+ modifier = Modifier.testTag(buttonTestTag)
+ ) {
+ Text("Button")
+ }
+ }
+
+ rule
+ .onNodeWithTag(buttonTestTag)
+ .captureToImage()
+ .assertAgainstGolden(
+ screenshotRule,
+ "outlinedButton_withAnimatedShape_default_darkTheme"
+ )
+ }
+
+ @OptIn(ExperimentalMaterial3ExpressiveApi::class)
+ @Test
+ fun textButton_withAnimatedShape_default_lightTheme() {
+ rule.setMaterialContent(lightColorScheme()) {
+ TextButton(
+ onClick = {},
+ shapes = ButtonDefaults.shapes(),
+ modifier = Modifier.testTag(buttonTestTag)
+ ) {
+ Text("Button")
+ }
+ }
+
+ rule
+ .onNodeWithTag(buttonTestTag)
+ .captureToImage()
+ .assertAgainstGolden(screenshotRule, "textButton_withAnimatedShape_default_lightTheme")
+ }
+
+ @OptIn(ExperimentalMaterial3ExpressiveApi::class)
+ @Test
+ fun textButton_withAnimatedShape_default_darkTheme() {
+ rule.setMaterialContent(darkColorScheme()) {
+ TextButton(
+ onClick = {},
+ shapes = ButtonDefaults.shapes(),
+ modifier = Modifier.testTag(buttonTestTag)
+ ) {
+ Text("Button")
+ }
+ }
+
+ rule
+ .onNodeWithTag(buttonTestTag)
+ .captureToImage()
+ .assertAgainstGolden(screenshotRule, "textButton_withAnimatedShape_default_darkTheme")
+ }
+
+ @OptIn(ExperimentalMaterial3ExpressiveApi::class)
+ @Test
+ fun button_withAnimatedShape_pressed_lightTheme() {
+ rule.setMaterialContent(lightColorScheme()) {
+ Button(
+ onClick = {},
+ shapes = ButtonDefaults.shapes(),
+ modifier = Modifier.testTag(buttonTestTag)
+ ) {
+ Text("Button")
+ }
+ }
+
+ rule.mainClock.autoAdvance = false
+ rule.onNodeWithTag(buttonTestTag).performTouchInput { down(center) }
+
+ rule.mainClock.advanceTimeByFrame()
+ rule.waitForIdle() // Wait for measure
+ rule.mainClock.advanceTimeBy(milliseconds = 200)
+
+ // Ripples are drawn on the RenderThread, not the main (UI) thread, so we can't wait for
+ // synchronization. Instead just wait until after the ripples are finished animating.
+ Thread.sleep(300)
+
+ rule
+ .onNodeWithTag(buttonTestTag)
+ .captureToImage()
+ .assertAgainstGolden(screenshotRule, "button_withAnimatedShape_pressed_lightTheme")
+ }
+
+ @OptIn(ExperimentalMaterial3ExpressiveApi::class)
+ @Test
+ fun button_withAnimatedShape_pressed_darkTheme() {
+ rule.setMaterialContent(darkColorScheme()) {
+ Button(
+ onClick = {},
+ shapes = ButtonDefaults.shapes(),
+ modifier = Modifier.testTag(buttonTestTag)
+ ) {
+ Text("Button")
+ }
+ }
+
+ rule.mainClock.autoAdvance = false
+ rule.onNodeWithTag(buttonTestTag).performTouchInput { down(center) }
+
+ rule.mainClock.advanceTimeByFrame()
+ rule.waitForIdle() // Wait for measure
+ rule.mainClock.advanceTimeBy(milliseconds = 200)
+
+ // Ripples are drawn on the RenderThread, not the main (UI) thread, so we can't wait for
+ // synchronization. Instead just wait until after the ripples are finished animating.
+ Thread.sleep(300)
+
+ rule
+ .onNodeWithTag(buttonTestTag)
+ .captureToImage()
+ .assertAgainstGolden(screenshotRule, "button_withAnimatedShape_pressed_darkTheme")
+ }
+
+ @OptIn(ExperimentalMaterial3ExpressiveApi::class)
+ @Test
+ fun elevatedButton_withAnimatedShape_pressed_lightTheme() {
+ rule.setMaterialContent(lightColorScheme()) {
+ ElevatedButton(
+ onClick = {},
+ shapes = ButtonDefaults.shapes(),
+ modifier = Modifier.testTag(buttonTestTag)
+ ) {
+ Text("Button")
+ }
+ }
+
+ rule.mainClock.autoAdvance = false
+ rule.onNodeWithTag(buttonTestTag).performTouchInput { down(center) }
+
+ rule.mainClock.advanceTimeByFrame()
+ rule.waitForIdle() // Wait for measure
+ rule.mainClock.advanceTimeBy(milliseconds = 200)
+
+ // Ripples are drawn on the RenderThread, not the main (UI) thread, so we can't wait for
+ // synchronization. Instead just wait until after the ripples are finished animating.
+ Thread.sleep(300)
+
+ rule
+ .onNodeWithTag(buttonTestTag)
+ .captureToImage()
+ .assertAgainstGolden(
+ screenshotRule,
+ "elevatedButton_withAnimatedShape_pressed_lightTheme"
+ )
+ }
+
+ @OptIn(ExperimentalMaterial3ExpressiveApi::class)
+ @Test
+ fun elevatedButton_withAnimatedShape_pressed_darkTheme() {
+ rule.setMaterialContent(darkColorScheme()) {
+ ElevatedButton(
+ onClick = {},
+ shapes = ButtonDefaults.shapes(),
+ modifier = Modifier.testTag(buttonTestTag)
+ ) {
+ Text("Button")
+ }
+ }
+
+ rule.mainClock.autoAdvance = false
+ rule.onNodeWithTag(buttonTestTag).performTouchInput { down(center) }
+
+ rule.mainClock.advanceTimeByFrame()
+ rule.waitForIdle() // Wait for measure
+ rule.mainClock.advanceTimeBy(milliseconds = 200)
+
+ // Ripples are drawn on the RenderThread, not the main (UI) thread, so we can't wait for
+ // synchronization. Instead just wait until after the ripples are finished animating.
+ Thread.sleep(300)
+
+ rule
+ .onNodeWithTag(buttonTestTag)
+ .captureToImage()
+ .assertAgainstGolden(
+ screenshotRule,
+ "elevatedButton_withAnimatedShape_pressed_darkTheme"
+ )
+ }
+
+ @OptIn(ExperimentalMaterial3ExpressiveApi::class)
+ @Test
+ fun filledTonalButton_withAnimatedShape_pressed_lightTheme() {
+ rule.setMaterialContent(lightColorScheme()) {
+ FilledTonalButton(
+ onClick = {},
+ shapes = ButtonDefaults.shapes(),
+ modifier = Modifier.testTag(buttonTestTag)
+ ) {
+ Text("Button")
+ }
+ }
+
+ rule.mainClock.autoAdvance = false
+ rule.onNodeWithTag(buttonTestTag).performTouchInput { down(center) }
+
+ rule.mainClock.advanceTimeByFrame()
+ rule.waitForIdle() // Wait for measure
+ rule.mainClock.advanceTimeBy(milliseconds = 200)
+
+ // Ripples are drawn on the RenderThread, not the main (UI) thread, so we can't wait for
+ // synchronization. Instead just wait until after the ripples are finished animating.
+ Thread.sleep(300)
+
+ rule
+ .onNodeWithTag(buttonTestTag)
+ .captureToImage()
+ .assertAgainstGolden(
+ screenshotRule,
+ "filledTonalButton_withAnimatedShape_pressed_lightTheme"
+ )
+ }
+
+ @OptIn(ExperimentalMaterial3ExpressiveApi::class)
+ @Test
+ fun filledTonalButton_withAnimatedShape_pressed_darkTheme() {
+ rule.setMaterialContent(darkColorScheme()) {
+ Button(
+ onClick = {},
+ shapes = ButtonDefaults.shapes(),
+ modifier = Modifier.testTag(buttonTestTag)
+ ) {
+ Text("Button")
+ }
+ }
+
+ rule.mainClock.autoAdvance = false
+ rule.onNodeWithTag(buttonTestTag).performTouchInput { down(center) }
+
+ rule.mainClock.advanceTimeByFrame()
+ rule.waitForIdle() // Wait for measure
+ rule.mainClock.advanceTimeBy(milliseconds = 200)
+
+ // Ripples are drawn on the RenderThread, not the main (UI) thread, so we can't wait for
+ // synchronization. Instead just wait until after the ripples are finished animating.
+ Thread.sleep(300)
+
+ rule
+ .onNodeWithTag(buttonTestTag)
+ .captureToImage()
+ .assertAgainstGolden(
+ screenshotRule,
+ "filledTonalButton_withAnimatedShape_pressed_darkTheme"
+ )
+ }
+
+ @OptIn(ExperimentalMaterial3ExpressiveApi::class)
+ @Test
+ fun outlinedButton_withAnimatedShape_pressed_lightTheme() {
+ rule.setMaterialContent(lightColorScheme()) {
+ OutlinedButton(
+ onClick = {},
+ shapes = ButtonDefaults.shapes(),
+ modifier = Modifier.testTag(buttonTestTag)
+ ) {
+ Text("Button")
+ }
+ }
+
+ rule.mainClock.autoAdvance = false
+ rule.onNodeWithTag(buttonTestTag).performTouchInput { down(center) }
+
+ rule.mainClock.advanceTimeByFrame()
+ rule.waitForIdle() // Wait for measure
+ rule.mainClock.advanceTimeBy(milliseconds = 200)
+
+ // Ripples are drawn on the RenderThread, not the main (UI) thread, so we can't wait for
+ // synchronization. Instead just wait until after the ripples are finished animating.
+ Thread.sleep(300)
+
+ rule
+ .onNodeWithTag(buttonTestTag)
+ .captureToImage()
+ .assertAgainstGolden(
+ screenshotRule,
+ "outlinedButton_withAnimatedShape_pressed_lightTheme"
+ )
+ }
+
+ @OptIn(ExperimentalMaterial3ExpressiveApi::class)
+ @Test
+ fun outlinedButton_withAnimatedShape_pressed_darkTheme() {
+ rule.setMaterialContent(darkColorScheme()) {
+ OutlinedButton(
+ onClick = {},
+ shapes = ButtonDefaults.shapes(),
+ modifier = Modifier.testTag(buttonTestTag)
+ ) {
+ Text("Button")
+ }
+ }
+
+ rule.mainClock.autoAdvance = false
+ rule.onNodeWithTag(buttonTestTag).performTouchInput { down(center) }
+
+ rule.mainClock.advanceTimeByFrame()
+ rule.waitForIdle() // Wait for measure
+ rule.mainClock.advanceTimeBy(milliseconds = 200)
+
+ // Ripples are drawn on the RenderThread, not the main (UI) thread, so we can't wait for
+ // synchronization. Instead just wait until after the ripples are finished animating.
+ Thread.sleep(300)
+
+ rule
+ .onNodeWithTag(buttonTestTag)
+ .captureToImage()
+ .assertAgainstGolden(
+ screenshotRule,
+ "outlinedButton_withAnimatedShape_pressed_darkTheme"
+ )
+ }
+
+ @OptIn(ExperimentalMaterial3ExpressiveApi::class)
+ @Test
+ fun textButton_withAnimatedShape_pressed_lightTheme() {
+ rule.setMaterialContent(lightColorScheme()) {
+ TextButton(
+ onClick = {},
+ shapes = ButtonDefaults.shapes(),
+ modifier = Modifier.testTag(buttonTestTag)
+ ) {
+ Text("Button")
+ }
+ }
+
+ rule.mainClock.autoAdvance = false
+ rule.onNodeWithTag(buttonTestTag).performTouchInput { down(center) }
+
+ rule.mainClock.advanceTimeByFrame()
+ rule.waitForIdle() // Wait for measure
+ rule.mainClock.advanceTimeBy(milliseconds = 200)
+
+ // Ripples are drawn on the RenderThread, not the main (UI) thread, so we can't wait for
+ // synchronization. Instead just wait until after the ripples are finished animating.
+ Thread.sleep(300)
+
+ rule
+ .onNodeWithTag(buttonTestTag)
+ .captureToImage()
+ .assertAgainstGolden(screenshotRule, "textButton_withAnimatedShape_pressed_lightTheme")
+ }
+
+ @OptIn(ExperimentalMaterial3ExpressiveApi::class)
+ @Test
+ fun textButton_withAnimatedShape_pressed_darkTheme() {
+ rule.setMaterialContent(darkColorScheme()) {
+ TextButton(
+ onClick = {},
+ shapes = ButtonDefaults.shapes(),
+ modifier = Modifier.testTag(buttonTestTag)
+ ) {
+ Text("Button")
+ }
+ }
+
+ rule.mainClock.autoAdvance = false
+ rule.onNodeWithTag(buttonTestTag).performTouchInput { down(center) }
+
+ rule.mainClock.advanceTimeByFrame()
+ rule.waitForIdle() // Wait for measure
+ rule.mainClock.advanceTimeBy(milliseconds = 200)
+
+ // Ripples are drawn on the RenderThread, not the main (UI) thread, so we can't wait for
+ // synchronization. Instead just wait until after the ripples are finished animating.
+ Thread.sleep(300)
+
+ rule
+ .onNodeWithTag(buttonTestTag)
+ .captureToImage()
+ .assertAgainstGolden(screenshotRule, "textButton_withAnimatedShape_pressed_darkTheme")
+ }
}
diff --git a/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/ButtonTest.kt b/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/ButtonTest.kt
index 1cac542..acca58b 100644
--- a/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/ButtonTest.kt
+++ b/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/ButtonTest.kt
@@ -15,6 +15,7 @@
*/
package androidx.compose.material3
+import android.os.Build
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
@@ -25,7 +26,6 @@
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Favorite
-import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material3.tokens.ElevatedButtonTokens
import androidx.compose.material3.tokens.FilledButtonTokens
import androidx.compose.material3.tokens.FilledTonalButtonTokens
@@ -35,8 +35,10 @@
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
+import androidx.compose.testutils.assertShape
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.Role
@@ -48,15 +50,18 @@
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsEqualTo
import androidx.compose.ui.test.assertIsNotEnabled
+import androidx.compose.ui.test.captureToImage
import androidx.compose.ui.test.getUnclippedBoundsInRoot
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.height
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
@@ -669,6 +674,80 @@
"height of button"
)
}
+
+ @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
+ @OptIn(ExperimentalMaterial3ExpressiveApi::class)
+ @Test
+ fun button_withAnimatedShape_defaultShape() {
+ lateinit var shape: Shape
+ val backgroundColor = Color.Yellow
+ val shapeColor = Color.Blue
+ rule.setMaterialContent(lightColorScheme()) {
+ shape = ButtonDefaults.shape
+ Surface(color = backgroundColor) {
+ Button(
+ onClick = {},
+ shapes = ButtonDefaults.shapes(),
+ modifier = Modifier.testTag(ButtonTestTag),
+ colors =
+ ButtonDefaults.buttonColors(
+ containerColor = shapeColor,
+ contentColor = shapeColor
+ )
+ ) {
+ Text("Button")
+ }
+ }
+ }
+
+ rule
+ .onNodeWithTag(ButtonTestTag)
+ .captureToImage()
+ .assertShape(
+ density = rule.density,
+ shapeColor = shapeColor,
+ backgroundColor = backgroundColor,
+ shape = shape
+ )
+ }
+
+ @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
+ @OptIn(ExperimentalMaterial3ExpressiveApi::class)
+ @Test
+ fun button_withAnimatedShape_pressedShape() {
+ lateinit var shape: Shape
+ val backgroundColor = Color.Yellow
+ val shapeColor = Color.Blue
+ rule.setMaterialContent(lightColorScheme()) {
+ shape = ButtonDefaults.pressedShape
+ Surface(color = backgroundColor) {
+ Button(
+ onClick = {},
+ shapes = ButtonDefaults.shapes(),
+ modifier = Modifier.testTag(ButtonTestTag),
+ colors =
+ ButtonDefaults.buttonColors(
+ containerColor = shapeColor,
+ contentColor = shapeColor
+ )
+ ) {
+ Text("Button")
+ }
+ }
+ }
+
+ rule.onNodeWithTag(ButtonTestTag).performTouchInput { down(center) }
+
+ rule
+ .onNodeWithTag(ButtonTestTag)
+ .captureToImage()
+ .assertShape(
+ density = rule.density,
+ shapeColor = shapeColor,
+ backgroundColor = backgroundColor,
+ shape = shape
+ )
+ }
}
private const val ButtonTestTag = "button"
diff --git a/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/SearchBarScreenshotTest.kt b/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/SearchBarScreenshotTest.kt
index cfe85b6..dd20fdc 100644
--- a/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/SearchBarScreenshotTest.kt
+++ b/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/SearchBarScreenshotTest.kt
@@ -442,6 +442,227 @@
assertAgainstGolden("dockedSearchBar_shadow_active")
}
+ @Test
+ fun newSearchBar_collapsed() {
+ rule.setMaterialContent(scheme.colorScheme) {
+ val state = rememberSearchBarState()
+ SearchBar(
+ modifier = Modifier.testTag(testTag),
+ state = state,
+ inputField = {
+ SearchBarDefaults.InputField(
+ searchBarState = state,
+ textFieldState = rememberTextFieldState(),
+ onSearch = {},
+ placeholder = { Text("Hint") },
+ )
+ },
+ )
+ }
+ assertAgainstGolden("searchBar_collapsed_${scheme.name}")
+ }
+
+ @Test
+ fun newSearchBar_collapsed_shadow() {
+ rule.setMaterialContent(lightColorScheme()) {
+ val state = rememberSearchBarState()
+ SearchBar(
+ modifier = Modifier.testTag(testTag),
+ state = state,
+ inputField = {
+ SearchBarDefaults.InputField(
+ searchBarState = state,
+ textFieldState = rememberTextFieldState(),
+ onSearch = {},
+ placeholder = { Text("Hint") },
+ )
+ },
+ shadowElevation = 6.dp,
+ )
+ }
+ assertAgainstGolden("searchBar_collapsed_shadow")
+ }
+
+ @Test
+ fun newSearchBar_collapsed_disabled() {
+ rule.setMaterialContent(scheme.colorScheme) {
+ val state = rememberSearchBarState()
+ SearchBar(
+ modifier = Modifier.testTag(testTag),
+ state = state,
+ inputField = {
+ SearchBarDefaults.InputField(
+ searchBarState = state,
+ textFieldState = rememberTextFieldState(),
+ onSearch = {},
+ enabled = false,
+ placeholder = { Text("Hint") },
+ )
+ },
+ )
+ }
+ assertAgainstGolden("searchBar_collapsed_disabled_${scheme.name}")
+ }
+
+ @Test
+ fun newSearchBar_fullScreen_expanded() {
+ rule.setMaterialContent(scheme.colorScheme) {
+ val state = rememberSearchBarState(initialExpanded = true)
+ ExpandedFullScreenSearchBar(
+ modifier = Modifier.testTag(testTag),
+ state = state,
+ inputField = {
+ SearchBarDefaults.InputField(
+ searchBarState = state,
+ textFieldState = rememberTextFieldState("Query"),
+ onSearch = {},
+ )
+ },
+ content = { Text("Content") },
+ )
+ }
+ assertAgainstGolden("searchBar_fullScreen_${scheme.name}")
+ }
+
+ @Test
+ fun newSearchBar_fullScreen_expanded_withIcons() {
+ rule.setMaterialContent(scheme.colorScheme) {
+ val state = rememberSearchBarState(initialExpanded = true)
+ ExpandedFullScreenSearchBar(
+ modifier = Modifier.testTag(testTag),
+ state = state,
+ inputField = {
+ SearchBarDefaults.InputField(
+ searchBarState = state,
+ textFieldState = rememberTextFieldState("Query"),
+ onSearch = {},
+ leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
+ trailingIcon = { Icon(Icons.Default.MoreVert, contentDescription = null) },
+ )
+ },
+ content = { Text("Content") },
+ )
+ }
+ assertAgainstGolden("searchBar_fullScreen_withIcons_${scheme.name}")
+ }
+
+ @Test
+ fun newSearchBar_fullScreen_expanded_customColors() {
+ rule.setMaterialContent(lightColorScheme()) {
+ val state = rememberSearchBarState(initialExpanded = true)
+ val colors =
+ SearchBarDefaults.colors(
+ containerColor = Color.Yellow,
+ dividerColor = Color.Green,
+ )
+ ExpandedFullScreenSearchBar(
+ modifier = Modifier.testTag(testTag),
+ state = state,
+ inputField = {
+ SearchBarDefaults.InputField(
+ searchBarState = state,
+ textFieldState = rememberTextFieldState("Query"),
+ onSearch = {},
+ colors = colors.inputFieldColors,
+ )
+ },
+ colors = colors,
+ content = { Text("Content") },
+ )
+ }
+ assertAgainstGolden("searchBar_fullScreen_customColors")
+ }
+
+ @Test
+ fun newSearchBar_docked_expanded() {
+ rule.setMaterialContent(scheme.colorScheme) {
+ val state = rememberSearchBarState(initialExpanded = true)
+ ExpandedDockedSearchBar(
+ modifier = Modifier.testTag(testTag),
+ state = state,
+ inputField = {
+ SearchBarDefaults.InputField(
+ searchBarState = state,
+ textFieldState = rememberTextFieldState("Query"),
+ onSearch = {},
+ )
+ },
+ content = { Text("Content") },
+ )
+ }
+ assertAgainstGolden("searchBar_docked_${scheme.name}")
+ }
+
+ @Test
+ fun newSearchBar_docked_expanded_withIcons() {
+ rule.setMaterialContent(scheme.colorScheme) {
+ val state = rememberSearchBarState(initialExpanded = true)
+ ExpandedDockedSearchBar(
+ modifier = Modifier.testTag(testTag),
+ state = state,
+ inputField = {
+ SearchBarDefaults.InputField(
+ searchBarState = state,
+ textFieldState = rememberTextFieldState("Query"),
+ onSearch = {},
+ leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
+ trailingIcon = { Icon(Icons.Default.MoreVert, contentDescription = null) },
+ )
+ },
+ content = { Text("Content") },
+ )
+ }
+ assertAgainstGolden("searchBar_docked_withIcons_${scheme.name}")
+ }
+
+ @Test
+ fun newSearchBar_docked_expanded_customShape() {
+ rule.setMaterialContent(lightColorScheme()) {
+ val state = rememberSearchBarState(initialExpanded = true)
+ ExpandedDockedSearchBar(
+ modifier = Modifier.testTag(testTag),
+ state = state,
+ inputField = {
+ SearchBarDefaults.InputField(
+ searchBarState = state,
+ textFieldState = rememberTextFieldState("Query"),
+ onSearch = {},
+ )
+ },
+ shape = CutCornerShape(24.dp),
+ content = { Text("Content") },
+ )
+ }
+ assertAgainstGolden("searchBar_docked_customShape")
+ }
+
+ @Test
+ fun newSearchBar_docked_expanded_customColors() {
+ rule.setMaterialContent(lightColorScheme()) {
+ val state = rememberSearchBarState(initialExpanded = true)
+ val colors =
+ SearchBarDefaults.colors(
+ containerColor = Color.Yellow,
+ dividerColor = Color.Green,
+ )
+ ExpandedDockedSearchBar(
+ modifier = Modifier.testTag(testTag),
+ state = state,
+ inputField = {
+ SearchBarDefaults.InputField(
+ searchBarState = state,
+ textFieldState = rememberTextFieldState("Query"),
+ onSearch = {},
+ colors = colors.inputFieldColors,
+ )
+ },
+ colors = colors,
+ content = { Text("Content") },
+ )
+ }
+ assertAgainstGolden("searchBar_docked_customColors")
+ }
+
private fun assertAgainstGolden(goldenName: String) {
rule.onNodeWithTag(testTag).captureToImage().assertAgainstGolden(screenshotRule, goldenName)
}
diff --git a/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/TimePickerScreenshotTest.kt b/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/TimePickerScreenshotTest.kt
index e273437..9964472 100644
--- a/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/TimePickerScreenshotTest.kt
+++ b/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/TimePickerScreenshotTest.kt
@@ -18,8 +18,10 @@
import android.os.Build
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.size
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.testutils.assertAgainstGolden
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.testTag
@@ -28,6 +30,7 @@
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
import androidx.test.filters.LargeTest
import androidx.test.filters.SdkSuppress
import androidx.test.screenshot.AndroidXScreenshotTestRule
@@ -122,6 +125,52 @@
rule.assertAgainstGolden("timePicker_24h_rtl_${scheme.name}")
}
+ @Test
+ fun clockFace_24h_mid() {
+ val state =
+ AnalogTimePickerState(
+ TimePickerState(initialHour = 0, initialMinute = 0, is24Hour = true)
+ )
+ rule.setMaterialContent(scheme.colorScheme) {
+ Box(
+ modifier = Modifier.testTag(TestTag).size(340.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ ClockFace(
+ modifier = Modifier.then(ClockFaceSizeModifier()),
+ state = state,
+ colors = TimePickerDefaults.colors(),
+ autoSwitchToMinute = true
+ )
+ }
+ }
+
+ rule.assertAgainstGolden("timePicker_24h_mid${scheme.name}")
+ }
+
+ @Test
+ fun clockFace_24h_min() {
+ val state =
+ AnalogTimePickerState(
+ TimePickerState(initialHour = 0, initialMinute = 0, is24Hour = true)
+ )
+ rule.setMaterialContent(scheme.colorScheme) {
+ Box(
+ modifier = Modifier.testTag(TestTag).size(300.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ ClockFace(
+ modifier = Modifier.then(ClockFaceSizeModifier()),
+ state = state,
+ colors = TimePickerDefaults.colors(),
+ autoSwitchToMinute = true
+ )
+ }
+ }
+
+ rule.assertAgainstGolden("timePicker_24h_min${scheme.name}")
+ }
+
private fun ComposeContentTestRule.assertAgainstGolden(goldenName: String) {
this.onNodeWithTag(TestTag).captureToImage().assertAgainstGolden(screenshotRule, goldenName)
}
diff --git a/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/TimePickerSizeTest.kt b/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/TimePickerSizeTest.kt
new file mode 100644
index 0000000..7e4cbcc
--- /dev/null
+++ b/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/TimePickerSizeTest.kt
@@ -0,0 +1,109 @@
+/*
+ * Copyright 2025 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.material3
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.size
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.test.assertIsSelected
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.DpSize
+import androidx.compose.ui.unit.dp
+import androidx.test.filters.MediumTest
+import com.google.common.truth.Truth.assertThat
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.Parameterized
+
+@MediumTest
+@RunWith(Parameterized::class)
+@OptIn(ExperimentalMaterial3Api::class)
+class TimePickerSizeTest(val config: Config) {
+
+ @get:Rule val rule = createComposeRule()
+
+ @Test
+ fun clockFace_size_resizesCorrectly() {
+ val state =
+ AnalogTimePickerState(
+ TimePickerState(initialHour = 10, initialMinute = 23, is24Hour = false)
+ )
+ rule
+ .setMaterialContentForSizeAssertions(
+ parentMaxWidth = config.size.width,
+ parentMaxHeight = config.size.height
+ ) {
+ ClockFace(
+ modifier = Modifier.then(ClockFaceSizeModifier()),
+ state = state,
+ colors = TimePickerDefaults.colors(),
+ autoSwitchToMinute = true
+ )
+ }
+ .assertIsSquareWithSize(config.expected)
+ }
+
+ @Test
+ fun clockFace_24Hour_everyValue() {
+ val state =
+ AnalogTimePickerState(
+ TimePickerState(initialHour = 10, initialMinute = 23, is24Hour = true)
+ )
+
+ rule.setMaterialContent(lightColorScheme()) {
+ Box(Modifier.size(config.size)) {
+ ClockFace(
+ modifier = Modifier,
+ state = state,
+ colors = TimePickerDefaults.colors(),
+ autoSwitchToMinute = true
+ )
+ }
+ }
+
+ repeat(24) { number ->
+ rule
+ .onNodeWithTimeValue(number, TimePickerSelectionMode.Hour, is24Hour = true)
+ .performClick()
+
+ rule.runOnIdle {
+ state.selection = TimePickerSelectionMode.Hour
+ assertThat(state.hour).isEqualTo(number)
+ }
+
+ rule
+ .onNodeWithTimeValue(number, TimePickerSelectionMode.Hour, is24Hour = true)
+ .assertIsSelected()
+ }
+ }
+
+ internal companion object {
+ @Parameterized.Parameters(name = "{0}")
+ @JvmStatic
+ fun parameters() =
+ arrayOf(
+ Config(DpSize(384.dp, 384.dp), 256.dp),
+ Config(DpSize(350.dp, 350.dp), 238.dp),
+ Config(DpSize(300.dp, 300.dp), 200.dp)
+ )
+ }
+
+ data class Config(val size: DpSize, val expected: Dp)
+}
diff --git a/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/TimePickerTest.kt b/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/TimePickerTest.kt
index 4fffa57..16b4f65 100644
--- a/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/TimePickerTest.kt
+++ b/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/TimePickerTest.kt
@@ -24,6 +24,7 @@
import androidx.compose.material3.internal.Strings
import androidx.compose.material3.internal.getString
import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
@@ -664,7 +665,12 @@
)
rule.setMaterialContent(lightColorScheme()) {
- ClockFace(state, TimePickerDefaults.colors(), autoSwitchToMinute = true)
+ ClockFace(
+ modifier = Modifier,
+ state = state,
+ colors = TimePickerDefaults.colors(),
+ autoSwitchToMinute = true
+ )
}
repeat(24) { number ->
@@ -691,7 +697,12 @@
)
rule.setMaterialContent(lightColorScheme()) {
- ClockFace(state, TimePickerDefaults.colors(), autoSwitchToMinute = true)
+ ClockFace(
+ modifier = Modifier,
+ state = state,
+ colors = TimePickerDefaults.colors(),
+ autoSwitchToMinute = true
+ )
}
repeat(12) { number ->
@@ -721,7 +732,12 @@
)
rule.setMaterialContent(lightColorScheme()) {
- ClockFace(state, TimePickerDefaults.colors(), autoSwitchToMinute = true)
+ ClockFace(
+ modifier = Modifier,
+ state = state,
+ colors = TimePickerDefaults.colors(),
+ autoSwitchToMinute = true
+ )
}
repeat(12) { number ->
@@ -763,7 +779,12 @@
)
state.selection = TimePickerSelectionMode.Minute
rule.setMaterialContent(lightColorScheme()) {
- ClockFace(state, TimePickerDefaults.colors(), autoSwitchToMinute = true)
+ ClockFace(
+ modifier = Modifier,
+ state = state,
+ colors = TimePickerDefaults.colors(),
+ autoSwitchToMinute = true
+ )
}
repeat(11) { number ->
@@ -782,7 +803,12 @@
)
state.selection = TimePickerSelectionMode.Minute
rule.setMaterialContent(lightColorScheme()) {
- ClockFace(state, TimePickerDefaults.colors(), autoSwitchToMinute = true)
+ ClockFace(
+ modifier = Modifier,
+ state = state,
+ colors = TimePickerDefaults.colors(),
+ autoSwitchToMinute = true
+ )
}
repeat(11) { number ->
@@ -790,38 +816,40 @@
rule.runOnIdle { assertThat(state.minute).isEqualTo(number * 5) }
}
}
+}
- private fun contentDescriptionForValue(
- resources: Resources,
- selection: TimePickerSelectionMode,
- is24Hour: Boolean,
- number: Int
- ): String {
-
- val id =
- if (selection == TimePickerSelectionMode.Minute) {
- R.string.m3c_time_picker_minute_suffix
- } else if (is24Hour) {
- R.string.m3c_time_picker_hour_24h_suffix
- } else {
- R.string.m3c_time_picker_hour_suffix
- }
-
- return resources.getString(id, number)
- }
-
- private fun SemanticsNodeInteractionsProvider.onNodeWithTimeValue(
- number: Int,
- selection: TimePickerSelectionMode,
- is24Hour: Boolean = false,
- ): SemanticsNodeInteraction =
- onAllNodesWithContentDescription(
- contentDescriptionForValue(
- InstrumentationRegistry.getInstrumentation().context.resources,
- selection,
- is24Hour,
- number
- )
+@OptIn(ExperimentalMaterial3Api::class)
+internal fun SemanticsNodeInteractionsProvider.onNodeWithTimeValue(
+ number: Int,
+ selection: TimePickerSelectionMode,
+ is24Hour: Boolean = false,
+): SemanticsNodeInteraction =
+ onAllNodesWithContentDescription(
+ contentDescriptionForValue(
+ InstrumentationRegistry.getInstrumentation().context.resources,
+ selection,
+ is24Hour,
+ number
)
- .onFirst()
+ )
+ .onFirst()
+
+@OptIn(ExperimentalMaterial3Api::class)
+internal fun contentDescriptionForValue(
+ resources: Resources,
+ selection: TimePickerSelectionMode,
+ is24Hour: Boolean,
+ number: Int
+): String {
+
+ val id =
+ if (selection == TimePickerSelectionMode.Minute) {
+ R.string.m3c_time_picker_minute_suffix
+ } else if (is24Hour) {
+ R.string.m3c_time_picker_hour_24h_suffix
+ } else {
+ R.string.m3c_time_picker_hour_suffix
+ }
+
+ return resources.getString(id, number)
}
diff --git a/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/ToggleButtonTest.kt b/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/ToggleButtonTest.kt
index 8257ca35..c4f2f68 100644
--- a/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/ToggleButtonTest.kt
+++ b/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/ToggleButtonTest.kt
@@ -310,7 +310,7 @@
@Test
fun buttonShapes_AllRounded_hasRoundedShapesIsTrue() {
assertThat(
- ButtonShapes(
+ ToggleButtonShapes(
shape = RoundedCornerShape(10.dp),
pressedShape = RoundedCornerShape(10.dp),
checkedShape = RoundedCornerShape(4.dp),
@@ -323,7 +323,7 @@
@Test
fun buttonShapes_mixedShapes_hasRoundedShapesIsFalse() {
assertThat(
- ButtonShapes(
+ ToggleButtonShapes(
shape = RectangleShape,
pressedShape = RoundedCornerShape(10.dp),
checkedShape = RoundedCornerShape(4.dp),
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Button.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Button.kt
index 6600a99..f24ae75 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Button.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Button.kt
@@ -17,6 +17,7 @@
package androidx.compose.material3
import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.core.VectorConverter
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.interaction.FocusInteraction
@@ -25,14 +26,20 @@
import androidx.compose.foundation.interaction.InteractionSource
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.PressInteraction
+import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.calculateEndPadding
+import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.CornerBasedShape
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.internal.ProvideContentColorTextStyle
import androidx.compose.material3.internal.animateElevation
+import androidx.compose.material3.internal.rememberAnimatedShape
import androidx.compose.material3.tokens.BaselineButtonTokens
import androidx.compose.material3.tokens.ButtonLargeTokens
import androidx.compose.material3.tokens.ButtonMediumTokens
@@ -43,6 +50,7 @@
import androidx.compose.material3.tokens.ElevatedButtonTokens
import androidx.compose.material3.tokens.FilledButtonTokens
import androidx.compose.material3.tokens.FilledTonalButtonTokens
+import androidx.compose.material3.tokens.MotionSchemeKeyTokens
import androidx.compose.material3.tokens.OutlinedButtonTokens
import androidx.compose.material3.tokens.TextButtonTokens
import androidx.compose.runtime.Composable
@@ -50,6 +58,8 @@
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
@@ -58,6 +68,7 @@
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.takeOrElse
+import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
@@ -174,6 +185,137 @@
}
}
+// TODO add link to image of pressed button
+/**
+ * <a href="https://m3.material.io/components/buttons/overview" class="external"
+ * target="_blank">Material Design button</a>.
+ *
+ * Buttons help people initiate actions, from sending an email, to sharing a document, to liking a
+ * post. It also morphs between the shapes provided in [shapes] depending on the state of the
+ * interaction with the button as long as the shapes provided our [CornerBasedShape]s. If a shape in
+ * [shapes] isn't a [CornerBasedShape], then button will change between the [ButtonShapes] according
+ * to user interaction.
+ *
+ * ![Filled button
+ * image](https://developer.android.com/images/reference/androidx/compose/material3/filled-button.png)
+ *
+ * Filled buttons are high-emphasis buttons. Filled buttons have the most visual impact after the
+ * [FloatingActionButton], and should be used for important, final actions that complete a flow,
+ * like "Save", "Join now", or "Confirm".
+ *
+ * @sample androidx.compose.material3.samples.ButtonWithAnimatedShapeSample
+ *
+ * Choose the best button for an action based on the amount of emphasis it needs. The more important
+ * an action is, the higher emphasis its button should be.
+ * - See [OutlinedButton] for a medium-emphasis button with a border.
+ * - See [ElevatedButton] for an [FilledTonalButton] with a shadow.
+ * - See [TextButton] for a low-emphasis button with no border.
+ * - See [FilledTonalButton] for a middle ground between [OutlinedButton] and [Button].
+ *
+ * The default text style for internal [Text] components will be set to [Typography.labelLarge].
+ *
+ * @param onClick called when this button is clicked
+ * @param shapes the [ButtonShapes] that this button with morph between depending on the user's
+ * interaction with the button.
+ * @param modifier the [Modifier] to be applied to this button
+ * @param enabled controls the enabled state of this button. When `false`, this component will not
+ * respond to user input, and it will appear visually disabled and disabled to accessibility
+ * services.
+ * @param colors [ButtonColors] that will be used to resolve the colors for this button in different
+ * states. See [ButtonDefaults.buttonColors].
+ * @param elevation [ButtonElevation] used to resolve the elevation for this button in different
+ * states. This controls the size of the shadow below the button. See
+ * [ButtonElevation.shadowElevation].
+ * @param border the border to draw around the container of this button
+ * @param contentPadding the spacing values to apply internally between the container and the
+ * content
+ * @param interactionSource an optional hoisted [MutableInteractionSource] for observing and
+ * emitting [Interaction]s for this button. You can use this to change the button's appearance or
+ * preview the button in different states. Note that if `null` is provided, interactions will
+ * still happen internally.
+ * @param content The content displayed on the button, expected to be text, icon or image.
+ */
+@Composable
+@ExperimentalMaterial3ExpressiveApi
+fun Button(
+ onClick: () -> Unit,
+ shapes: ButtonShapes,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ colors: ButtonColors = ButtonDefaults.buttonColors(),
+ elevation: ButtonElevation? = ButtonDefaults.buttonElevation(),
+ border: BorderStroke? = null,
+ contentPadding: PaddingValues = ButtonDefaults.ContentPadding,
+ interactionSource: MutableInteractionSource? = null,
+ content: @Composable RowScope.() -> Unit
+) {
+ @Suppress("NAME_SHADOWING")
+ val interactionSource = interactionSource ?: remember { MutableInteractionSource() }
+ // TODO Load the motionScheme tokens from the component tokens file
+ // MotionSchemeKeyTokens.DefaultEffects is intentional here to prevent
+ // any bounce in this component.
+ val defaultAnimationSpec = MotionSchemeKeyTokens.DefaultEffects.value<Float>()
+ val pressed by interactionSource.collectIsPressedAsState()
+ val containerColor = colors.containerColor(enabled)
+ val contentColor = colors.contentColor(enabled)
+ val shadowElevation = elevation?.shadowElevation(enabled, interactionSource)?.value ?: 0.dp
+ val layoutDirection = LocalLayoutDirection.current
+ val buttonShape = shapeByInteraction(shapes, pressed, defaultAnimationSpec)
+
+ Surface(
+ onClick = onClick,
+ modifier = modifier.semantics { role = Role.Button },
+ enabled = enabled,
+ shape = buttonShape,
+ color = containerColor,
+ contentColor = contentColor,
+ shadowElevation = shadowElevation,
+ border = border,
+ interactionSource = interactionSource
+ ) {
+ ProvideContentColorTextStyle(
+ contentColor = contentColor,
+ textStyle = MaterialTheme.typography.labelLarge
+ ) {
+ Row(
+ Modifier.defaultMinSize(
+ minWidth = ButtonDefaults.MinWidth,
+ minHeight = ButtonDefaults.MinHeight
+ )
+ .then(
+ when (buttonShape) {
+ is ShapeWithHorizontalCenterOptically -> {
+ Modifier.horizontalCenterOptically(
+ shape = buttonShape,
+ maxStartOffset =
+ contentPadding.calculateStartPadding(layoutDirection),
+ maxEndOffset =
+ contentPadding.calculateEndPadding(layoutDirection)
+ )
+ }
+ is CornerBasedShape -> {
+ Modifier.horizontalCenterOptically(
+ shape = buttonShape,
+ maxStartOffset =
+ contentPadding.calculateStartPadding(layoutDirection),
+ maxEndOffset =
+ contentPadding.calculateEndPadding(layoutDirection)
+ )
+ }
+ else -> {
+ Modifier
+ }
+ }
+ )
+ .padding(contentPadding),
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically,
+ content = content
+ )
+ }
+ }
+}
+
/**
* <a href="https://m3.material.io/components/buttons/overview" class="external"
* target="_blank">Material Design elevated button</a>.
@@ -247,6 +389,84 @@
content = content
)
+// TODO add link to image of pressed elevated button
+/**
+ * <a href="https://m3.material.io/components/buttons/overview" class="external"
+ * target="_blank">Material Design elevated button</a>.
+ *
+ * Buttons help people initiate actions, from sending an email, to sharing a document, to liking a
+ * post. It also morphs between the shapes provided in [shapes] depending on the state of the
+ * interaction with the button as long as the shapes provided our [CornerBasedShape]s. If a shape in
+ * [shapes] isn't a [CornerBasedShape], then button will change between the [ButtonShapes] according
+ * to user interaction.
+ *
+ * ![Elevated button
+ * image](https://developer.android.com/images/reference/androidx/compose/material3/elevated-button.png)
+ *
+ * Elevated buttons are high-emphasis buttons that are essentially [FilledTonalButton]s with a
+ * shadow. To prevent shadow creep, only use them when absolutely necessary, such as when the button
+ * requires visual separation from patterned container.
+ *
+ * @sample androidx.compose.material3.samples.ElevatedButtonWithAnimatedShapeSample
+ *
+ * Choose the best button for an action based on the amount of emphasis it needs. The more important
+ * an action is, the higher emphasis its button should be.
+ * - See [Button] for a high-emphasis button without a shadow, also known as a filled button.
+ * - See [FilledTonalButton] for a middle ground between [OutlinedButton] and [Button].
+ * - See [OutlinedButton] for a medium-emphasis button with a border.
+ * - See [TextButton] for a low-emphasis button with no border.
+ *
+ * The default text style for internal [Text] components will be set to [Typography.labelLarge].
+ *
+ * @param onClick called when this button is clicked
+ * @param shapes the [ButtonShapes] that this button with morph between depending on the user's
+ * interaction with the button.
+ * @param modifier the [Modifier] to be applied to this button
+ * @param enabled controls the enabled state of this button. When `false`, this component will not
+ * respond to user input, and it will appear visually disabled and disabled to accessibility
+ * services.
+ * @param colors [ButtonColors] that will be used to resolve the colors for this button in different
+ * states. See [ButtonDefaults.elevatedButtonColors].
+ * @param elevation [ButtonElevation] used to resolve the elevation for this button in different
+ * states. This controls the size of the shadow below the button. Additionally, when the container
+ * color is [ColorScheme.surface], this controls the amount of primary color applied as an
+ * overlay. See [ButtonDefaults.elevatedButtonElevation].
+ * @param border the border to draw around the container of this button
+ * @param contentPadding the spacing values to apply internally between the container and the
+ * content
+ * @param interactionSource an optional hoisted [MutableInteractionSource] for observing and
+ * emitting [Interaction]s for this button. You can use this to change the button's appearance or
+ * preview the button in different states. Note that if `null` is provided, interactions will
+ * still happen internally.
+ * @param content The content displayed on the button, expected to be text, icon or image.
+ */
+@Composable
+@ExperimentalMaterial3ExpressiveApi
+fun ElevatedButton(
+ onClick: () -> Unit,
+ shapes: ButtonShapes,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ colors: ButtonColors = ButtonDefaults.elevatedButtonColors(),
+ elevation: ButtonElevation? = ButtonDefaults.elevatedButtonElevation(),
+ border: BorderStroke? = null,
+ contentPadding: PaddingValues = ButtonDefaults.ContentPadding,
+ interactionSource: MutableInteractionSource? = null,
+ content: @Composable RowScope.() -> Unit
+) =
+ Button(
+ onClick = onClick,
+ shapes = shapes,
+ modifier = modifier,
+ enabled = enabled,
+ colors = colors,
+ elevation = elevation,
+ border = border,
+ contentPadding = contentPadding,
+ interactionSource = interactionSource,
+ content = content
+ )
+
/**
* <a href="https://m3.material.io/components/buttons/overview" class="external"
* target="_blank">Material Design filled tonal button</a>.
@@ -321,6 +541,85 @@
content = content
)
+// TODO add link to image of pressed filled tonal button
+/**
+ * <a href="https://m3.material.io/components/buttons/overview" class="external"
+ * target="_blank">Material Design filled tonal button</a>.
+ *
+ * Buttons help people initiate actions, from sending an email, to sharing a document, to liking a
+ * post. It also morphs between the shapes provided in [shapes] depending on the state of the
+ * interaction with the button as long as the shapes provided our [CornerBasedShape]s. If a shape in
+ * [shapes] isn't a [CornerBasedShape], then button will change between the [ButtonShapes] according
+ * to user interaction.
+ *
+ * ![Filled tonal button
+ * image](https://developer.android.com/images/reference/androidx/compose/material3/filled-tonal-button.png)
+ *
+ * Filled tonal buttons are medium-emphasis buttons that is an alternative middle ground between
+ * default [Button]s (filled) and [OutlinedButton]s. They can be used in contexts where
+ * lower-priority button requires slightly more emphasis than an outline would give, such as "Next"
+ * in an onboarding flow. Tonal buttons use the secondary color mapping.
+ *
+ * @sample androidx.compose.material3.samples.FilledTonalButtonWithAnimatedShapeSample
+ *
+ * Choose the best button for an action based on the amount of emphasis it needs. The more important
+ * an action is, the higher emphasis its button should be.
+ * - See [Button] for a high-emphasis button without a shadow, also known as a filled button.
+ * - See [ElevatedButton] for a [FilledTonalButton] with a shadow.
+ * - See [OutlinedButton] for a medium-emphasis button with a border.
+ * - See [TextButton] for a low-emphasis button with no border.
+ *
+ * The default text style for internal [Text] components will be set to [Typography.labelLarge].
+ *
+ * @param onClick called when this button is clicked
+ * @param shapes the [ButtonShapes] that this button with morph between depending on the user's
+ * interaction with the button.
+ * @param modifier the [Modifier] to be applied to this button
+ * @param enabled controls the enabled state of this button. When `false`, this component will not
+ * respond to user input, and it will appear visually disabled and disabled to accessibility
+ * services.
+ * @param colors [ButtonColors] that will be used to resolve the colors for this button in different
+ * states. See [ButtonDefaults.filledTonalButtonColors].
+ * @param elevation [ButtonElevation] used to resolve the elevation for this button in different
+ * states. This controls the size of the shadow below the button. Additionally, when the container
+ * color is [ColorScheme.surface], this controls the amount of primary color applied as an
+ * overlay.
+ * @param border the border to draw around the container of this button
+ * @param contentPadding the spacing values to apply internally between the container and the
+ * content
+ * @param interactionSource an optional hoisted [MutableInteractionSource] for observing and
+ * emitting [Interaction]s for this button. You can use this to change the button's appearance or
+ * preview the button in different states. Note that if `null` is provided, interactions will
+ * still happen internally.
+ * @param content The content displayed on the button, expected to be text, icon or image.
+ */
+@Composable
+@ExperimentalMaterial3ExpressiveApi
+fun FilledTonalButton(
+ onClick: () -> Unit,
+ shapes: ButtonShapes,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ colors: ButtonColors = ButtonDefaults.filledTonalButtonColors(),
+ elevation: ButtonElevation? = ButtonDefaults.filledTonalButtonElevation(),
+ border: BorderStroke? = null,
+ contentPadding: PaddingValues = ButtonDefaults.ContentPadding,
+ interactionSource: MutableInteractionSource? = null,
+ content: @Composable RowScope.() -> Unit
+) =
+ Button(
+ onClick = onClick,
+ shapes = shapes,
+ modifier = modifier,
+ enabled = enabled,
+ colors = colors,
+ elevation = elevation,
+ border = border,
+ contentPadding = contentPadding,
+ interactionSource = interactionSource,
+ content = content
+ )
+
/**
* <a href="https://m3.material.io/components/buttons/overview" class="external"
* target="_blank">Material Design outlined button</a>.
@@ -394,6 +693,84 @@
content = content
)
+// TODO add link to image of pressed outlined button
+/**
+ * <a href="https://m3.material.io/components/buttons/overview" class="external"
+ * target="_blank">Material Design outlined button</a>.
+ *
+ * Buttons help people initiate actions, from sending an email, to sharing a document, to liking a
+ * post. It also morphs between the shapes provided in [shapes] depending on the state of the
+ * interaction with the button as long as the shapes provided our [CornerBasedShape]s. If a shape in
+ * [shapes] isn't a [CornerBasedShape], then button will change between the [ButtonShapes] according
+ * to user interaction.
+ *
+ * ![Outlined button
+ * image](https://developer.android.com/images/reference/androidx/compose/material3/outlined-button.png)
+ *
+ * Outlined buttons are medium-emphasis buttons. They contain actions that are important, but are
+ * not the primary action in an app. Outlined buttons pair well with [Button]s to indicate an
+ * alternative, secondary action.
+ *
+ * @sample androidx.compose.material3.samples.OutlinedButtonWithAnimatedShapeSample
+ *
+ * Choose the best button for an action based on the amount of emphasis it needs. The more important
+ * an action is, the higher emphasis its button should be.
+ * - See [Button] for a high-emphasis button without a shadow, also known as a filled button.
+ * - See [FilledTonalButton] for a middle ground between [OutlinedButton] and [Button].
+ * - See [OutlinedButton] for a medium-emphasis button with a border.
+ * - See [TextButton] for a low-emphasis button with no border.
+ *
+ * The default text style for internal [Text] components will be set to [Typography.labelLarge].
+ *
+ * @param onClick called when this button is clicked
+ * @param shapes the [ButtonShapes] that this button with morph between depending on the user's
+ * interaction with the button.
+ * @param modifier the [Modifier] to be applied to this button
+ * @param enabled controls the enabled state of this button. When `false`, this component will not
+ * respond to user input, and it will appear visually disabled and disabled to accessibility
+ * services.
+ * @param colors [ButtonColors] that will be used to resolve the colors for this button in different
+ * states. See [ButtonDefaults.outlinedButtonColors].
+ * @param elevation [ButtonElevation] used to resolve the elevation for this button in different
+ * states. This controls the size of the shadow below the button. Additionally, when the container
+ * color is [ColorScheme.surface], this controls the amount of primary color applied as an
+ * overlay.
+ * @param border the border to draw around the container of this button. Pass `null` for no border.
+ * @param contentPadding the spacing values to apply internally between the container and the
+ * content
+ * @param interactionSource an optional hoisted [MutableInteractionSource] for observing and
+ * emitting [Interaction]s for this button. You can use this to change the button's appearance or
+ * preview the button in different states. Note that if `null` is provided, interactions will
+ * still happen internally.
+ * @param content The content displayed on the button, expected to be text, icon or image.
+ */
+@Composable
+@ExperimentalMaterial3ExpressiveApi
+fun OutlinedButton(
+ onClick: () -> Unit,
+ shapes: ButtonShapes,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ colors: ButtonColors = ButtonDefaults.outlinedButtonColors(),
+ elevation: ButtonElevation? = null,
+ border: BorderStroke? = ButtonDefaults.outlinedButtonBorder(enabled),
+ contentPadding: PaddingValues = ButtonDefaults.ContentPadding,
+ interactionSource: MutableInteractionSource? = null,
+ content: @Composable RowScope.() -> Unit
+) =
+ Button(
+ onClick = onClick,
+ shapes = shapes,
+ modifier = modifier,
+ enabled = enabled,
+ colors = colors,
+ elevation = elevation,
+ border = border,
+ contentPadding = contentPadding,
+ interactionSource = interactionSource,
+ content = content
+ )
+
/**
* <a href="https://m3.material.io/components/buttons/overview" class="external"
* target="_blank">Material Design text button</a>.
@@ -408,7 +785,7 @@
* and cards. In cards, text buttons help maintain an emphasis on card content. Text buttons are
* used for the lowest priority actions, especially when presenting multiple options.
*
- * @sample androidx.compose.material3.samples.TextButtonSample
+ * @sample androidx.compose.material3.samples.TextButtonWithAnimatedShapeSample
*
* Choose the best button for an action based on the amount of emphasis it needs. The more important
* an action is, the higher emphasis its button should be.
@@ -468,6 +845,85 @@
content = content
)
+// TODO add link to image of pressed text button
+/**
+ * <a href="https://m3.material.io/components/buttons/overview" class="external"
+ * target="_blank">Material Design text button</a>.
+ *
+ * Buttons help people initiate actions, from sending an email, to sharing a document, to liking a
+ * post. It also morphs between the shapes provided in [shapes] depending on the state of the
+ * interaction with the button as long as the shapes provided our [CornerBasedShape]s. If a shape in
+ * [shapes] isn't a [CornerBasedShape], then button will change between the [ButtonShapes] according
+ * to user interaction.
+ *
+ * ![Text button
+ * image](https://developer.android.com/images/reference/androidx/compose/material3/text-button.png)
+ *
+ * Text buttons are typically used for less-pronounced actions, including those located in dialogs
+ * and cards. In cards, text buttons help maintain an emphasis on card content. Text buttons are
+ * used for the lowest priority actions, especially when presenting multiple options.
+ *
+ * @sample androidx.compose.material3.samples.TextButtonSample
+ *
+ * Choose the best button for an action based on the amount of emphasis it needs. The more important
+ * an action is, the higher emphasis its button should be.
+ * - See [Button] for a high-emphasis button without a shadow, also known as a filled button.
+ * - See [ElevatedButton] for a [FilledTonalButton] with a shadow.
+ * - See [FilledTonalButton] for a middle ground between [OutlinedButton] and [Button].
+ * - See [OutlinedButton] for a medium-emphasis button with a border.
+ *
+ * The default text style for internal [Text] components will be set to [Typography.labelLarge].
+ *
+ * @param onClick called when this button is clicked
+ * @param shapes the [ButtonShapes] that this button with morph between depending on the user's
+ * interaction with the button.
+ * @param modifier the [Modifier] to be applied to this button
+ * @param enabled controls the enabled state of this button. When `false`, this component will not
+ * respond to user input, and it will appear visually disabled and disabled to accessibility
+ * services.
+ * @param colors [ButtonColors] that will be used to resolve the colors for this button in different
+ * states. See [ButtonDefaults.textButtonColors].
+ * @param elevation [ButtonElevation] used to resolve the elevation for this button in different
+ * states. This controls the size of the shadow below the button. Additionally, when the container
+ * color is [ColorScheme.surface], this controls the amount of primary color applied as an
+ * overlay. A TextButton typically has no elevation, and the default value is `null`. See
+ * [ElevatedButton] for a button with elevation.
+ * @param border the border to draw around the container of this button
+ * @param contentPadding the spacing values to apply internally between the container and the
+ * content
+ * @param interactionSource an optional hoisted [MutableInteractionSource] for observing and
+ * emitting [Interaction]s for this button. You can use this to change the button's appearance or
+ * preview the button in different states. Note that if `null` is provided, interactions will
+ * still happen internally.
+ * @param content The content displayed on the button, expected to be text.
+ */
+@ExperimentalMaterial3ExpressiveApi
+@Composable
+fun TextButton(
+ onClick: () -> Unit,
+ shapes: ButtonShapes,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ colors: ButtonColors = ButtonDefaults.textButtonColors(),
+ elevation: ButtonElevation? = null,
+ border: BorderStroke? = null,
+ contentPadding: PaddingValues = ButtonDefaults.TextButtonContentPadding,
+ interactionSource: MutableInteractionSource? = null,
+ content: @Composable RowScope.() -> Unit
+) =
+ Button(
+ onClick = onClick,
+ shapes = shapes,
+ modifier = modifier,
+ enabled = enabled,
+ colors = colors,
+ elevation = elevation,
+ border = border,
+ contentPadding = contentPadding,
+ interactionSource = interactionSource,
+ content = content
+ )
+
// TODO(b/201341237): Use token values for 0 elevation?
// TODO(b/201341237): Use token values for null border?
// TODO(b/201341237): Use token values for no color (transparent)?
@@ -702,13 +1158,20 @@
@ExperimentalMaterial3ExpressiveApi
val XLargeIconSpacing = ButtonXLargeTokens.IconLabelSpace
- /** Square shape for any button. */
+ /** Square shape for default buttons. */
@Suppress("OPT_IN_MARKER_ON_WRONG_TARGET")
@get:ExperimentalMaterial3ExpressiveApi
@ExperimentalMaterial3ExpressiveApi
val squareShape: Shape
@Composable get() = ButtonSmallTokens.ContainerShapeSquare.value
+ /** Pressed shape for default buttons. */
+ @Suppress("OPT_IN_MARKER_ON_WRONG_TARGET")
+ @get:ExperimentalMaterial3ExpressiveApi
+ @ExperimentalMaterial3ExpressiveApi
+ val pressedShape: Shape
+ @Composable get() = ButtonSmallTokens.PressedContainerShape.value
+
/** Default shape for a button. */
val shape: Shape
@Composable get() = ButtonSmallTokens.ContainerShapeRound.value
@@ -730,6 +1193,24 @@
@Composable get() = ButtonSmallTokens.ContainerShapeRound.value
/**
+ * Creates a [ButtonShapes] that represents the default shape and pressed shape used in a
+ * button.
+ */
+ @ExperimentalMaterial3ExpressiveApi
+ @Composable
+ fun shapes() = MaterialTheme.shapes.defaultButtonShapes
+
+ @OptIn(ExperimentalMaterial3ExpressiveApi::class)
+ internal val Shapes.defaultButtonShapes: ButtonShapes
+ @Composable
+ get() {
+ return defaultButtonShapesCached
+ ?: ButtonShapes(shape = shape, pressedShape = pressedShape).also {
+ defaultButtonShapesCached = it
+ }
+ }
+
+ /**
* Creates a [ButtonColors] that represents the default container and content colors used in a
* [Button].
*/
@@ -1277,3 +1758,72 @@
return result
}
}
+
+/**
+ * The shapes that will be used in buttons. Button will morph between these shapes depending on the
+ * interaction of the button, assuming all of the shapes are [CornerBasedShape]s.
+ *
+ * @property shape is the active shape.
+ * @property pressedShape is the pressed shape.
+ */
+@ExperimentalMaterial3ExpressiveApi
+@Immutable
+class ButtonShapes(val shape: Shape, val pressedShape: Shape) {
+ /** Returns a copy of this ButtonShapes, optionally overriding some of the values. */
+ fun copy(
+ shape: Shape? = this.shape,
+ pressedShape: Shape? = this.pressedShape,
+ ) =
+ ButtonShapes(
+ shape = shape.takeOrElse { this.shape },
+ pressedShape = pressedShape.takeOrElse { this.pressedShape }
+ )
+
+ internal fun Shape?.takeOrElse(block: () -> Shape): Shape = this ?: block()
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other == null || other !is ButtonShapes) return false
+
+ if (shape != other.shape) return false
+ if (pressedShape != other.pressedShape) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = shape.hashCode()
+ result = 31 * result + pressedShape.hashCode()
+
+ return result
+ }
+}
+
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+internal val ButtonShapes.hasRoundedCornerShapes: Boolean
+ get() = shape is RoundedCornerShape && pressedShape is RoundedCornerShape
+
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+private fun shapeByInteraction(
+ shapes: ButtonShapes,
+ pressed: Boolean,
+ animationSpec: FiniteAnimationSpec<Float>
+): Shape {
+ val shape =
+ if (pressed) {
+ shapes.pressedShape
+ } else {
+ shapes.shape
+ }
+
+ if (shapes.hasRoundedCornerShapes)
+ return key(shapes) {
+ rememberAnimatedShape(
+ shape as RoundedCornerShape,
+ animationSpec,
+ )
+ }
+
+ return shape
+}
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/ButtonGroup.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/ButtonGroup.kt
index ace32d4..b555741 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/ButtonGroup.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/ButtonGroup.kt
@@ -47,6 +47,7 @@
import androidx.compose.ui.platform.InspectorInfo
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
import androidx.compose.ui.util.fastForEachIndexed
@@ -80,9 +81,13 @@
* @sample androidx.compose.material3.samples.MultiSelectConnectedButtonGroupSample
* @sample androidx.compose.material3.samples.SingleSelectConnectedButtonGroupSample
* @param modifier the [Modifier] to be applied to the button group.
- * @param animateFraction the percentage, represented by a float, of the width of the interacted
- * child element that will be used to expand the interacted child element as well as compress the
- * neighboring children.
+ * @param expandedRatio the percentage, represented by a float, of the width of the interacted child
+ * element that will be used to expand the interacted child element as well as compress the
+ * neighboring children. By Default, standard button group will expand the interacted child
+ * element by [ButtonGroupDefaults.ExpandedRatio] of its width and this will be propagated to its
+ * neighbors. If 0f is passed into this slot, then the interacted child element will not expand at
+ * all and the neighboring elements will not compress. If 1f is passed into this slot, then the
+ * interacted child element will expand to 200% of its default width when pressed.
* @param horizontalArrangement The horizontal arrangement of the button group's children.
* @param content the content displayed in the button group, expected to use a Material3 component
* or a composable that is tagged with [Modifier.interactionSourceData].
@@ -91,9 +96,8 @@
@ExperimentalMaterial3ExpressiveApi
fun ButtonGroup(
modifier: Modifier = Modifier,
- @FloatRange(0.0) animateFraction: Float = ButtonGroupDefaults.animateFraction,
- horizontalArrangement: Arrangement.Horizontal =
- Arrangement.spacedBy(ButtonGroupDefaults.spaceBetween),
+ @FloatRange(0.0) expandedRatio: Float = ButtonGroupDefaults.ExpandedRatio,
+ horizontalArrangement: Arrangement.Horizontal = ButtonGroupDefaults.HorizontalArrangement,
content: @Composable ButtonGroupScope.() -> Unit
) {
// TODO Load the motionScheme tokens from the component tokens file
@@ -132,7 +136,7 @@
pressedIndex = index
coroutineScope.launch {
anim.animateTo(
- targetValue = animateFraction,
+ targetValue = expandedRatio,
animationSpec = defaultAnimationSpec
)
}
@@ -181,56 +185,66 @@
/**
* The default percentage, represented as a float, of the width of the interacted child element
* that will be used to expand the interacted child element as well as compress the neighboring
- * children.
+ * children. By Default, standard button group will expand the interacted child element by 15%
+ * of its width and this will be propagated to its neighbors.
*/
- val animateFraction = 0.15f
+ val ExpandedRatio = 0.15f
- /** The default spacing used between children. */
- val spaceBetween = ButtonGroupSmallTokens.BetweenSpace
+ /** The default Arrangement used between children. */
+ val HorizontalArrangement: Arrangement.Horizontal =
+ Arrangement.spacedBy(ButtonGroupSmallTokens.BetweenSpace)
/** The default spacing used between children for connected button group */
// TODO replace with token value
- val connectedSpaceBetween = 2.dp
+ val ConnectedSpaceBetween: Dp = 2.dp
/** Default shape for the leading button in a connected button group */
- val connectedLeadingButtonShape: Shape =
- // TODO replace with token value
- RoundedCornerShape(
- topStart = ShapeDefaults.CornerFull,
- bottomStart = ShapeDefaults.CornerFull,
- topEnd = ShapeDefaults.CornerSmall,
- bottomEnd = ShapeDefaults.CornerSmall
- )
+ val connectedLeadingButtonShape: Shape
+ @Composable
+ get() =
+ // TODO replace with token value
+ RoundedCornerShape(
+ topStart = ShapeDefaults.CornerFull,
+ bottomStart = ShapeDefaults.CornerFull,
+ topEnd = ShapeDefaults.CornerSmall,
+ bottomEnd = ShapeDefaults.CornerSmall
+ )
/** Default shape for the pressed state for the leading button in a connected button group. */
- val connectedLeadingButtonPressShape: Shape =
- // TODO replace with token value
- RoundedCornerShape(
- topStart = ShapeDefaults.CornerFull,
- bottomStart = ShapeDefaults.CornerFull,
- topEnd = ShapeDefaults.CornerExtraSmall,
- bottomEnd = ShapeDefaults.CornerExtraSmall
- )
+ val connectedLeadingButtonPressShape: Shape
+ @Composable
+ get() =
+ // TODO replace with token value
+ RoundedCornerShape(
+ topStart = ShapeDefaults.CornerFull,
+ bottomStart = ShapeDefaults.CornerFull,
+ topEnd = ShapeDefaults.CornerExtraSmall,
+ bottomEnd = ShapeDefaults.CornerExtraSmall
+ )
/** Default shape for the trailing button in a connected button group */
- val connectedTrailingButtonShape: Shape =
- // TODO replace with token value
- RoundedCornerShape(
- topEnd = ShapeDefaults.CornerFull,
- bottomEnd = ShapeDefaults.CornerFull,
- topStart = ShapeDefaults.CornerSmall,
- bottomStart = ShapeDefaults.CornerSmall
- )
+ val connectedTrailingButtonShape: Shape
+ @Composable
+ get() =
+ // TODO replace with token value
+ RoundedCornerShape(
+ topEnd = ShapeDefaults.CornerFull,
+ bottomEnd = ShapeDefaults.CornerFull,
+ topStart = ShapeDefaults.CornerSmall,
+ bottomStart = ShapeDefaults.CornerSmall
+ )
/** Default shape for the pressed state for the trailing button in a connected button group. */
- val connectedTrailingButtonPressShape: Shape =
- // TODO replace with token value
- RoundedCornerShape(
- topEnd = ShapeDefaults.CornerFull,
- bottomEnd = ShapeDefaults.CornerFull,
- topStart = ShapeDefaults.CornerExtraSmall,
- bottomStart = ShapeDefaults.CornerExtraSmall
- )
+ val connectedTrailingButtonPressShape: Shape
+ @Composable
+ get() =
+ // TODO replace with token value
+ RoundedCornerShape(
+ topEnd = ShapeDefaults.CornerFull,
+ bottomEnd = ShapeDefaults.CornerFull,
+ topStart = ShapeDefaults.CornerExtraSmall,
+ bottomStart = ShapeDefaults.CornerExtraSmall
+ )
}
private class ButtonGroupMeasurePolicy(
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/SearchBar.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/SearchBar.kt
index 860584f..a620105 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/SearchBar.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/SearchBar.kt
@@ -55,6 +55,7 @@
import androidx.compose.foundation.layout.exclude
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.onConsumedWindowInsetsChanged
import androidx.compose.foundation.layout.only
@@ -139,14 +140,13 @@
import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged
-import androidx.compose.ui.layout.positionOnScreen
+import androidx.compose.ui.layout.positionInWindow
import androidx.compose.ui.platform.InterceptPlatformTextInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.SoftwareKeyboardController
import androidx.compose.ui.semantics.contentDescription
-import androidx.compose.ui.semantics.onClick
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.TextStyle
@@ -154,18 +154,28 @@
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.Velocity
+import androidx.compose.ui.unit.constrain
import androidx.compose.ui.unit.constrainHeight
import androidx.compose.ui.unit.constrainWidth
import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.lerp
+import androidx.compose.ui.unit.offset
import androidx.compose.ui.unit.round
import androidx.compose.ui.util.fastFirst
import androidx.compose.ui.util.fastFirstOrNull
+import androidx.compose.ui.util.fastForEach
+import androidx.compose.ui.util.fastMap
+import androidx.compose.ui.util.fastMaxOfOrNull
import androidx.compose.ui.util.lerp
import androidx.compose.ui.window.DialogProperties
+import androidx.compose.ui.window.Popup
+import androidx.compose.ui.window.PopupPositionProvider
+import androidx.compose.ui.window.PopupProperties
import androidx.compose.ui.zIndex
import kotlin.coroutines.cancellation.CancellationException
import kotlin.math.abs
@@ -188,7 +198,8 @@
* image](https://developer.android.com/images/reference/androidx/compose/material3/search-bar.png)
*
* The [SearchBar] component represents a search bar in the collapsed state. It should be used in
- * conjunction with an [ExpandedFullScreenSearchBar] to display search results when expanded.
+ * conjunction with an [ExpandedFullScreenSearchBar] or [ExpandedDockedSearchBar] to display search
+ * results when expanded.
*
* @param state the state of the search bar. This state should also be passed to the [inputField]
* and the expanded search bar.
@@ -243,8 +254,8 @@
* A [TopSearchBar] is a [SearchBar] with additional handling for top app bar behavior, such as
* window insets and scrolling. Using a [TopSearchBar] as the top bar of a [Scaffold] ensures that
* the search bar remains at the top of the screen. Like with [SearchBar], [TopSearchBar] should be
- * used in conjunction with an [ExpandedFullScreenSearchBar] to display search results when
- * expanded.
+ * used in conjunction with an [ExpandedFullScreenSearchBar] or [ExpandedDockedSearchBar] to display
+ * search results when expanded.
*
* @param state the state of the search bar. This state should also be passed to the [inputField]
* and the expanded search bar.
@@ -301,6 +312,8 @@
/**
* [ExpandedFullScreenSearchBar] represents a search bar that is currently expanding or in the
* expanded state, showing search results. This component is displayed in a new full-screen dialog.
+ * If this expansion behavior is undesirable, for example on medium or large screens such as
+ * tablets, [ExpandedDockedSearchBar] can be used instead.
*
* @param state the state of the search bar. This state should also be passed to the [inputField]
* and the collapsed search bar.
@@ -369,7 +382,91 @@
// Focus the input field on the first expansion,
// but no need to re-focus if the focus gets cleared.
- LaunchedEffect(state.isExpanded) { focusRequester.requestFocus() }
+ LaunchedEffect(Unit) { focusRequester.requestFocus() }
+ }
+}
+
+/**
+ * [ExpandedDockedSearchBar] represents a search bar that is currently expanding or in the expanded
+ * state, showing search results. This component is displayed in a popup over the collapsed search
+ * bar. It is recommended to use [ExpandedDockedSearchBar] on medium and large screens such as
+ * tablets, and to instead use [ExpandedFullScreenSearchBar] on compact screen such as phones.
+ *
+ * @param state the state of the search bar. This state should also be passed to the [inputField]
+ * and the collapsed search bar.
+ * @param inputField the input field of this search bar that allows entering a query, typically a
+ * [SearchBarDefaults.InputField].
+ * @param modifier the [Modifier] to be applied to this expanded search bar.
+ * @param shape the shape of this search bar.
+ * @param colors [SearchBarColors] that will be used to resolve the colors used for this search bar
+ * in different states. See [SearchBarDefaults.colors].
+ * @param tonalElevation when [SearchBarColors.containerColor] is [ColorScheme.surface], a
+ * translucent primary color overlay is applied on top of the container. A higher tonal elevation
+ * value will result in a darker color in light theme and lighter color in dark theme. See also:
+ * [Surface].
+ * @param shadowElevation the elevation for the shadow below this search bar.
+ * @param properties the platform-specific properties to configure the dialog's behavior. Any
+ * properties which limit the dialog's size (e.g. [DialogProperties.usePlatformDefaultWidth]) are
+ * ignored.
+ * @param content the content of this search bar to display search results below the [inputField].
+ */
+@ExperimentalMaterial3Api
+@Composable
+internal fun ExpandedDockedSearchBar(
+ state: SearchBarState,
+ inputField: @Composable () -> Unit,
+ modifier: Modifier = Modifier,
+ shape: Shape = SearchBarDefaults.dockedShape,
+ colors: SearchBarColors = SearchBarDefaults.colors(),
+ tonalElevation: Dp = SearchBarDefaults.TonalElevation,
+ shadowElevation: Dp = SearchBarDefaults.ShadowElevation,
+ properties: PopupProperties = PopupProperties(focusable = true, clippingEnabled = false),
+ content: @Composable ColumnScope.() -> Unit,
+) {
+ if (!state.isExpanded) return
+
+ val positionProvider =
+ remember(state) {
+ object : PopupPositionProvider {
+ override fun calculatePosition(
+ anchorBounds: IntRect,
+ windowSize: IntSize,
+ layoutDirection: LayoutDirection,
+ popupContentSize: IntSize
+ ): IntOffset = state.collapsedBounds.topLeft
+ }
+ }
+
+ val scope = rememberCoroutineScope()
+
+ Popup(
+ popupPositionProvider = positionProvider,
+ onDismissRequest = { scope.launch { state.animateToCollapsed() } },
+ properties = properties,
+ ) {
+ val focusRequester = remember { FocusRequester() }
+
+ DockedSearchBarLayout(
+ state = state,
+ inputField = {
+ Box(
+ modifier = Modifier.focusRequester(focusRequester),
+ propagateMinConstraints = true,
+ ) {
+ inputField()
+ }
+ },
+ modifier = modifier,
+ shape = shape,
+ colors = colors,
+ tonalElevation = tonalElevation,
+ shadowElevation = shadowElevation,
+ content = content,
+ )
+
+ // Focus the input field on the first expansion,
+ // but no need to re-focus if the focus gets cleared.
+ LaunchedEffect(Unit) { focusRequester.requestFocus() }
}
}
@@ -1201,10 +1298,6 @@
if (searchBarState.isExpanded) {
stateDescription = suggestionsAvailableSemantics
}
- onClick {
- focusRequester.requestFocus()
- true
- }
},
enabled = enabled,
readOnly = readOnly,
@@ -1360,10 +1453,6 @@
if (expanded) {
stateDescription = suggestionsAvailableSemantics
}
- onClick {
- focusRequester.requestFocus()
- true
- }
},
enabled = enabled,
readOnly = readOnly,
@@ -1499,10 +1588,6 @@
if (expanded) {
stateDescription = suggestionsAvailableSemantics
}
- onClick {
- focusRequester.requestFocus()
- true
- }
},
enabled = enabled,
singleLine = true,
@@ -2125,6 +2210,83 @@
@OptIn(ExperimentalMaterial3Api::class)
@Composable
+private fun DockedSearchBarLayout(
+ state: SearchBarState,
+ inputField: @Composable () -> Unit,
+ modifier: Modifier,
+ shape: Shape,
+ colors: SearchBarColors,
+ tonalElevation: Dp,
+ shadowElevation: Dp,
+ content: @Composable ColumnScope.() -> Unit,
+) {
+ val scope = rememberCoroutineScope()
+ BackHandler(enabled = state.isExpanded) { scope.launch { state.animateToCollapsed() } }
+
+ Surface(
+ shape = shape,
+ color = colors.containerColor,
+ contentColor = contentColorFor(colors.containerColor),
+ tonalElevation = tonalElevation,
+ shadowElevation = shadowElevation,
+ modifier = modifier.imePadding(),
+ ) {
+ val windowContainerHeight = getWindowContainerHeight()
+ val maxHeight = windowContainerHeight * DockedExpandedTableMaxHeightScreenRatio
+ val minHeight = DockedExpandedTableMinHeight.coerceAtMost(maxHeight)
+
+ Layout(
+ contents =
+ listOf(
+ inputField,
+ {
+ Column {
+ HorizontalDivider(color = colors.dividerColor)
+ content()
+ }
+ },
+ )
+ ) { measurables, baseConstraints ->
+ val (inputFieldMeasurables, contentMeasurables) = measurables
+ val constraintMaxHeight =
+ lerp(state.collapsedBounds.height, maxHeight.roundToPx(), state.progress)
+ val constraints =
+ baseConstraints.constrain(
+ Constraints(
+ minHeight = minHeight.roundToPx().coerceAtMost(constraintMaxHeight),
+ maxHeight = constraintMaxHeight,
+ )
+ )
+ val looseConstraints = constraints.copy(minWidth = 0, minHeight = 0)
+
+ val inputFieldPlaceables =
+ inputFieldMeasurables.fastMap { it.measure(looseConstraints) }
+ val inputFieldWidth = inputFieldPlaceables.fastMaxOfOrNull { it.width } ?: 0
+ val inputFieldHeight = inputFieldPlaceables.fastMaxOfOrNull { it.height } ?: 0
+
+ val contentConstraints =
+ looseConstraints
+ .offset(vertical = -inputFieldHeight)
+ .copy(maxWidth = inputFieldWidth)
+ val contentPlaceables = contentMeasurables.fastMap { it.measure(contentConstraints) }
+
+ val height = inputFieldHeight + (contentPlaceables.fastMaxOfOrNull { it.height } ?: 0)
+ val width =
+ max(
+ inputFieldWidth,
+ contentPlaceables.fastMaxOfOrNull { it.width } ?: 0,
+ )
+
+ layout(constraints.constrainWidth(width), constraints.constrainHeight(height)) {
+ inputFieldPlaceables.fastForEach { it.place(0, 0) }
+ contentPlaceables.fastForEach { it.place(0, inputFieldHeight) }
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
private fun FullScreenSearchBarLayout(
state: SearchBarState,
predictiveBackState: PredictiveBackState,
@@ -2224,30 +2386,30 @@
},
) { measurables, constraints ->
val predictiveBackProgress = lastInProgressValue.value.transform()
+ val collapsedWidth =
+ state.collapsedBounds.width.takeIf { it != 0 } ?: SearchBarMinWidth.roundToPx()
+ val collapsedHeight =
+ state.collapsedBounds.height.takeIf { it != 0 } ?: InputFieldHeight.roundToPx()
val predictiveBackEndWidth =
(constraints.maxWidth * SearchBarPredictiveBackMinScale)
.roundToInt()
- .coerceAtLeast(state.collapsedBounds.width)
+ .coerceAtLeast(collapsedWidth)
val predictiveBackEndHeight =
(constraints.maxHeight * SearchBarPredictiveBackMinScale)
.roundToInt()
- .coerceAtLeast(state.collapsedBounds.height)
+ .coerceAtLeast(collapsedHeight)
val endWidth = lerp(constraints.maxWidth, predictiveBackEndWidth, predictiveBackProgress)
val endHeight = lerp(constraints.maxHeight, predictiveBackEndHeight, predictiveBackProgress)
- val width =
- constraints.constrainWidth(lerp(state.collapsedBounds.width, endWidth, state.progress))
- val height =
- constraints.constrainHeight(
- lerp(state.collapsedBounds.height, endHeight, state.progress)
- )
+ val width = constraints.constrainWidth(lerp(collapsedWidth, endWidth, state.progress))
+ val height = constraints.constrainHeight(lerp(collapsedHeight, endHeight, state.progress))
val surfaceMeasurable = measurables.fastFirst { it.layoutId == LayoutIdSurface }
val surfacePlaceable = surfaceMeasurable.measure(Constraints.fixed(width, height))
val inputFieldMeasurable = measurables.fastFirst { it.layoutId == LayoutIdInputField }
val inputFieldPlaceable =
- inputFieldMeasurable.measure(Constraints.fixed(width, state.collapsedBounds.height))
+ inputFieldMeasurable.measure(Constraints.fixed(width, collapsedHeight))
val topPadding = unconsumedInsets.getTop(this@Layout) + SearchBarVerticalPadding.roundToPx()
val bottomPadding = SearchBarVerticalPadding.roundToPx()
@@ -2343,7 +2505,7 @@
@OptIn(ExperimentalMaterial3Api::class)
private val SearchBarState.collapsedBounds: IntRect
get() =
- collapsedCoords?.let { IntRect(offset = it.positionOnScreen().round(), size = it.size) }
+ collapsedCoords?.let { IntRect(offset = it.positionInWindow().round(), size = it.size) }
?: IntRect.Zero
private fun calculatePredictiveBackMultiplier(
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Shapes.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Shapes.kt
index badfc12..4c8d388 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Shapes.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Shapes.kt
@@ -259,7 +259,10 @@
}
/** Cached shapes used in components */
- internal var defaultToggleButtonShapesCached: ButtonShapes? = null
+ @OptIn(ExperimentalMaterial3ExpressiveApi::class)
+ internal var defaultButtonShapesCached: ButtonShapes? = null
+ @OptIn(ExperimentalMaterial3ExpressiveApi::class)
+ internal var defaultToggleButtonShapesCached: ToggleButtonShapes? = null
internal var defaultVerticalDragHandleShapesCached: DragHandleShapes? = null
}
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Slider.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Slider.kt
index 7705df8..94234e8 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Slider.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Slider.kt
@@ -289,8 +289,6 @@
) {
val state =
remember(steps, valueRange) { SliderState(value, steps, onValueChangeFinished, valueRange) }
-
- state.onValueChangeFinished = onValueChangeFinished
state.onValueChange = onValueChange
state.value = value
@@ -2399,23 +2397,23 @@
private var valueState by mutableFloatStateOf(value)
- /**
- * [Float] that indicates the current value that the thumb currently is in respect to the track.
- */
+ /** [Float] that indicates the value that the thumb currently is in respect to the track. */
var value: Float
set(newVal) {
- val coercedValue = newVal.coerceIn(valueRange.start, valueRange.endInclusive)
- val snappedValue =
- snapValueToTick(
- coercedValue,
- tickFractions,
- valueRange.start,
- valueRange.endInclusive
- )
- valueState = snappedValue
+ valueState = calculateSnappedValue(newVal)
}
get() = valueState
+ private fun calculateSnappedValue(newVal: Float): Float {
+ val coercedValue = newVal.coerceIn(valueRange.start, valueRange.endInclusive)
+ return snapValueToTick(
+ coercedValue,
+ tickFractions,
+ valueRange.start,
+ valueRange.endInclusive
+ )
+ }
+
override suspend fun drag(
dragPriority: MutatePriority,
block: suspend DragScope.() -> Unit
@@ -2448,7 +2446,7 @@
}
}
- /** callback in which value should be updated */
+ /** Callback in which value should be updated. */
var onValueChange: ((Float) -> Unit)? = null
internal val tickFractions = stepsToTickFractions(steps)
@@ -2471,7 +2469,7 @@
value.coerceIn(valueRange.start, valueRange.endInclusive)
)
- internal var isDragging by mutableStateOf(false)
+ var isDragging by mutableStateOf(false)
private set
internal fun updateDimensions(
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/TimePicker.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/TimePicker.kt
index f11dc72..4dc22d5 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/TimePicker.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/TimePicker.kt
@@ -17,6 +17,7 @@
package androidx.compose.material3
+import androidx.annotation.FloatRange
import androidx.annotation.IntRange
import androidx.collection.IntList
import androidx.collection.MutableIntList
@@ -165,7 +166,6 @@
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
-import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
@@ -720,6 +720,8 @@
internal class AnalogTimePickerState(val state: TimePickerState) : TimePickerState by state {
+ var currentDiameter by mutableStateOf(0.dp)
+
val currentAngle: Float
get() = anim.value
@@ -915,18 +917,20 @@
internal val AnalogTimePickerState.selectorPos: DpOffset
get() {
- val handleRadiusPx = ClockDialSelectorHandleContainerSize / 2
+ val scale: Float = currentDiameter / ClockDialContainerSize
+ val handleRadiusDp = (ClockDialSelectorHandleContainerSize / 2f) * scale
val selectorLength =
if (is24hour && this.isPm && selection == TimePickerSelectionMode.Hour) {
- InnerCircleRadius
+ currentDiameter * InnerCircleToSizeRatio
} else {
- OuterCircleSizeRadius
+ currentDiameter * OuterCircleToSizeRatio
}
- .minus(handleRadiusPx)
+ .minus(handleRadiusDp)
+ .coerceAtLeast(0.dp)
- val length = selectorLength + handleRadiusPx
- val offsetX = length * cos(currentAngle) + ClockDialContainerSize / 2
- val offsetY = length * sin(currentAngle) + ClockDialContainerSize / 2
+ val length = selectorLength + handleRadiusDp
+ val offsetX = length * cos(currentAngle) + currentDiameter / 2
+ val offsetY = length * sin(currentAngle) + currentDiameter / 2
return DpOffset(offsetX, offsetY)
}
@@ -943,9 +947,14 @@
modifier = modifier.semantics { isTraversalGroup = true },
horizontalAlignment = Alignment.CenterHorizontally
) {
- VerticalClockDisplay(state, colors)
+ VerticalClockDisplay(state = state, colors = colors)
Spacer(modifier = Modifier.height(ClockDisplayBottomMargin))
- ClockFace(state, colors, autoSwitchToMinute)
+ ClockFace(
+ modifier = Modifier.size(ClockDialContainerSize),
+ state = state,
+ colors = colors,
+ autoSwitchToMinute = autoSwitchToMinute
+ )
Spacer(modifier = Modifier.height(ClockFaceBottomMargin))
}
}
@@ -964,7 +973,12 @@
) {
HorizontalClockDisplay(state, colors)
Spacer(modifier = Modifier.width(ClockDisplayBottomMargin))
- ClockFace(state, colors, autoSwitchToMinute)
+ ClockFace(
+ modifier = Modifier.then(ClockFaceSizeModifier()),
+ state,
+ colors,
+ autoSwitchToMinute
+ )
}
}
@@ -1453,9 +1467,13 @@
private var offsetX = 0f
private var offsetY = 0f
- private var center: IntOffset = IntOffset.Zero
+ private var center: IntOffset by mutableStateOf(IntOffset.Zero)
private val maxDist
- get() = with(requireDensity()) { MaxDistance.toPx() }
+ get() =
+ with(requireDensity()) {
+ MaxDistance.toPx() * state.currentDiameter.roundToPx() /
+ ClockDialContainerSize.roundToPx()
+ }
private val pointerInputTapNode =
delegate(
@@ -1498,14 +1516,20 @@
offsetX += dragAmount.x
offsetY += dragAmount.y
state.rotateTo(atan(offsetY - center.y, offsetX - center.x), animationSpec)
+ state.moveSelector(
+ x = offsetX,
+ y = offsetY,
+ maxDist = maxDist,
+ center = center
+ )
}
- state.moveSelector(offsetX, offsetY, maxDist, center)
}
}
)
override fun onRemeasured(size: IntSize) {
center = size.center
+ state.currentDiameter = with(requireDensity()) { size.width.toDp() }
}
override fun onPointerEvent(
@@ -1540,6 +1564,7 @@
@Composable
internal fun ClockFace(
+ modifier: Modifier,
state: AnalogTimePickerState,
colors: TimePickerColors,
autoSwitchToMinute: Boolean
@@ -1547,7 +1572,8 @@
// TODO Load the motionScheme tokens from the component tokens file
Crossfade(
modifier =
- Modifier.background(shape = CircleShape, color = colors.clockDialColor)
+ modifier
+ .background(shape = CircleShape, color = colors.clockDialColor)
.then(
ClockDialModifier(
state,
@@ -1556,14 +1582,13 @@
MotionSchemeKeyTokens.DefaultSpatial.value()
)
)
- .size(ClockDialContainerSize)
.drawSelector(state, colors),
targetState = state.clockFaceValues,
animationSpec = MotionSchemeKeyTokens.DefaultEffects.value()
) { screen ->
CircularLayout(
modifier = Modifier.size(ClockDialContainerSize).semantics { selectableGroup() },
- radius = OuterCircleSizeRadius,
+ radiusToSizeRatio = OuterCircleToSizeRatio,
) {
CompositionLocalProvider(
LocalContentColor provides colors.clockDialContentColor(false)
@@ -1589,7 +1614,7 @@
Modifier.layoutId(LayoutId.InnerCircle)
.size(ClockDialContainerSize)
.background(shape = CircleShape, color = Color.Transparent),
- radius = InnerCircleRadius
+ radiusToSizeRatio = InnerCircleToSizeRatio
) {
repeat(ExtraHours.size) { index ->
val innerValue = ExtraHours[index]
@@ -1615,7 +1640,9 @@
this.drawWithContent {
val selectorOffsetPx = Offset(state.selectorPos.x.toPx(), state.selectorPos.y.toPx())
- val selectorRadius = ClockDialSelectorHandleContainerSize.toPx() / 2
+ val selectorRadius =
+ ClockDialSelectorHandleContainerSize.toPx() / 2f * state.currentDiameter.roundToPx() /
+ ClockDialContainerSize.roundToPx()
val selectorColor = colors.selectorColor
// clear out the selector section
@@ -1910,15 +1937,18 @@
}
}
-/** Distribute elements evenly on a circle of [radius] */
+/**
+ * Distribute children evenly on a circle of radius equal to the height of this layout times
+ * [radiusToSizeRatio].
+ */
@Composable
private fun CircularLayout(
modifier: Modifier = Modifier,
- radius: Dp,
+ @FloatRange(from = 0.0, to = 1.0) radiusToSizeRatio: Float,
content: @Composable () -> Unit,
) {
Layout(modifier = modifier, content = content) { measurables, constraints ->
- val radiusPx = radius.toPx()
+ val radiusPx = constraints.maxHeight * radiusToSizeRatio
val itemConstraints = constraints.copy(minWidth = 0, minHeight = 0)
val placeables =
measurables
@@ -2006,8 +2036,8 @@
private const val RadiansPerHour: Float = FullCircle / 12f
private const val SeparatorZIndex = 2f
-private val OuterCircleSizeRadius = 101.dp
-private val InnerCircleRadius = 69.dp
+private val OuterCircleToSizeRatio: Float = 101.dp / ClockDialContainerSize
+private val InnerCircleToSizeRatio: Float = 69.dp / ClockDialContainerSize
private val ClockDisplayBottomMargin = 36.dp
private val ClockFaceBottomMargin = 24.dp
private val DisplaySeparatorWidth = 24.dp
@@ -2022,6 +2052,11 @@
MutableIntList(Hours.size).apply { Hours.forEach { add((it % 12 + 12)) } }
private val PeriodToggleMargin = 12.dp
+private val TimePickerMaxHeight = 384.dp
+private val TimePickerMidHeight = 330.dp
+private val ClockDialMidContainerSize = 238.dp
+private val ClockDialMinContainerSize = 200.dp
+
/**
* Measure the composable with 0,0 so that it stays on the screen. Necessary to correctly handle
* focus
@@ -2060,3 +2095,22 @@
return visible == otherModifier.visible
}
}
+
+internal class ClockFaceSizeModifier : LayoutModifier {
+
+ override fun MeasureScope.measure(
+ measurable: Measurable,
+ constraints: Constraints
+ ): MeasureResult {
+ var max = constraints.maxHeight.toDp()
+ val size =
+ when {
+ max >= TimePickerMaxHeight -> ClockDialContainerSize
+ max >= TimePickerMidHeight -> ClockDialMidContainerSize
+ else -> ClockDialMinContainerSize
+ }.roundToPx()
+
+ val placeable = measurable.measure(Constraints.fixed(size, size))
+ return layout(placeable.width, placeable.height) { placeable.place(0, 0) }
+ }
+}
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/ToggleButton.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/ToggleButton.kt
index 2f4b4f4..3052150 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/ToggleButton.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/ToggleButton.kt
@@ -67,7 +67,7 @@
* [checked]'s value. It also morphs between the three shapes provided in [shapes] depending on the
* state of the interaction with the toggle button as long as the three shapes provided our
* [CornerBasedShape]s. If a shape in [shapes] isn't a [CornerBasedShape], then toggle button will
- * toggle between the [ButtonShapes] according to user interaction.
+ * toggle between the [ToggleButtonShapes] according to user interaction.
*
* TODO link to an image when available
*
@@ -93,7 +93,7 @@
* @param enabled controls the enabled state of this toggle button. When `false`, this component
* will not respond to user input, and it will appear visually disabled and disabled to
* accessibility services.
- * @param shapes the [ButtonShapes] that the toggle button will morph between depending on the
+ * @param shapes the [ToggleButtonShapes] that the toggle button will morph between depending on the
* user's interaction with the toggle button.
* @param colors [ToggleButtonColors] that will be used to resolve the colors used for this toggle
* button in different states. See [ToggleButtonDefaults.toggleButtonColors].
@@ -117,7 +117,7 @@
onCheckedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
- shapes: ButtonShapes = ToggleButtonDefaults.shapes(),
+ shapes: ToggleButtonShapes = ToggleButtonDefaults.shapes(),
colors: ToggleButtonColors = ToggleButtonDefaults.toggleButtonColors(),
elevation: ButtonElevation? = ButtonDefaults.buttonElevation(),
border: BorderStroke? = null,
@@ -197,7 +197,7 @@
* [checked]'s value. It also morphs between the three shapes provided in [shapes] depending on the
* state of the interaction with the toggle button as long as the three shapes provided our
* [CornerBasedShape]s. If a shape in [shapes] isn't a [CornerBasedShape], then toggle button will
- * toggle between the [ButtonShapes] according to user interaction.
+ * toggle between the [ToggleButtonShapes] according to user interaction.
*
* TODO link to an image when available
*
@@ -214,7 +214,7 @@
* @param enabled controls the enabled state of this toggle button. When `false`, this component
* will not respond to user input, and it will appear visually disabled and disabled to
* accessibility services.
- * @param shapes the [ButtonShapes] that the toggle button will morph between depending on the
+ * @param shapes the [ToggleButtonShapes] that the toggle button will morph between depending on the
* user's interaction with the toggle button.
* @param colors [ToggleButtonColors] that will be used to resolve the colors used for this toggle
* button in different states. See [ToggleButtonDefaults.elevatedToggleButtonColors].
@@ -238,7 +238,7 @@
onCheckedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
- shapes: ButtonShapes = ToggleButtonDefaults.shapes(),
+ shapes: ToggleButtonShapes = ToggleButtonDefaults.shapes(),
colors: ToggleButtonColors = ToggleButtonDefaults.elevatedToggleButtonColors(),
elevation: ButtonElevation? = ButtonDefaults.elevatedButtonElevation(),
border: BorderStroke? = null,
@@ -267,7 +267,7 @@
* [checked]'s value. It also morphs between the three shapes provided in [shapes] depending on the
* state of the interaction with the toggle button as long as the three shapes provided our
* [CornerBasedShape]s. If a shape in [shapes] isn't a [CornerBasedShape], then toggle button will
- * toggle between the [ButtonShapes] according to user interaction.
+ * toggle between the [ToggleButtonShapes] according to user interaction.
*
* TODO link to an image when available
*
@@ -287,7 +287,7 @@
* @param enabled controls the enabled state of this toggle button. When `false`, this component
* will not respond to user input, and it will appear visually disabled and disabled to
* accessibility services.
- * @param shapes the [ButtonShapes] that the toggle button will morph between depending on the
+ * @param shapes the [ToggleButtonShapes] that the toggle button will morph between depending on the
* user's interaction with the toggle button.
* @param colors [ToggleButtonColors] that will be used to resolve the colors used for this toggle
* button in different states. See [ToggleButtonDefaults.tonalToggleButtonColors].
@@ -311,7 +311,7 @@
onCheckedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
- shapes: ButtonShapes = ToggleButtonDefaults.shapes(),
+ shapes: ToggleButtonShapes = ToggleButtonDefaults.shapes(),
colors: ToggleButtonColors = ToggleButtonDefaults.tonalToggleButtonColors(),
elevation: ButtonElevation? = ButtonDefaults.filledTonalButtonElevation(),
border: BorderStroke? = null,
@@ -340,7 +340,7 @@
* [checked]'s value. It also morphs between the three shapes provided in [shapes] depending on the
* state of the interaction with the toggle button as long as the three shapes provided our
* [CornerBasedShape]s. If a shape in [shapes] isn't a [CornerBasedShape], then toggle button will
- * toggle between the [ButtonShapes] according to user interaction.
+ * toggle between the [ToggleButtonShapes] according to user interaction.
*
* TODO link to an image when available
*
@@ -358,7 +358,7 @@
* @param enabled controls the enabled state of this toggle button. When `false`, this component
* will not respond to user input, and it will appear visually disabled and disabled to
* accessibility services.
- * @param shapes the [ButtonShapes] that the toggle button will morph between depending on the
+ * @param shapes the [ToggleButtonShapes] that the toggle button will morph between depending on the
* user's interaction with the toggle button.
* @param colors [ToggleButtonColors] that will be used to resolve the colors used for this toggle
* button in different states. See [ToggleButtonDefaults.outlinedToggleButtonColors].
@@ -382,7 +382,7 @@
onCheckedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
- shapes: ButtonShapes = ToggleButtonDefaults.shapes(),
+ shapes: ToggleButtonShapes = ToggleButtonDefaults.shapes(),
colors: ToggleButtonColors = ToggleButtonDefaults.outlinedToggleButtonColors(),
elevation: ButtonElevation? = null,
border: BorderStroke? = if (!checked) ButtonDefaults.outlinedButtonBorder(enabled) else null,
@@ -439,36 +439,36 @@
)
/**
- * Creates a [ButtonShapes] that represents the default shape, pressedShape, and checkedShape
- * used in a [ToggleButton].
+ * Creates a [ToggleButtonShapes] that represents the default shape, pressedShape, and
+ * checkedShape used in a [ToggleButton].
*/
- @Composable fun shapes() = MaterialTheme.shapes.defaultShapes
+ @Composable fun shapes() = MaterialTheme.shapes.defaultToggleButtonShapes
/**
- * Creates a [ButtonShapes] that represents the default shape, pressedShape, and checkedShape
- * used in a [ToggleButton] and its variants.
+ * Creates a [ToggleButtonShapes] that represents the default shape, pressedShape, and
+ * checkedShape used in a [ToggleButton] and its variants.
*
- * @param shape the unchecked shape for [ButtonShapes]
- * @param pressedShape the unchecked shape for [ButtonShapes]
- * @param checkedShape the unchecked shape for [ButtonShapes]
+ * @param shape the unchecked shape for [ToggleButtonShapes]
+ * @param pressedShape the unchecked shape for [ToggleButtonShapes]
+ * @param checkedShape the unchecked shape for [ToggleButtonShapes]
*/
@Composable
fun shapes(
shape: Shape? = null,
pressedShape: Shape? = null,
checkedShape: Shape? = null
- ): ButtonShapes =
- MaterialTheme.shapes.defaultShapes.copy(
+ ): ToggleButtonShapes =
+ MaterialTheme.shapes.defaultToggleButtonShapes.copy(
shape = shape,
pressedShape = pressedShape,
checkedShape = checkedShape
)
- internal val Shapes.defaultShapes: ButtonShapes
+ internal val Shapes.defaultToggleButtonShapes: ToggleButtonShapes
@Composable
get() {
return defaultToggleButtonShapesCached
- ?: ButtonShapes(
+ ?: ToggleButtonShapes(
shape = shape,
pressedShape = pressedShape,
checkedShape = checkedShape
@@ -885,14 +885,16 @@
* @property pressedShape is the pressed shape.
* @property checkedShape is the checked shape.
*/
-class ButtonShapes(val shape: Shape, val pressedShape: Shape, val checkedShape: Shape) {
- /** Returns a copy of this ButtonShapes, optionally overriding some of the values. */
+@ExperimentalMaterial3ExpressiveApi
+@Immutable
+class ToggleButtonShapes(val shape: Shape, val pressedShape: Shape, val checkedShape: Shape) {
+ /** Returns a copy of this ToggleButtonShapes, optionally overriding some of the values. */
fun copy(
shape: Shape? = this.shape,
pressedShape: Shape? = this.pressedShape,
checkedShape: Shape? = this.checkedShape
) =
- ButtonShapes(
+ ToggleButtonShapes(
shape = shape.takeOrElse { this.shape },
pressedShape = pressedShape.takeOrElse { this.pressedShape },
checkedShape = checkedShape.takeOrElse { this.checkedShape }
@@ -902,7 +904,7 @@
override fun equals(other: Any?): Boolean {
if (this === other) return true
- if (other == null || other !is ButtonShapes) return false
+ if (other == null || other !is ToggleButtonShapes) return false
if (shape != other.shape) return false
if (pressedShape != other.pressedShape) return false
@@ -920,15 +922,17 @@
}
}
-internal val ButtonShapes.hasRoundedCornerShapes: Boolean
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+internal val ToggleButtonShapes.hasRoundedCornerShapes: Boolean
get() =
shape is RoundedCornerShape &&
pressedShape is RoundedCornerShape &&
checkedShape is RoundedCornerShape
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun shapeByInteraction(
- shapes: ButtonShapes,
+ shapes: ToggleButtonShapes,
pressed: Boolean,
checked: Boolean,
animationSpec: FiniteAnimationSpec<Float>
@@ -938,7 +942,9 @@
shapes.pressedShape
} else if (checked) {
shapes.checkedShape
- } else shapes.shape
+ } else {
+ shapes.shape
+ }
if (shapes.hasRoundedCornerShapes)
return key(shapes) {
diff --git a/compose/runtime/runtime/build.gradle b/compose/runtime/runtime/build.gradle
index df41707..d1c6939 100644
--- a/compose/runtime/runtime/build.gradle
+++ b/compose/runtime/runtime/build.gradle
@@ -46,7 +46,6 @@
implementation(libs.kotlinStdlibCommon)
api(libs.kotlinCoroutinesCore)
implementation(project(":collection:collection"))
- implementation(project(":performance:performance-annotation"))
}
}
diff --git a/compose/runtime/runtime/compose-runtime-benchmark/src/androidTest/java/androidx/compose/runtime/benchmark/ComposeBenchmarkBase.kt b/compose/runtime/runtime/compose-runtime-benchmark/src/androidTest/java/androidx/compose/runtime/benchmark/ComposeBenchmarkBase.kt
index 243417a..5998d77 100644
--- a/compose/runtime/runtime/compose-runtime-benchmark/src/androidTest/java/androidx/compose/runtime/benchmark/ComposeBenchmarkBase.kt
+++ b/compose/runtime/runtime/compose-runtime-benchmark/src/androidTest/java/androidx/compose/runtime/benchmark/ComposeBenchmarkBase.kt
@@ -242,18 +242,6 @@
withContext(TestMonotonicFrameClock(this)) { testBody() }
}
-inline fun BenchmarkRule.measureRepeatedSuspendable(block: BenchmarkRule.Scope.() -> Unit) {
- // Note: this is an extension function to discourage calling from Java.
-
- // Extract members to locals, to ensure we check #applied, and we don't hit accessors
- val localState = getState()
- val localScope = scope
-
- while (localState.keepRunningInline()) {
- block(localScope)
- }
-}
-
fun ControlledComposition.performRecompose(
readObserver: (Any) -> Unit,
writeObserver: (Any) -> Unit
diff --git a/compose/runtime/runtime/proguard-rules.pro b/compose/runtime/runtime/proguard-rules.pro
index 400440d..6e78193 100644
--- a/compose/runtime/runtime/proguard-rules.pro
+++ b/compose/runtime/runtime/proguard-rules.pro
@@ -17,7 +17,7 @@
# Keep all the functions created to throw an exception. We don't want these functions to be
# inlined in any way, which R8 will do by default. The whole point of these functions is to
# reduce the amount of code generated at the call site.
--keepclassmembers,allowshrinking,allowobfuscation class androidx.compose.runtime.** {
+-keep,allowshrinking,allowobfuscation class androidx.compose.runtime.** {
# java.lang.Void == methods that return Nothing
static void throw*Exception(...);
static void throw*ExceptionForNullCheck(...);
@@ -31,7 +31,3 @@
static void compose*RuntimeError(...);
static java.lang.Void compose*RuntimeError(...);
}
-
--keepclassmembers class * {
- @dalvik.annotation.optimization.NeverInline *;
-}
diff --git a/compose/runtime/runtime/src/androidMain/kotlin/androidx/compose/runtime/collection/ArrayUtils.android.kt b/compose/runtime/runtime/src/androidMain/kotlin/androidx/compose/runtime/collection/ArrayUtils.android.kt
deleted file mode 100644
index c487bdc..0000000
--- a/compose/runtime/runtime/src/androidMain/kotlin/androidx/compose/runtime/collection/ArrayUtils.android.kt
+++ /dev/null
@@ -1,29 +0,0 @@
-/*
- * 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.
- */
-
-@file:Suppress("NOTHING_TO_INLINE", "KotlinRedundantDiagnosticSuppress")
-
-package androidx.compose.runtime.collection
-
-internal actual inline fun <T> Array<out T>.fastCopyInto(
- destination: Array<T>,
- destinationOffset: Int,
- startIndex: Int,
- endIndex: Int
-): Array<T> {
- System.arraycopy(this, startIndex, destination, destinationOffset, endIndex - startIndex)
- return destination
-}
diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/SlotTable.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/SlotTable.kt
index afd4db85..a295937 100644
--- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/SlotTable.kt
+++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/SlotTable.kt
@@ -23,7 +23,6 @@
import androidx.collection.MutableIntSet
import androidx.collection.MutableObjectList
import androidx.collection.mutableIntListOf
-import androidx.compose.runtime.collection.fastCopyInto
import androidx.compose.runtime.platform.makeSynchronizedObject
import androidx.compose.runtime.platform.synchronized
import androidx.compose.runtime.snapshots.fastAny
@@ -2123,7 +2122,7 @@
// 4) copy the slots to their new location
if (moveDataLen > 0) {
val slots = slots
- slots.fastCopyInto(
+ slots.copyInto(
destination = slots,
destinationOffset = destinationSlot,
startIndex = dataIndexToDataAddress(dataStart + moveDataLen),
@@ -2207,7 +2206,7 @@
)
val slots = toWriter.slots
val currentSlot = toWriter.currentSlot
- fromWriter.slots.fastCopyInto(
+ fromWriter.slots.copyInto(
destination = slots,
destinationOffset = currentSlot,
startIndex = sourceSlotsStart,
@@ -2677,7 +2676,7 @@
val slots = slots
if (index < gapStart) {
// move the gap down to index by shifting the data up.
- slots.fastCopyInto(
+ slots.copyInto(
destination = slots,
destinationOffset = index + gapLen,
startIndex = index,
@@ -2685,7 +2684,7 @@
)
} else {
// Shift the data down, leaving the gap at index
- slots.fastCopyInto(
+ slots.copyInto(
destination = slots,
destinationOffset = gapStart,
startIndex = gapStart + gapLen,
@@ -2831,13 +2830,13 @@
val newGapEndAddress = gapStart + newGapLen
// Copy the old arrays into the new arrays
- slots.fastCopyInto(
+ slots.copyInto(
destination = newData,
destinationOffset = 0,
startIndex = 0,
endIndex = gapStart
)
- slots.fastCopyInto(
+ slots.copyInto(
destination = newData,
destinationOffset = newGapEndAddress,
startIndex = oldGapEndAddress,
diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Stack.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Stack.kt
index c2e25cd..03989dd 100644
--- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Stack.kt
+++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Stack.kt
@@ -14,13 +14,9 @@
* limitations under the License.
*/
-@file:Suppress("NOTHING_TO_INLINE", "KotlinRedundantDiagnosticSuppress")
-
package androidx.compose.runtime
-import kotlin.jvm.JvmField
import kotlin.jvm.JvmInline
-import kotlin.math.min
@JvmInline
internal value class Stack<T>(private val backing: ArrayList<T> = ArrayList()) {
@@ -46,33 +42,22 @@
}
internal class IntStack {
- @JvmField internal var slots = IntArray(10)
- @JvmField internal var tos = 0
+ private var slots = IntArray(10)
+ private var tos = 0
- inline val size: Int
+ val size: Int
get() = tos
- @dalvik.annotation.optimization.NeverInline
- private fun resize(): IntArray {
- val copy = slots.copyOf(slots.size * 2)
- slots = copy
- return copy
- }
-
fun push(value: Int) {
- var slots = slots
if (tos >= slots.size) {
- slots = resize()
+ slots = slots.copyOf(slots.size * 2)
}
slots[tos++] = value
}
fun pop(): Int = slots[--tos]
- fun peekOr(default: Int): Int {
- val index = tos - 1
- return if (index >= 0) slots[index] else default
- }
+ fun peekOr(default: Int): Int = if (tos > 0) peek() else default
fun peek() = slots[tos - 1]
@@ -80,20 +65,16 @@
fun peek(index: Int) = slots[index]
- inline fun isEmpty() = tos == 0
+ fun isEmpty() = tos == 0
- inline fun isNotEmpty() = tos != 0
+ fun isNotEmpty() = tos != 0
fun clear() {
tos = 0
}
fun indexOf(value: Int): Int {
- val slots = slots
- val end = min(slots.size, tos)
- for (i in 0 until end) {
- if (slots[i] == value) return i
- }
+ for (i in 0 until tos) if (slots[i] == value) return i
return -1
}
}
diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/changelist/Operations.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/changelist/Operations.kt
index 6a1339b..f250bc8 100644
--- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/changelist/Operations.kt
+++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/changelist/Operations.kt
@@ -24,10 +24,8 @@
import androidx.compose.runtime.RememberManager
import androidx.compose.runtime.SlotWriter
import androidx.compose.runtime.changelist.Operation.ObjectParameter
-import androidx.compose.runtime.collection.fastCopyInto
import androidx.compose.runtime.debugRuntimeCheck
import androidx.compose.runtime.requirePrecondition
-import dalvik.annotation.optimization.NeverInline
import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.InvocationKind.EXACTLY_ONCE
import kotlin.contracts.contract
@@ -135,12 +133,11 @@
return (currentSize + resizeAmount).coerceAtLeast(requiredSize)
}
- @NeverInline
private fun resizeOpCodes() {
val resizeAmount = opCodesSize.coerceAtMost(OperationsMaxResizeAmount)
@Suppress("UNCHECKED_CAST")
val newOpCodes = arrayOfNulls<Operation>(opCodesSize + resizeAmount) as Array<Operation>
- opCodes = opCodes.fastCopyInto(newOpCodes, 0, 0, opCodesSize)
+ opCodes = opCodes.copyInto(newOpCodes, 0, 0, opCodesSize)
}
private inline fun ensureIntArgsSizeAtLeast(requiredSize: Int) {
@@ -150,7 +147,6 @@
}
}
- @NeverInline
private fun resizeIntArgs(currentSize: Int, requiredSize: Int) {
val newIntArgs = IntArray(determineNewSize(currentSize, requiredSize))
intArgs.copyInto(newIntArgs, 0, 0, currentSize)
@@ -164,10 +160,9 @@
}
}
- @NeverInline
private fun resizeObjectArgs(currentSize: Int, requiredSize: Int) {
val newObjectArgs = arrayOfNulls<Any>(determineNewSize(currentSize, requiredSize))
- objectArgs.fastCopyInto(newObjectArgs, 0, 0, currentSize)
+ objectArgs.copyInto(newObjectArgs, 0, 0, currentSize)
objectArgs = newObjectArgs
}
@@ -296,7 +291,7 @@
other.pushOp(op)
// Move the objects then null out our contents
- objectArgs.fastCopyInto(
+ objectArgs.copyInto(
destination = other.objectArgs,
destinationOffset = other.objectArgsSize - op.objects,
startIndex = objectArgsSize - op.objects,
diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/collection/ArrayUtils.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/collection/ArrayUtils.kt
deleted file mode 100644
index 03c2546..0000000
--- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/collection/ArrayUtils.kt
+++ /dev/null
@@ -1,28 +0,0 @@
-/*
- * 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.runtime.collection
-
-/**
- * Equivalent of Array.copyInto() with an implementation designed to avoid unnecessary null checks
- * and exception throws on Android after inlining.
- */
-internal expect fun <T> Array<out T>.fastCopyInto(
- destination: Array<T>,
- destinationOffset: Int,
- startIndex: Int,
- endIndex: Int
-): Array<T>
diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/collection/MutableVector.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/collection/MutableVector.kt
index 8144a7b..1315b32 100644
--- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/collection/MutableVector.kt
+++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/collection/MutableVector.kt
@@ -18,7 +18,6 @@
package androidx.compose.runtime.collection
-import dalvik.annotation.optimization.NeverInline
import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.contract
import kotlin.jvm.JvmField
@@ -67,7 +66,7 @@
ensureCapacity(size + 1)
val content = content
if (index != size) {
- content.fastCopyInto(
+ content.copyInto(
destination = content,
destinationOffset = index + 1,
startIndex = index,
@@ -84,13 +83,12 @@
*/
fun addAll(index: Int, elements: List<T>): Boolean {
if (elements.isEmpty()) return false
- val elementsSize = elements.size
- ensureCapacity(size + elementsSize)
+ ensureCapacity(size + elements.size)
val content = content
if (index != size) {
- content.fastCopyInto(
+ content.copyInto(
destination = content,
- destinationOffset = index + elementsSize,
+ destinationOffset = index + elements.size,
startIndex = index,
endIndex = size
)
@@ -98,7 +96,7 @@
for (i in elements.indices) {
content[index + i] = elements[i]
}
- size += elementsSize
+ size += elements.size
return true
}
@@ -107,25 +105,24 @@
* that are in the way.
*/
fun addAll(index: Int, elements: MutableVector<T>): Boolean {
- val elementsSize = elements.size
- if (elementsSize == 0) return false
- ensureCapacity(size + elementsSize)
+ if (elements.isEmpty()) return false
+ ensureCapacity(size + elements.size)
val content = content
if (index != size) {
- content.fastCopyInto(
+ content.copyInto(
destination = content,
- destinationOffset = index + elementsSize,
+ destinationOffset = index + elements.size,
startIndex = index,
endIndex = size
)
}
- elements.content.fastCopyInto(
+ elements.content.copyInto(
destination = content,
destinationOffset = index,
startIndex = 0,
- endIndex = elementsSize
+ endIndex = elements.size
)
- size += elementsSize
+ size += elements.size
return true
}
@@ -150,13 +147,12 @@
* [MutableVector] was changed.
*/
fun addAll(@Suppress("ArrayReturn") elements: Array<T>): Boolean {
- val elementsSize = elements.size
- if (elementsSize == 0) {
+ if (elements.isEmpty()) {
return false
}
- ensureCapacity(size + elementsSize)
- elements.fastCopyInto(destination = content, destinationOffset = size, 0, elementsSize)
- size += elementsSize
+ ensureCapacity(size + elements.size)
+ elements.copyInto(destination = content, destinationOffset = size)
+ size += elements.size
return true
}
@@ -166,19 +162,18 @@
*/
fun addAll(index: Int, elements: Collection<T>): Boolean {
if (elements.isEmpty()) return false
- val elementsSize = elements.size
- ensureCapacity(size + elementsSize)
+ ensureCapacity(size + elements.size)
val content = content
if (index != size) {
- content.fastCopyInto(
+ content.copyInto(
destination = content,
- destinationOffset = index + elementsSize,
+ destinationOffset = index + elements.size,
startIndex = index,
endIndex = size
)
}
elements.forEachIndexed { i, item -> content[index + i] = item }
- size += elementsSize
+ size += elements.size
return true
}
@@ -292,14 +287,13 @@
}
}
- @NeverInline
@PublishedApi
internal fun resizeStorage(capacity: Int) {
val oldContent = content
val oldSize = oldContent.size
val newSize = max(capacity, oldSize * 2)
val newContent = arrayOfNulls<Any?>(newSize) as Array<T?>
- oldContent.fastCopyInto(newContent, 0, 0, oldSize)
+ oldContent.copyInto(newContent, 0, 0, oldSize)
content = newContent
}
@@ -700,7 +694,7 @@
val content = content
val item = content[index] as T
if (index != lastIndex) {
- content.fastCopyInto(
+ content.copyInto(
destination = content,
destinationOffset = index,
startIndex = index + 1,
@@ -716,7 +710,7 @@
fun removeRange(start: Int, end: Int) {
if (end > start) {
if (end < size) {
- content.fastCopyInto(
+ content.copyInto(
destination = content,
destinationOffset = start,
startIndex = end,
diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/SnapshotWeakSet.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/SnapshotWeakSet.kt
index e70d6fd..66075f0a 100644
--- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/SnapshotWeakSet.kt
+++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/SnapshotWeakSet.kt
@@ -17,7 +17,6 @@
package androidx.compose.runtime.snapshots
import androidx.compose.runtime.TestOnly
-import androidx.compose.runtime.collection.fastCopyInto
import androidx.compose.runtime.internal.WeakReference
import androidx.compose.runtime.internal.identityHashCode
@@ -71,18 +70,13 @@
val newCapacity = capacity * 2
val newValues = arrayOfNulls<WeakReference<T>?>(newCapacity)
val newHashes = IntArray(newCapacity)
- values.fastCopyInto(
+ values.copyInto(
destination = newValues,
destinationOffset = insertIndex + 1,
startIndex = insertIndex,
endIndex = size
)
- values.fastCopyInto(
- destination = newValues,
- destinationOffset = 0,
- startIndex = 0,
- endIndex = insertIndex
- )
+ values.copyInto(destination = newValues, endIndex = insertIndex)
hashes.copyInto(
destination = newHashes,
destinationOffset = insertIndex + 1,
@@ -93,7 +87,7 @@
values = newValues
hashes = newHashes
} else {
- values.fastCopyInto(
+ values.copyInto(
destination = values,
destinationOffset = insertIndex + 1,
startIndex = insertIndex,
diff --git a/compose/ui/ui-graphics/proguard-rules.pro b/compose/ui/ui-graphics/proguard-rules.pro
index 72f4a6c..67d118b 100644
--- a/compose/ui/ui-graphics/proguard-rules.pro
+++ b/compose/ui/ui-graphics/proguard-rules.pro
@@ -15,7 +15,7 @@
# Keep all the functions created to throw an exception. We don't want these functions to be
# inlined in any way, which R8 will do by default. The whole point of these functions is to
# reduce the amount of code generated at the call site.
--keepclassmembers,allowshrinking,allowobfuscation class androidx.compose.**.* {
+-keep,allowshrinking,allowobfuscation class androidx.compose.**.* {
static void throw*Exception(...);
static void throw*ExceptionForNullCheck(...);
# For methods returning Nothing
diff --git a/compose/ui/ui-graphics/src/commonMain/kotlin/androidx/compose/ui/graphics/IntervalTree.kt b/compose/ui/ui-graphics/src/commonMain/kotlin/androidx/compose/ui/graphics/IntervalTree.kt
index 8a2ff33..1d3b503 100644
--- a/compose/ui/ui-graphics/src/commonMain/kotlin/androidx/compose/ui/graphics/IntervalTree.kt
+++ b/compose/ui/ui-graphics/src/commonMain/kotlin/androidx/compose/ui/graphics/IntervalTree.kt
@@ -82,7 +82,8 @@
// structure beyond what can be found in various descriptions of binary search
// trees and red/black trees
- @JvmField internal val terminator = Node(Float.MAX_VALUE, Float.MIN_VALUE, null, TreeColorBlack)
+ @JvmField
+ internal val terminator = Node(Float.MAX_VALUE, Float.MIN_VALUE, null, TreeColor.Black)
@JvmField internal var root = terminator
@JvmField internal val stack = ArrayList<Node>()
@@ -202,7 +203,7 @@
* @param data Data to associate with the interval
*/
fun addInterval(start: Float, end: Float, data: T?) {
- val node = Node(start, end, data, TreeColorRed)
+ val node = Node(start, end, data, TreeColor.Red)
// Update the tree without doing any balancing
var current = root
@@ -238,44 +239,44 @@
private fun rebalance(target: Node) {
var node = target
- while (node !== root && node.parent.color == TreeColorRed) {
+ while (node !== root && node.parent.color == TreeColor.Red) {
val ancestor = node.parent.parent
if (node.parent === ancestor.left) {
val right = ancestor.right
- if (right.color == TreeColorRed) {
- right.color = TreeColorBlack
- node.parent.color = TreeColorBlack
- ancestor.color = TreeColorRed
+ if (right.color == TreeColor.Red) {
+ right.color = TreeColor.Black
+ node.parent.color = TreeColor.Black
+ ancestor.color = TreeColor.Red
node = ancestor
} else {
if (node === node.parent.right) {
node = node.parent
rotateLeft(node)
}
- node.parent.color = TreeColorBlack
- ancestor.color = TreeColorRed
+ node.parent.color = TreeColor.Black
+ ancestor.color = TreeColor.Red
rotateRight(ancestor)
}
} else {
val left = ancestor.left
- if (left.color == TreeColorRed) {
- left.color = TreeColorBlack
- node.parent.color = TreeColorBlack
- ancestor.color = TreeColorRed
+ if (left.color == TreeColor.Red) {
+ left.color = TreeColor.Black
+ node.parent.color = TreeColor.Black
+ ancestor.color = TreeColor.Red
node = ancestor
} else {
if (node === node.parent.left) {
node = node.parent
rotateRight(node)
}
- node.parent.color = TreeColorBlack
- ancestor.color = TreeColorRed
+ node.parent.color = TreeColor.Black
+ ancestor.color = TreeColor.Red
rotateLeft(ancestor)
}
}
}
- root.color = TreeColorBlack
+ root.color = TreeColor.Black
}
private fun rotateLeft(node: Node) {
@@ -339,6 +340,11 @@
}
}
+ internal enum class TreeColor {
+ Red,
+ Black
+ }
+
internal inner class Node(start: Float, end: Float, data: T?, var color: TreeColor) :
Interval<T>(start, end, data) {
var min: Float = start
@@ -372,8 +378,3 @@
}
}
}
-
-private typealias TreeColor = Int
-
-private const val TreeColorRed = 0
-private const val TreeColorBlack = 1
diff --git a/compose/ui/ui-graphics/src/commonMain/kotlin/androidx/compose/ui/graphics/PathGeometry.kt b/compose/ui/ui-graphics/src/commonMain/kotlin/androidx/compose/ui/graphics/PathGeometry.kt
index 4c96e1e..53b0d54 100644
--- a/compose/ui/ui-graphics/src/commonMain/kotlin/androidx/compose/ui/graphics/PathGeometry.kt
+++ b/compose/ui/ui-graphics/src/commonMain/kotlin/androidx/compose/ui/graphics/PathGeometry.kt
@@ -58,7 +58,7 @@
// segments.
var type = iterator.next(points)
while (type != PathSegment.Type.Done) {
- @Suppress("KotlinConstantConditions", "RedundantSuppression")
+ @Suppress("KotlinConstantConditions")
when (type) {
PathSegment.Type.Move -> {
if (!first) {
@@ -175,7 +175,7 @@
var type = iterator.next(points)
while (type != PathSegment.Type.Done) {
- @Suppress("KotlinConstantConditions", "RedundantSuppression")
+ @Suppress("KotlinConstantConditions")
when (type) {
PathSegment.Type.Move -> {
if (!first && !isEmpty) {
diff --git a/compose/ui/ui-graphics/src/commonMain/kotlin/androidx/compose/ui/graphics/colorspace/ColorSpace.kt b/compose/ui/ui-graphics/src/commonMain/kotlin/androidx/compose/ui/graphics/colorspace/ColorSpace.kt
index ec0cc69..3905cff 100644
--- a/compose/ui/ui-graphics/src/commonMain/kotlin/androidx/compose/ui/graphics/colorspace/ColorSpace.kt
+++ b/compose/ui/ui-graphics/src/commonMain/kotlin/androidx/compose/ui/graphics/colorspace/ColorSpace.kt
@@ -633,7 +633,7 @@
* @param r2: The third element of the vector
* @return The first element of the resulting multiplication.
*/
-@Suppress("NOTHING_TO_INLINE", "KotlinRedundantDiagnosticSuppress")
+@Suppress("NOTHING_TO_INLINE")
internal inline fun mul3x3Float3_0(lhs: FloatArray, r0: Float, r1: Float, r2: Float): Float {
return lhs[0] * r0 + lhs[3] * r1 + lhs[6] * r2
}
@@ -648,7 +648,7 @@
* @param r2: The third element of the vector
* @return The second element of the resulting multiplication.
*/
-@Suppress("NOTHING_TO_INLINE", "KotlinRedundantDiagnosticSuppress")
+@Suppress("NOTHING_TO_INLINE")
internal inline fun mul3x3Float3_1(lhs: FloatArray, r0: Float, r1: Float, r2: Float): Float {
return lhs[1] * r0 + lhs[4] * r1 + lhs[7] * r2
}
@@ -663,7 +663,7 @@
* @param r2: The third element of the vector
* @return The third element of the resulting multiplication.
*/
-@Suppress("NOTHING_TO_INLINE", "KotlinRedundantDiagnosticSuppress")
+@Suppress("NOTHING_TO_INLINE")
internal inline fun mul3x3Float3_2(lhs: FloatArray, r0: Float, r1: Float, r2: Float): Float {
return lhs[2] * r0 + lhs[5] * r1 + lhs[8] * r2
}
diff --git a/compose/ui/ui-graphics/src/commonMain/kotlin/androidx/compose/ui/graphics/colorspace/Connector.kt b/compose/ui/ui-graphics/src/commonMain/kotlin/androidx/compose/ui/graphics/colorspace/Connector.kt
index 2a81bac..7a47484 100644
--- a/compose/ui/ui-graphics/src/commonMain/kotlin/androidx/compose/ui/graphics/colorspace/Connector.kt
+++ b/compose/ui/ui-graphics/src/commonMain/kotlin/androidx/compose/ui/graphics/colorspace/Connector.kt
@@ -230,7 +230,7 @@
chromaticAdaptation(
Adaptation.Bradford.transform,
srcXYZ,
- Illuminant.newD50Xyz()
+ Illuminant.D50Xyz.copyOf()
)
transform = mul3x3(srcAdaptation, source.transform)
}
@@ -240,7 +240,7 @@
chromaticAdaptation(
Adaptation.Bradford.transform,
dstXYZ,
- Illuminant.newD50Xyz()
+ Illuminant.D50Xyz.copyOf()
)
inverseTransform = inverse3x3(mul3x3(dstAdaptation, destination.transform))
}
diff --git a/compose/ui/ui-graphics/src/commonMain/kotlin/androidx/compose/ui/graphics/colorspace/Illuminant.kt b/compose/ui/ui-graphics/src/commonMain/kotlin/androidx/compose/ui/graphics/colorspace/Illuminant.kt
index 0bf3461..b3598c3 100644
--- a/compose/ui/ui-graphics/src/commonMain/kotlin/androidx/compose/ui/graphics/colorspace/Illuminant.kt
+++ b/compose/ui/ui-graphics/src/commonMain/kotlin/androidx/compose/ui/graphics/colorspace/Illuminant.kt
@@ -73,6 +73,4 @@
val E = WhitePoint(0.33333f, 0.33333f)
internal val D50Xyz = floatArrayOf(0.964212f, 1.0f, 0.825188f)
-
- internal fun newD50Xyz() = floatArrayOf(0.964212f, 1.0f, 0.825188f)
}
diff --git a/compose/ui/ui-graphics/src/commonMain/kotlin/androidx/compose/ui/graphics/colorspace/Rgb.kt b/compose/ui/ui-graphics/src/commonMain/kotlin/androidx/compose/ui/graphics/colorspace/Rgb.kt
index e9d3032..06f2891 100644
--- a/compose/ui/ui-graphics/src/commonMain/kotlin/androidx/compose/ui/graphics/colorspace/Rgb.kt
+++ b/compose/ui/ui-graphics/src/commonMain/kotlin/androidx/compose/ui/graphics/colorspace/Rgb.kt
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-@file:Suppress("NOTHING_TO_INLINE", "KotlinRedundantDiagnosticSuppress")
+@file:Suppress("NOTHING_TO_INLINE")
package androidx.compose.ui.graphics.colorspace
@@ -853,9 +853,10 @@
var result = super.hashCode()
result = 31 * result + whitePoint.hashCode()
result = 31 * result + primaries.contentHashCode()
- result = 31 * result + (if (min != 0.0f) min.toBits() else 0)
- result = 31 * result + (if (max != 0.0f) max.toBits() else 0)
- result = (31 * result + (transferParameters?.hashCode() ?: 0))
+ result = 31 * result + (if (min != +0.0f) min.toBits() else 0)
+ result = 31 * result + (if (max != +0.0f) max.toBits() else 0)
+ result =
+ (31 * result + if (transferParameters != null) transferParameters.hashCode() else 0)
if (transferParameters == null) {
result = 31 * result + oetfOrig.hashCode()
result = 31 * result + eotfOrig.hashCode()
diff --git a/compose/ui/ui-text/proguard-rules.pro b/compose/ui/ui-text/proguard-rules.pro
index 72f4a6c..67d118b 100644
--- a/compose/ui/ui-text/proguard-rules.pro
+++ b/compose/ui/ui-text/proguard-rules.pro
@@ -15,7 +15,7 @@
# Keep all the functions created to throw an exception. We don't want these functions to be
# inlined in any way, which R8 will do by default. The whole point of these functions is to
# reduce the amount of code generated at the call site.
--keepclassmembers,allowshrinking,allowobfuscation class androidx.compose.**.* {
+-keep,allowshrinking,allowobfuscation class androidx.compose.**.* {
static void throw*Exception(...);
static void throw*ExceptionForNullCheck(...);
# For methods returning Nothing
diff --git a/compose/ui/ui-unit/proguard-rules.pro b/compose/ui/ui-unit/proguard-rules.pro
index 72f4a6c..67d118b 100644
--- a/compose/ui/ui-unit/proguard-rules.pro
+++ b/compose/ui/ui-unit/proguard-rules.pro
@@ -15,7 +15,7 @@
# Keep all the functions created to throw an exception. We don't want these functions to be
# inlined in any way, which R8 will do by default. The whole point of these functions is to
# reduce the amount of code generated at the call site.
--keepclassmembers,allowshrinking,allowobfuscation class androidx.compose.**.* {
+-keep,allowshrinking,allowobfuscation class androidx.compose.**.* {
static void throw*Exception(...);
static void throw*ExceptionForNullCheck(...);
# For methods returning Nothing
diff --git a/compose/ui/ui-unit/src/commonMain/kotlin/androidx/compose/ui/unit/Constraints.kt b/compose/ui/ui-unit/src/commonMain/kotlin/androidx/compose/ui/unit/Constraints.kt
index 80e1938..79ba2da 100644
--- a/compose/ui/ui-unit/src/commonMain/kotlin/androidx/compose/ui/unit/Constraints.kt
+++ b/compose/ui/ui-unit/src/commonMain/kotlin/androidx/compose/ui/unit/Constraints.kt
@@ -368,16 +368,10 @@
private const val MinFocusBits = 16
private const val MaxAllowedForMinFocusBits = (1 shl (31 - MinFocusBits)) - 2
-/** The mask to use for the focused dimension when there is minimal focus. */
-private const val MinFocusMask = 0xFFFF // 64K (16 bits)
-
/** The number of bits used for the non-focused dimension when there is minimal focus. */
private const val MinNonFocusBits = 15
private const val MaxAllowedForMinNonFocusBits = (1 shl (31 - MinNonFocusBits)) - 2
-/** The mask to use for the non-focused dimension when there is minimal focus. */
-private const val MinNonFocusMask = 0x7FFF // 32K (15 bits)
-
/** The number of bits to use for the focused dimension when there is maximal focus. */
private const val MaxFocusBits = 18
private const val MaxAllowedForMaxFocusBits = (1 shl (31 - MaxFocusBits)) - 2
@@ -389,9 +383,6 @@
private const val MaxNonFocusBits = 13
private const val MaxAllowedForMaxNonFocusBits = (1 shl (31 - MaxNonFocusBits)) - 2
-/** The mask to use for the non-focused dimension when there is maximal focus. */
-private const val MaxNonFocusMask = 0x1FFF // 8K (13 bits)
-
// 0xFFFFFFFE_00000003UL.toLong(), written as a signed value to declare it const
@PublishedApi internal const val MaxDimensionsAndFocusMask = -0x00000001_FFFFFFFDL
@@ -452,22 +443,35 @@
}
internal fun bitsNeedForSizeUnchecked(size: Int): Int {
+ // We could look at the value of size itself, for instance by doing:
+ // when {
+ // size < MaxNonFocusMask -> MaxNonFocusBits
+ // ...
+ // }
+ // but the following solution saves a few instructions by avoiding
+ // multiple moves to load large constants
+ val bits = (size + 1).countLeadingZeroBits()
return when {
- size < MaxNonFocusMask -> MaxNonFocusBits
- size < MinNonFocusMask -> MinNonFocusBits
- size < MinFocusMask -> MinFocusBits
- size < MaxFocusMask -> MaxFocusBits
+ bits >= 32 - MaxNonFocusBits -> MaxNonFocusBits
+ bits >= 32 - MinNonFocusBits -> MinNonFocusBits
+ bits >= 32 - MinFocusBits -> MinFocusBits
+ bits >= 32 - MaxFocusBits -> MaxFocusBits
else -> 255
}
}
private inline fun maxAllowedForSize(size: Int): Int {
+ // See comment in bitsNeedForSizeUnchecked()
+ // Note: the return value in every case is `1 shl (31 - bits) - 2`
+ // However, computing the value instead of using constants uses more
+ // instructions, so not worth it
+ val bits = (size + 1).countLeadingZeroBits()
+ if (bits <= 13) throwInvalidConstraintsSizeException(size)
return when {
- size < MaxNonFocusMask -> MaxAllowedForMaxNonFocusBits
- size < MinNonFocusMask -> MaxAllowedForMinNonFocusBits
- size < MinFocusMask -> MaxAllowedForMinFocusBits
- size < MaxFocusMask -> MaxAllowedForMaxFocusBits
- else -> throwInvalidConstraintsSizeException(size)
+ bits >= 32 - MaxNonFocusBits -> MaxAllowedForMaxNonFocusBits
+ bits >= 32 - MinNonFocusBits -> MaxAllowedForMinNonFocusBits
+ bits >= 32 - MinFocusBits -> MaxAllowedForMinFocusBits
+ else -> MaxAllowedForMaxFocusBits
}
}
diff --git a/compose/ui/ui-unit/src/commonMain/kotlin/androidx/compose/ui/unit/Density.kt b/compose/ui/ui-unit/src/commonMain/kotlin/androidx/compose/ui/unit/Density.kt
index cbefe8a..5b3e3c5 100644
--- a/compose/ui/ui-unit/src/commonMain/kotlin/androidx/compose/ui/unit/Density.kt
+++ b/compose/ui/ui-unit/src/commonMain/kotlin/androidx/compose/ui/unit/Density.kt
@@ -65,7 +65,7 @@
*/
@Stable
fun TextUnit.toPx(): Float {
- checkPrecondition(type == TextUnitType.Sp) { "Only Sp can convert to Px" }
+ check(type == TextUnitType.Sp) { "Only Sp can convert to Px" }
return toDp().toPx()
}
diff --git a/compose/ui/ui-unit/src/commonMain/kotlin/androidx/compose/ui/unit/Dp.kt b/compose/ui/ui-unit/src/commonMain/kotlin/androidx/compose/ui/unit/Dp.kt
index e5cac56..a88b3c0 100644
--- a/compose/ui/ui-unit/src/commonMain/kotlin/androidx/compose/ui/unit/Dp.kt
+++ b/compose/ui/ui-unit/src/commonMain/kotlin/androidx/compose/ui/unit/Dp.kt
@@ -83,10 +83,7 @@
/** Infinite dp dimension. */
@Stable val Infinity = Dp(Float.POSITIVE_INFINITY)
- /**
- * Constant that means unspecified Dp. Instead of comparing a [Dp] value to this constant,
- * consider using [isSpecified] and [isUnspecified] instead.
- */
+ /** Constant that means unspecified Dp */
@Stable val Unspecified = Dp(Float.NaN)
}
}
diff --git a/compose/ui/ui-util/proguard-rules.pro b/compose/ui/ui-util/proguard-rules.pro
index 72f4a6c..67d118b 100644
--- a/compose/ui/ui-util/proguard-rules.pro
+++ b/compose/ui/ui-util/proguard-rules.pro
@@ -15,7 +15,7 @@
# Keep all the functions created to throw an exception. We don't want these functions to be
# inlined in any way, which R8 will do by default. The whole point of these functions is to
# reduce the amount of code generated at the call site.
--keepclassmembers,allowshrinking,allowobfuscation class androidx.compose.**.* {
+-keep,allowshrinking,allowobfuscation class androidx.compose.**.* {
static void throw*Exception(...);
static void throw*ExceptionForNullCheck(...);
# For methods returning Nothing
diff --git a/compose/ui/ui/api/current.txt b/compose/ui/ui/api/current.txt
index 5a884ad..2b88ca8 100644
--- a/compose/ui/ui/api/current.txt
+++ b/compose/ui/ui/api/current.txt
@@ -228,7 +228,6 @@
public abstract class AutofillManager {
method public abstract void cancel();
method public abstract void commit();
- method public abstract void requestAutofillForActiveElement();
}
public final class AutofillNode {
@@ -2860,6 +2859,7 @@
public final class DelegatableNodeKt {
method public static void invalidateSubtree(androidx.compose.ui.node.DelegatableNode);
+ method public static void requestAutofill(androidx.compose.ui.node.DelegatableNode);
method public static androidx.compose.ui.unit.Density requireDensity(androidx.compose.ui.node.DelegatableNode);
method public static androidx.compose.ui.graphics.GraphicsContext requireGraphicsContext(androidx.compose.ui.node.DelegatableNode);
method public static androidx.compose.ui.layout.LayoutCoordinates requireLayoutCoordinates(androidx.compose.ui.node.DelegatableNode);
diff --git a/compose/ui/ui/api/restricted_current.txt b/compose/ui/ui/api/restricted_current.txt
index 2c0d712..8b114f0 100644
--- a/compose/ui/ui/api/restricted_current.txt
+++ b/compose/ui/ui/api/restricted_current.txt
@@ -228,7 +228,6 @@
public abstract class AutofillManager {
method public abstract void cancel();
method public abstract void commit();
- method public abstract void requestAutofillForActiveElement();
}
public final class AutofillNode {
@@ -2914,6 +2913,7 @@
public final class DelegatableNodeKt {
method public static void invalidateSubtree(androidx.compose.ui.node.DelegatableNode);
+ method public static void requestAutofill(androidx.compose.ui.node.DelegatableNode);
method public static androidx.compose.ui.unit.Density requireDensity(androidx.compose.ui.node.DelegatableNode);
method public static androidx.compose.ui.graphics.GraphicsContext requireGraphicsContext(androidx.compose.ui.node.DelegatableNode);
method public static androidx.compose.ui.layout.LayoutCoordinates requireLayoutCoordinates(androidx.compose.ui.node.DelegatableNode);
diff --git a/compose/ui/ui/build.gradle b/compose/ui/ui/build.gradle
index 3a1ada1..73d3551 100644
--- a/compose/ui/ui/build.gradle
+++ b/compose/ui/ui/build.gradle
@@ -24,6 +24,7 @@
import androidx.build.KotlinTarget
import androidx.build.LibraryType
+import androidx.build.KmpPlatformsKt
import androidx.build.PlatformIdentifier
import static androidx.inspection.gradle.InspectionPluginKt.packageInspector
@@ -59,8 +60,6 @@
api(project(":compose:ui:ui-util"))
api("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
-
- implementation(project(":performance:performance-annotation"))
}
}
diff --git a/compose/ui/ui/proguard-rules.pro b/compose/ui/ui/proguard-rules.pro
index 1f592ff3..fa5b4c3 100644
--- a/compose/ui/ui/proguard-rules.pro
+++ b/compose/ui/ui/proguard-rules.pro
@@ -35,7 +35,7 @@
# Keep all the functions created to throw an exception. We don't want these functions to be
# inlined in any way, which R8 will do by default. The whole point of these functions is to
# reduce the amount of code generated at the call site.
--keepclassmembers,allowshrinking,allowobfuscation class androidx.compose.**.* {
+-keep,allowshrinking,allowobfuscation class androidx.compose.**.* {
static void throw*Exception(...);
static void throw*ExceptionForNullCheck(...);
# For methods returning Nothing
@@ -52,7 +52,3 @@
-keepnames class androidx.compose.ui.input.pointer.PointerInputEventHandler {
*;
}
-
--keepclassmembers class * {
- @dalvik.annotation.optimization.NeverInline *;
-}
diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/autofill/AndroidAutofillManagerTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/autofill/AndroidAutofillManagerTest.kt
index d6ce34a..25dcd50 100644
--- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/autofill/AndroidAutofillManagerTest.kt
+++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/autofill/AndroidAutofillManagerTest.kt
@@ -42,11 +42,14 @@
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.focus.FocusManager
-import androidx.compose.ui.focus.focusProperties
+import androidx.compose.ui.node.SemanticsModifierNode
+import androidx.compose.ui.node.elementOf
+import androidx.compose.ui.node.requestAutofill
import androidx.compose.ui.platform.LocalAutofillManager
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.semantics.SemanticsPropertyReceiver
import androidx.compose.ui.semantics.contentDataType
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.contentType
@@ -713,68 +716,6 @@
@Test
@SmallTest
- @SdkSuppress(minSdkVersion = 26)
- fun autofillManager_requestAutofillAfterFocus() {
- val am: PlatformAutofillManager = mock()
- val contextMenuTag = "menu_tag"
- var autofillManager: AutofillManager?
-
- rule.setContent {
- autofillManager = LocalAutofillManager.current
- (autofillManager as AndroidAutofillManager).platformAutofillManager = am
- Box(
- modifier =
- Modifier.semantics {
- testTag = contextMenuTag
- onAutofillText { true }
- }
- .focusProperties { canFocus = true }
- .clickable { autofillManager?.requestAutofillForActiveElement() }
- .size(height, width)
- )
- }
-
- // `requestAutofill` is always called after an element is focused
- rule.onNodeWithTag(contextMenuTag).requestFocus()
- rule.runOnIdle { verify(am).notifyViewEntered(any(), any(), any()) }
-
- // then `requestAutofill` is called on that same previously focused element
- rule.onNodeWithTag(contextMenuTag).performClick()
- rule.runOnIdle { verify(am).requestAutofill(any(), any(), any()) }
- }
-
- @Test
- @SmallTest
- @SdkSuppress(minSdkVersion = 26)
- fun autofillManager_notAutofillable_doesNotrequestAutofillAfterFocus() {
- val am: PlatformAutofillManager = mock()
- val contextMenuTag = "menu_tag"
- var autofillManager: AutofillManager?
-
- rule.setContent {
- autofillManager = LocalAutofillManager.current
- (autofillManager as AndroidAutofillManager).platformAutofillManager = am
- Box(
- modifier =
- Modifier.semantics { testTag = contextMenuTag }
- .focusProperties { canFocus = true }
- .clickable { autofillManager?.requestAutofillForActiveElement() }
- .size(height, width)
- )
- }
- clearInvocations(am)
-
- // `requestAutofill` is always called after an element is focused
- rule.onNodeWithTag(contextMenuTag).requestFocus()
- rule.runOnIdle { verifyZeroInteractions(am) }
-
- // then `requestAutofill` is called on that same previously focused element
- rule.onNodeWithTag(contextMenuTag).performClick()
- rule.runOnIdle { verifyNoMoreInteractions(am) }
- }
-
- @Test
- @SmallTest
fun autofillManager_lazyColumnScroll_callsCommit() {
lateinit var state: LazyListState
lateinit var coroutineScope: CoroutineScope
@@ -866,4 +807,35 @@
// A column disappearing will call commit
rule.runOnIdle { verify(am).commit() }
}
+
+ @Test
+ @SmallTest
+ @SdkSuppress(minSdkVersion = 26)
+ fun autofillManager_requestAutofill() {
+ val am: PlatformAutofillManager = mock()
+ val semanticsModifier = TestSemanticsModifier { testTag = "TestTag" }
+ var autofillManager: AutofillManager?
+
+ rule.setContent {
+ autofillManager = LocalAutofillManager.current
+ (autofillManager as AndroidAutofillManager).platformAutofillManager = am
+ Box(Modifier.elementOf(semanticsModifier))
+ }
+
+ // Act
+ rule.runOnIdle { semanticsModifier.requestAutofill() }
+
+ // Assert
+ rule.runOnIdle { verify(am).requestAutofill(any(), any(), any()) }
+ }
+
+ private class TestSemanticsModifier(
+ private val onApplySemantics: SemanticsPropertyReceiver.() -> Unit
+ ) : SemanticsModifierNode, Modifier.Node() {
+
+ override fun SemanticsPropertyReceiver.applySemantics() {
+ contentType = ContentType.Username
+ onApplySemantics.invoke(this)
+ }
+ }
}
diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/HitPathTrackerTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/HitPathTrackerTest.kt
index 84314e3e..1d90f0b 100644
--- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/HitPathTrackerTest.kt
+++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/HitPathTrackerTest.kt
@@ -3510,6 +3510,10 @@
override fun requestFocus(): Boolean = false
+ override fun requestAutofill(node: LayoutNode) {
+ TODO("Not yet implemented")
+ }
+
override fun measureAndLayout(sendPointerUpdate: Boolean) {}
override fun measureAndLayout(layoutNode: LayoutNode, constraints: Constraints) {}
diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/PointerInputEventProcessorTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/PointerInputEventProcessorTest.kt
index 3f9150a..ffb3fec 100644
--- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/PointerInputEventProcessorTest.kt
+++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/PointerInputEventProcessorTest.kt
@@ -2846,6 +2846,10 @@
override fun requestFocus(): Boolean = false
+ override fun requestAutofill(node: LayoutNode) {
+ TODO("Not yet implemented")
+ }
+
override val rootForTest: RootForTest
get() = TODO("Not yet implemented")
diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/layout/Helpers.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/layout/Helpers.kt
index 76aa7ce..4322a1c 100644
--- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/layout/Helpers.kt
+++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/layout/Helpers.kt
@@ -281,6 +281,10 @@
override fun requestFocus() = TODO("Not yet implemented")
+ override fun requestAutofill(node: LayoutNode) {
+ TODO("Not yet implemented")
+ }
+
override fun onSemanticsChange() {}
override fun getFocusDirection(keyEvent: KeyEvent) = TODO("Not yet implemented")
diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/node/NodeChainTester.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/node/NodeChainTester.kt
index a36713d..5d5e649 100644
--- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/node/NodeChainTester.kt
+++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/node/NodeChainTester.kt
@@ -521,6 +521,10 @@
override fun requestFocus(): Boolean = false
+ override fun requestAutofill(node: LayoutNode) {
+ TODO("Not yet implemented")
+ }
+
override fun measureAndLayout(sendPointerUpdate: Boolean) {}
override fun measureAndLayout(layoutNode: LayoutNode, constraints: Constraints) {}
diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/semantics/SemanticsModifierNodeTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/semantics/SemanticsModifierNodeTest.kt
index 7cfa2d4c..c49374e 100644
--- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/semantics/SemanticsModifierNodeTest.kt
+++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/semantics/SemanticsModifierNodeTest.kt
@@ -77,9 +77,7 @@
// Assert.
rule.runOnIdle {
if (precomputedSemantics) {
- // One invocation when the modifier node calls autoInvalidateNodeSelf and another
- // when the Layout node is attached.
- assertThat(semanticsModifier.applySemanticsInvocations).isEqualTo(2)
+ assertThat(semanticsModifier.applySemanticsInvocations).isEqualTo(1)
} else {
assertThat(semanticsModifier.applySemanticsInvocations).isEqualTo(0)
}
@@ -194,7 +192,7 @@
// Assert.
rule.runOnIdle {
assertThat(semanticsModifier.applySemanticsInvocations)
- .isEqualTo(if (precomputedSemantics) 8 else 2)
+ .isEqualTo(if (precomputedSemantics) 7 else 2)
}
}
diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/semantics/SemanticsTests.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/semantics/SemanticsTests.kt
index 8e4a41b..4c213f7 100644
--- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/semantics/SemanticsTests.kt
+++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/semantics/SemanticsTests.kt
@@ -26,12 +26,16 @@
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.RecomposeScope
import androidx.compose.runtime.ReusableContent
import androidx.compose.runtime.Stable
+import androidx.compose.runtime.currentRecomposeScope
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
+import androidx.compose.ui.ComposeUiFlags
+import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.ContentDataType
import androidx.compose.ui.autofill.ContentType
@@ -134,6 +138,40 @@
.assert(SemanticsMatcher.expectValue(SemanticsProperties.PaneTitle, paneTitleString))
}
+ @OptIn(ExperimentalComposeUiApi::class)
+ @Test
+ fun testSemanticsCalculatedOncePerComposition() {
+ var recomposeScope: RecomposeScope? = null
+ var count = 0
+ fun Modifier.count() = semantics { count++ }
+ rule.setContent {
+ recomposeScope = currentRecomposeScope
+ Box(
+ modifier = Modifier.count().count().count(),
+ )
+ }
+ rule.runOnIdle {
+ if (ComposeUiFlags.isSemanticAutofillEnabled) {
+ // with autofill on, semantics is eagerly evaluated
+ assertThat(count).isEqualTo(3)
+ } else {
+ // before autofill, semantics was lazily evaluated
+ assertThat(count).isEqualTo(0)
+ }
+ count = 0
+ recomposeScope!!.invalidate()
+ }
+ rule.runOnIdle {
+ if (ComposeUiFlags.isSemanticAutofillEnabled) {
+ // with autofill on, semantics is eagerly evaluated
+ assertThat(count).isEqualTo(3)
+ } else {
+ // before autofill, semantics was lazily evaluated
+ assertThat(count).isEqualTo(0)
+ }
+ }
+ }
+
@Test
@Suppress("DEPRECATION")
fun isContainerProperty_unmergedConfig() {
diff --git a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/autofill/AndroidAutofillManager.android.kt b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/autofill/AndroidAutofillManager.android.kt
index 69f31cc..4d49938 100644
--- a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/autofill/AndroidAutofillManager.android.kt
+++ b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/autofill/AndroidAutofillManager.android.kt
@@ -77,19 +77,6 @@
platformAutofillManager.cancel()
}
- // This will be used to request autofill when
- // `AutofillManager.requestAutofillForActiveElement()` is called (e.g. from the text toolbar).
- private var previouslyFocusedId = -1
-
- override fun requestAutofillForActiveElement() {
- if (previouslyFocusedId <= 0) return
-
- rectManager.rects.withRect(previouslyFocusedId) { left, top, right, bottom ->
- reusableRect.set(left, top, right, bottom)
- platformAutofillManager.requestAutofill(view, previouslyFocusedId, reusableRect)
- }
- }
-
override fun onFocusChanged(
previous: FocusTargetModifierNode?,
current: FocusTargetModifierNode?
@@ -105,7 +92,6 @@
rectManager.rects.withRect(semanticsId) { l, t, r, b ->
platformAutofillManager.notifyViewEntered(view, semanticsId, Rect(l, t, r, b))
}
- previouslyFocusedId = semanticsId
}
}
}
@@ -138,7 +124,6 @@
val previousFocus = prevConfig?.getOrNull(SemanticsProperties.Focused)
val currFocus = config?.getOrNull(SemanticsProperties.Focused)
if (previousFocus != true && currFocus == true && config.isAutofillable()) {
- previouslyFocusedId = semanticsId
rectManager.rects.withRect(semanticsId) { l, t, r, b ->
platformAutofillManager.notifyViewEntered(view, semanticsId, Rect(l, t, r, b))
}
@@ -236,6 +221,13 @@
private var currentlyDisplayedIDs = MutableIntSet()
private var pendingChangesToDisplayedIds = false
+ internal fun requestAutofill(semanticsInfo: SemanticsInfo) {
+ rectManager.rects.withRect(semanticsInfo.semanticsId) { left, top, right, bottom ->
+ reusableRect.set(left, top, right, bottom)
+ platformAutofillManager.requestAutofill(view, semanticsInfo.semanticsId, reusableRect)
+ }
+ }
+
internal fun onPostAttach(semanticsInfo: SemanticsInfo) {
if (semanticsInfo.semanticsConfiguration?.isRelatedToAutoCommit() == true) {
currentlyDisplayedIDs.add(semanticsInfo.semanticsId)
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 c059f5d..7da948c 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
@@ -1247,6 +1247,13 @@
}
}
+ override fun requestAutofill(node: LayoutNode) {
+ @OptIn(ExperimentalComposeUiApi::class)
+ if (autofillSupported() && ComposeUiFlags.isSemanticAutofillEnabled) {
+ _autofillManager?.requestAutofill(node)
+ }
+ }
+
fun requestClearInvalidObservations() {
observationClearRequested = true
}
diff --git a/compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/node/DelegatingNodeTest.kt b/compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/node/DelegatingNodeTest.kt
index 0328f65..25e747f 100644
--- a/compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/node/DelegatingNodeTest.kt
+++ b/compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/node/DelegatingNodeTest.kt
@@ -776,7 +776,8 @@
check(owner is MockOwner)
owner.onRequestMeasureParams.clear()
owner.invalidatedLayers.clear()
- owner.semanticsChanged = false
+ layoutNode.isSemanticsInvalidated = false
+ // owner.semanticsChanged = false
}
private fun NodeChain.layoutInvalidated(): Boolean {
@@ -792,9 +793,7 @@
}
private fun NodeChain.semanticsInvalidated(): Boolean {
- val owner = layoutNode.owner
- check(owner is MockOwner)
- return owner.semanticsChanged
+ return layoutNode.isSemanticsInvalidated
}
internal fun layout(
diff --git a/compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/node/LayoutNodeTest.kt b/compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/node/LayoutNodeTest.kt
index f13b1f1..faa645f 100644
--- a/compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/node/LayoutNodeTest.kt
+++ b/compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/node/LayoutNodeTest.kt
@@ -2448,6 +2448,10 @@
override fun requestFocus(): Boolean = false
+ override fun requestAutofill(node: LayoutNode) {
+ TODO("Not yet implemented")
+ }
+
override fun measureAndLayout(sendPointerUpdate: Boolean) {}
override fun measureAndLayout(layoutNode: LayoutNode, constraints: Constraints) {}
@@ -2538,11 +2542,7 @@
}
}
- var semanticsChanged: Boolean = false
-
- override fun onSemanticsChange() {
- semanticsChanged = true
- }
+ override fun onSemanticsChange() {}
override fun onLayoutChange(layoutNode: LayoutNode) {
layoutChangeCount++
diff --git a/compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/node/ModifierLocalConsumerEntityTest.kt b/compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/node/ModifierLocalConsumerEntityTest.kt
index 4fdd4ea..de1b8d6 100644
--- a/compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/node/ModifierLocalConsumerEntityTest.kt
+++ b/compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/node/ModifierLocalConsumerEntityTest.kt
@@ -446,6 +446,10 @@
override fun requestFocus() = TODO("Not yet implemented")
+ override fun requestAutofill(node: LayoutNode) {
+ TODO("Not yet implemented")
+ }
+
override fun measureAndLayout(sendPointerUpdate: Boolean) = TODO("Not yet implemented")
override fun measureAndLayout(layoutNode: LayoutNode, constraints: Constraints) {
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/autofill/AutofillManager.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/autofill/AutofillManager.kt
index 2a6163b..f5ebdc7 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/autofill/AutofillManager.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/autofill/AutofillManager.kt
@@ -42,14 +42,4 @@
* without processing any information entered in the autofillable field.
*/
abstract fun cancel()
-
- /**
- * Request autofill for previously focused element.
- *
- * This may have no effect, and it is not required that any autofill service will be notified.
- *
- * Any component that can be autofilled may call this when it is active to request an autofill
- * services response.
- */
- abstract fun requestAutofillForActiveElement()
}
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/key/KeyInputModifier.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/key/KeyInputModifier.kt
index cba5ff1..d60c1b5 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/key/KeyInputModifier.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/key/KeyInputModifier.kt
@@ -46,7 +46,7 @@
fun Modifier.onPreviewKeyEvent(onPreviewKeyEvent: (KeyEvent) -> Boolean): Modifier =
this then KeyInputElement(onKeyEvent = null, onPreKeyEvent = onPreviewKeyEvent)
-private data class KeyInputElement(
+private class KeyInputElement(
val onKeyEvent: ((KeyEvent) -> Boolean)?,
val onPreKeyEvent: ((KeyEvent) -> Boolean)?
) : ModifierNodeElement<KeyInputNode>() {
@@ -67,6 +67,21 @@
properties["onPreviewKeyEvent"] = it
}
}
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is KeyInputElement) return false
+
+ if (onKeyEvent !== other.onKeyEvent) return false
+ if (onPreKeyEvent !== other.onPreKeyEvent) return false
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = onKeyEvent?.hashCode() ?: 0
+ result = 31 * result + (onPreKeyEvent?.hashCode() ?: 0)
+ return result
+ }
}
private class KeyInputNode(
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/pointer/util/PointerIdArray.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/pointer/util/PointerIdArray.kt
index f295af3..d96d294 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/pointer/util/PointerIdArray.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/pointer/util/PointerIdArray.kt
@@ -19,7 +19,6 @@
package androidx.compose.ui.input.pointer.util
import androidx.compose.ui.input.pointer.PointerId
-import dalvik.annotation.optimization.NeverInline
/**
* This collection is specifically for dealing with [PointerId] values. We know that they contain
@@ -145,7 +144,6 @@
if (index >= size) size = index + 1
}
- @NeverInline
private fun resizeStorage(minSize: Int): LongArray {
return internalArray.copyOf(maxOf(minSize, internalArray.size * 2)).apply {
internalArray = this
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/DelegatableNode.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/DelegatableNode.kt
index 08505aa..8b45c3f 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/DelegatableNode.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/DelegatableNode.kt
@@ -332,6 +332,13 @@
checkPreconditionNotNull(requireLayoutNode().owner) { "This node does not have an owner." }
/**
+ * Requests autofill for the LayoutNode that this [DelegatableNode] is attached to. If the node does
+ * not have any autofill semantic properties set, then the request still may be sent to the Autofill
+ * service, but no response is expected.
+ */
+fun DelegatableNode.requestAutofill() = requireLayoutNode().requestAutofill()
+
+/**
* Returns the current [Density] of the LayoutNode that this [DelegatableNode] is attached to. If
* the node is not attached, this function will throw an [IllegalStateException].
*/
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutNode.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutNode.kt
index 5f60779..ef5efb6 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutNode.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutNode.kt
@@ -400,7 +400,15 @@
override fun isTransparent(): Boolean = outerCoordinator.isTransparent()
- private var isSemanticsInvalidated = false
+ internal var isSemanticsInvalidated = false
+
+ internal fun requestAutofill() {
+ // Ignore calls while semantics are being applied (b/378114177).
+ if (isCurrentlyCalculatingSemanticsConfiguration) return
+
+ val owner = requireOwner()
+ owner.requestAutofill(this)
+ }
internal fun invalidateSemantics() {
// Ignore calls to invalidate Semantics while semantics are being applied (b/378114177).
@@ -923,6 +931,9 @@
requirePrecondition(!isDeactivated) { "modifier is updated when deactivated" }
if (isAttached) {
applyModifier(value)
+ if (isSemanticsInvalidated) {
+ invalidateSemantics()
+ }
} else {
pendingModifier = value
}
@@ -935,19 +946,6 @@
if (lookaheadRoot == null && nodes.has(Nodes.ApproachMeasure)) {
lookaheadRoot = this
}
- // Notify semantics listeners if semantics was invalidated.
- @OptIn(ExperimentalComposeUiApi::class)
- if (ComposeUiFlags.isSemanticAutofillEnabled && isSemanticsInvalidated) {
- val prev = _semanticsConfiguration
- _semanticsConfiguration = calculateSemanticsConfiguration()
- isSemanticsInvalidated = false
- val owner = requireOwner()
- owner.semanticsOwner.notifySemanticsChange(this, prev)
-
- // This is needed for Accessibility and ContentCapture. Remove after these systems
- // are migrated to use SemanticsInfo and SemanticListeners.
- owner.onSemanticsChange()
- }
}
private fun resetModifierState() {
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/MyersDiff.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/MyersDiff.kt
index 1d84d69..acc7620 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/MyersDiff.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/MyersDiff.kt
@@ -18,7 +18,6 @@
package androidx.compose.ui.node
import androidx.compose.ui.internal.checkPrecondition
-import dalvik.annotation.optimization.NeverInline
import kotlin.jvm.JvmInline
import kotlin.math.abs
import kotlin.math.min
@@ -418,7 +417,6 @@
val size: Int
get() = lastIndex
- @NeverInline
private fun resizeStack(stack: IntArray): IntArray {
val copy = stack.copyOf(stack.size * 2)
this.stack = copy
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/NodeKind.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/NodeKind.kt
index a4f5fe6..eb27c9a 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/NodeKind.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/NodeKind.kt
@@ -331,7 +331,7 @@
node.invalidateDraw()
}
if (Nodes.Semantics in selfKindSet && node is SemanticsModifierNode) {
- node.invalidateSemantics()
+ node.requireLayoutNode().isSemanticsInvalidated = true
}
if (Nodes.ParentData in selfKindSet && node is ParentDataModifierNode) {
node.invalidateParentData()
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/Owner.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/Owner.kt
index a75bf87..ee7342a 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/Owner.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/Owner.kt
@@ -245,6 +245,9 @@
*/
fun requestFocus(): Boolean
+ /** Ask the system to request autofill values to this owner. */
+ fun requestAutofill(node: LayoutNode)
+
/**
* Iterates through all LayoutNodes that have requested layout and measures and lays them out.
* If [sendPointerUpdate] is `true` then a simulated PointerEvent may be sent to update pointer
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/spatial/RectList.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/spatial/RectList.kt
index ab2eb07..259ae65 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/spatial/RectList.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/spatial/RectList.kt
@@ -14,11 +14,10 @@
* limitations under the License.
*/
-@file:Suppress("NOTHING_TO_INLINE", "KotlinRedundantDiagnosticSuppress")
+@file:Suppress("NOTHING_TO_INLINE")
package androidx.compose.ui.spatial
-import dalvik.annotation.optimization.NeverInline
import kotlin.jvm.JvmField
import kotlin.math.max
import kotlin.math.min
@@ -99,24 +98,19 @@
* keep this in mind if you call this method and have cached any of those values in a local
* variable, you may need to refresh them.
*/
- private inline fun allocateItemsIndex(): Int {
+ internal fun allocateItemsIndex(): Int {
val currentItems = items
val currentSize = itemsSize
itemsSize = currentSize + LongsPerItem
val actualSize = currentItems.size
if (actualSize <= currentSize + LongsPerItem) {
- resizeStorage(actualSize, currentSize, currentItems)
+ val newSize = max(actualSize * 2, currentSize + LongsPerItem)
+ items = currentItems.copyOf(newSize)
+ stack = stack.copyOf(newSize)
}
return currentSize
}
- @NeverInline
- private fun resizeStorage(actualSize: Int, currentSize: Int, currentItems: LongArray) {
- val newSize = max(actualSize * 2, currentSize + LongsPerItem)
- items = currentItems.copyOf(newSize)
- stack = stack.copyOf(newSize)
- }
-
/**
* Insert a value and corresponding bounding rectangle into the RectList. This method does not
* check to see that [value] doesn't already exist somewhere in the list.
diff --git a/constraintlayout/constraintlayout-compose/api/current.ignore b/constraintlayout/constraintlayout-compose/api/current.ignore
new file mode 100644
index 0000000..dfb0518a
--- /dev/null
+++ b/constraintlayout/constraintlayout-compose/api/current.ignore
@@ -0,0 +1,9 @@
+// Baseline format: 1.0
+RemovedMethod: androidx.constraintlayout.compose.DebugFlags#getShowBounds():
+ Removed method androidx.constraintlayout.compose.DebugFlags.getShowBounds()
+RemovedMethod: androidx.constraintlayout.compose.DebugFlags#getShowKeyPositions():
+ Removed method androidx.constraintlayout.compose.DebugFlags.getShowKeyPositions()
+RemovedMethod: androidx.constraintlayout.compose.DebugFlags#getShowPaths():
+ Removed method androidx.constraintlayout.compose.DebugFlags.getShowPaths()
+RemovedMethod: androidx.constraintlayout.compose.GridFlag#isPlaceLayoutsOnSpansFirst():
+ Removed method androidx.constraintlayout.compose.GridFlag.isPlaceLayoutsOnSpansFirst()
diff --git a/constraintlayout/constraintlayout-compose/api/restricted_current.ignore b/constraintlayout/constraintlayout-compose/api/restricted_current.ignore
new file mode 100644
index 0000000..dfb0518a
--- /dev/null
+++ b/constraintlayout/constraintlayout-compose/api/restricted_current.ignore
@@ -0,0 +1,9 @@
+// Baseline format: 1.0
+RemovedMethod: androidx.constraintlayout.compose.DebugFlags#getShowBounds():
+ Removed method androidx.constraintlayout.compose.DebugFlags.getShowBounds()
+RemovedMethod: androidx.constraintlayout.compose.DebugFlags#getShowKeyPositions():
+ Removed method androidx.constraintlayout.compose.DebugFlags.getShowKeyPositions()
+RemovedMethod: androidx.constraintlayout.compose.DebugFlags#getShowPaths():
+ Removed method androidx.constraintlayout.compose.DebugFlags.getShowPaths()
+RemovedMethod: androidx.constraintlayout.compose.GridFlag#isPlaceLayoutsOnSpansFirst():
+ Removed method androidx.constraintlayout.compose.GridFlag.isPlaceLayoutsOnSpansFirst()
diff --git a/constraintlayout/constraintlayout-compose/api/restricted_current.txt b/constraintlayout/constraintlayout-compose/api/restricted_current.txt
index 467bc76..339b479 100644
--- a/constraintlayout/constraintlayout-compose/api/restricted_current.txt
+++ b/constraintlayout/constraintlayout-compose/api/restricted_current.txt
@@ -679,8 +679,7 @@
method public final void drawDebugBounds(androidx.compose.ui.graphics.drawscope.DrawScope, float forcedScaleFactor);
method public String getDesignInfo(int startX, int startY, String args);
method public final float getForcedScaleFactor();
- method @Deprecated protected final java.util.Map<androidx.compose.ui.layout.Measurable,androidx.constraintlayout.core.state.WidgetFrame> getFrameCache();
- method protected final java.util.Map<java.lang.String,androidx.constraintlayout.core.state.WidgetFrame> getFrameCache2();
+ method protected final java.util.Map<androidx.compose.ui.layout.Measurable,androidx.constraintlayout.core.state.WidgetFrame> getFrameCache();
method public final int getLayoutCurrentHeight();
method public final int getLayoutCurrentWidth();
method protected final androidx.constraintlayout.compose.LayoutInformationReceiver? getLayoutInformationReceiver();
@@ -689,16 +688,12 @@
method protected final androidx.constraintlayout.compose.State getState();
method public void measure(androidx.constraintlayout.core.widgets.ConstraintWidget constraintWidget, androidx.constraintlayout.core.widgets.analyzer.BasicMeasure.Measure measure);
method public final void parseDesignElements(androidx.constraintlayout.compose.ConstraintSet constraintSet);
- method @Deprecated public final void performLayout(androidx.compose.ui.layout.Placeable.PlacementScope, java.util.List<? extends androidx.compose.ui.layout.Measurable> measurables);
- method public final void performLayout(androidx.compose.ui.layout.Placeable.PlacementScope, java.util.List<? extends androidx.compose.ui.layout.Measurable> measurables, java.util.Map<androidx.compose.ui.layout.Measurable,androidx.compose.ui.layout.Placeable> placeableMap);
- method @Deprecated public final long performMeasure(long constraints, androidx.compose.ui.unit.LayoutDirection layoutDirection, androidx.constraintlayout.compose.ConstraintSet constraintSet, java.util.List<? extends androidx.compose.ui.layout.Measurable> measurables, int optimizationLevel);
- method public final long performMeasure(long constraints, androidx.compose.ui.unit.LayoutDirection layoutDirection, androidx.constraintlayout.compose.ConstraintSet constraintSet, java.util.List<? extends androidx.compose.ui.layout.Measurable> measurables, java.util.Map<androidx.compose.ui.layout.Measurable,androidx.compose.ui.layout.Placeable> placeableMap, int optimizationLevel);
+ method public final void performLayout(androidx.compose.ui.layout.Placeable.PlacementScope, java.util.List<? extends androidx.compose.ui.layout.Measurable> measurables);
+ method public final long performMeasure(long constraints, androidx.compose.ui.unit.LayoutDirection layoutDirection, androidx.constraintlayout.compose.ConstraintSet constraintSet, java.util.List<? extends androidx.compose.ui.layout.Measurable> measurables, int optimizationLevel);
method public final void setForcedScaleFactor(float);
method protected final void setLayoutInformationReceiver(androidx.constraintlayout.compose.LayoutInformationReceiver?);
- method protected final void setPlaceables(java.util.Map<androidx.compose.ui.layout.Measurable,androidx.compose.ui.layout.Placeable>);
property public final float forcedScaleFactor;
- property @Deprecated protected final java.util.Map<androidx.compose.ui.layout.Measurable,androidx.constraintlayout.core.state.WidgetFrame> frameCache;
- property protected final java.util.Map<java.lang.String,androidx.constraintlayout.core.state.WidgetFrame> frameCache2;
+ property protected final java.util.Map<androidx.compose.ui.layout.Measurable,androidx.constraintlayout.core.state.WidgetFrame> frameCache;
property public final int layoutCurrentHeight;
property public final int layoutCurrentWidth;
property protected final androidx.constraintlayout.compose.LayoutInformationReceiver? layoutInformationReceiver;
diff --git a/constraintlayout/constraintlayout-compose/src/androidInstrumentedTest/kotlin/androidx/constraintlayout/compose/ConstraintLayoutTest.kt b/constraintlayout/constraintlayout-compose/src/androidInstrumentedTest/kotlin/androidx/constraintlayout/compose/ConstraintLayoutTest.kt
index c6ccca1..e7f7289 100644
--- a/constraintlayout/constraintlayout-compose/src/androidInstrumentedTest/kotlin/androidx/constraintlayout/compose/ConstraintLayoutTest.kt
+++ b/constraintlayout/constraintlayout-compose/src/androidInstrumentedTest/kotlin/androidx/constraintlayout/compose/ConstraintLayoutTest.kt
@@ -44,7 +44,6 @@
import androidx.compose.ui.graphics.asAndroidBitmap
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.layout.FirstBaseline
-import androidx.compose.ui.layout.LookaheadScope
import androidx.compose.ui.layout.boundsInParent
import androidx.compose.ui.layout.layout
import androidx.compose.ui.layout.layoutId
@@ -2455,138 +2454,6 @@
assertEquals(IntSize(rootSizePx.fastRoundToInt(), 0), layoutSize)
}
- @Test
- fun testToggleVisibilityWithFillConstraintsWidth() =
- with(rule.density) {
- val rootSizePx = 100f
-
- var toggleVisibility by mutableStateOf(false)
-
- rule.setContent {
- // Regression test, modify only dimensions if necessary
- ConstraintLayout(modifier = Modifier.size(rootSizePx.toDp())) {
- val (titleRef, detailRef) = createRefs()
- Box(
- modifier =
- Modifier.background(Color.Cyan).testTag("box1").constrainAs(detailRef) {
- centerHorizontallyTo(parent)
-
- width = Dimension.fillToConstraints
- height = rootSizePx.toDp().asDimension()
-
- visibility =
- if (!toggleVisibility) Visibility.Gone else Visibility.Visible
- }
- )
- Box(
- modifier =
- Modifier.background(Color.Red).testTag("box2").constrainAs(titleRef) {
- centerHorizontallyTo(parent)
-
- width = Dimension.fillToConstraints
- height = rootSizePx.toDp().asDimension()
-
- visibility =
- if (toggleVisibility) Visibility.Gone else Visibility.Visible
- }
- )
- }
- }
- rule.waitForIdle()
-
- rule.onNodeWithTag("box1").apply {
- assertWidthIsEqualTo(Dp.Unspecified)
- assertHeightIsEqualTo(Dp.Unspecified)
- }
- rule.onNodeWithTag("box2").apply {
- assertWidthIsEqualTo(rootSizePx.toDp())
- assertHeightIsEqualTo(rootSizePx.toDp())
- }
-
- toggleVisibility = !toggleVisibility
- rule.waitForIdle()
-
- rule.onNodeWithTag("box1").apply {
- assertWidthIsEqualTo(rootSizePx.toDp())
- assertHeightIsEqualTo(rootSizePx.toDp())
- }
- rule.onNodeWithTag("box2").apply {
- assertWidthIsEqualTo(Dp.Unspecified)
- assertHeightIsEqualTo(Dp.Unspecified)
- }
- Unit // Test expects to return Unit
- }
-
- @Test
- fun testToggleVisibilityWithFillConstraintsWidth_underLookahead() =
- with(rule.density) {
- val rootSizePx = 100f
-
- var toggleVisibility by mutableStateOf(false)
-
- rule.setContent {
- LookaheadScope {
- // Regression test, modify only dimensions if necessary
- ConstraintLayout(modifier = Modifier.size(rootSizePx.toDp())) {
- val (titleRef, detailRef) = createRefs()
- Box(
- modifier =
- Modifier.background(Color.Cyan).testTag("box1").constrainAs(
- detailRef
- ) {
- centerHorizontallyTo(parent)
-
- width = Dimension.fillToConstraints
- height = rootSizePx.toDp().asDimension()
-
- visibility =
- if (!toggleVisibility) Visibility.Gone
- else Visibility.Visible
- }
- )
- Box(
- modifier =
- Modifier.background(Color.Red).testTag("box2").constrainAs(
- titleRef
- ) {
- centerHorizontallyTo(parent)
-
- width = Dimension.fillToConstraints
- height = rootSizePx.toDp().asDimension()
-
- visibility =
- if (toggleVisibility) Visibility.Gone
- else Visibility.Visible
- }
- )
- }
- }
- }
- rule.waitForIdle()
-
- rule.onNodeWithTag("box1").apply {
- assertWidthIsEqualTo(Dp.Unspecified)
- assertHeightIsEqualTo(Dp.Unspecified)
- }
- rule.onNodeWithTag("box2").apply {
- assertWidthIsEqualTo(rootSizePx.toDp())
- assertHeightIsEqualTo(rootSizePx.toDp())
- }
-
- toggleVisibility = !toggleVisibility
- rule.waitForIdle()
-
- rule.onNodeWithTag("box1").apply {
- assertWidthIsEqualTo(rootSizePx.toDp())
- assertHeightIsEqualTo(rootSizePx.toDp())
- }
- rule.onNodeWithTag("box2").apply {
- assertWidthIsEqualTo(Dp.Unspecified)
- assertHeightIsEqualTo(Dp.Unspecified)
- }
- Unit // Test expects to return Unit
- }
-
/**
* Provides a list constraints combination for horizontal anchors: `start`, `end`,
* `absoluteLeft`, `absoluteRight`.
diff --git a/constraintlayout/constraintlayout-compose/src/androidMain/kotlin/androidx/constraintlayout/compose/ConstraintLayout.kt b/constraintlayout/constraintlayout-compose/src/androidMain/kotlin/androidx/constraintlayout/compose/ConstraintLayout.kt
index da91f65..04acd28 100644
--- a/constraintlayout/constraintlayout-compose/src/androidMain/kotlin/androidx/constraintlayout/compose/ConstraintLayout.kt
+++ b/constraintlayout/constraintlayout-compose/src/androidMain/kotlin/androidx/constraintlayout/compose/ConstraintLayout.kt
@@ -420,27 +420,21 @@
val contentTracker = remember { mutableStateOf(Unit, neverEqualPolicy()) }
val measurePolicy = MeasurePolicy { measurables, constraints ->
- // Map to properly capture Placeables across Measure and Layout passes
- val placeableMap = mutableMapOf<Measurable, Placeable>()
-
- // Call to invalidate measure on content recomposition
contentTracker.value
-
val layoutSize =
measurer.performMeasure(
- constraints = constraints,
- layoutDirection = layoutDirection,
- constraintSet = constraintSet,
- measurables = measurables,
- placeableMap = placeableMap,
- optimizationLevel = optimizationLevel
+ constraints,
+ layoutDirection,
+ constraintSet,
+ measurables,
+ optimizationLevel
)
// We read the remeasurement requester state, to request remeasure when the value
// changes. This will happen when the scope helpers are changing at recomposition.
remeasureRequesterState.value
layout(layoutSize.width, layoutSize.height) {
- with(measurer) { performLayout(measurables = measurables, placeableMap = placeableMap) }
+ with(measurer) { performLayout(measurables) }
}
}
@@ -811,25 +805,17 @@
true
}
val measurePolicy = MeasurePolicy { measurables, constraints ->
- // Map to properly capture Placeables across Measure and Layout passes
- val placeableMap = mutableMapOf<Measurable, Placeable>()
-
- // Call to invalidate measure on content recomposition
contentTracker.value
-
val layoutSize =
measurer.performMeasure(
- constraints = constraints,
- layoutDirection = layoutDirection,
- constraintSet = constraintSet,
- measurables = measurables,
- placeableMap = placeableMap,
- optimizationLevel = optimizationLevel
+ constraints,
+ layoutDirection,
+ constraintSet,
+ measurables,
+ optimizationLevel
)
layout(layoutSize.width, layoutSize.height) {
- with(measurer) {
- performLayout(measurables = measurables, placeableMap = placeableMap)
- }
+ with(measurer) { performLayout(measurables) }
}
}
if (constraintSet is EditableJSONLayout) {
@@ -1625,22 +1611,9 @@
private var computedLayoutResult: String = ""
protected var layoutInformationReceiver: LayoutInformationReceiver? = null
protected val root = ConstraintWidgetContainer(0, 0).also { it.measurer = this }
-
- /**
- * Due to Lookahead measure pass, the object used to measure and place should not be
- * instantiated internally, instead, should be instantiated within the MeasurePolicy call, and
- * then passed to update this variable, at each the measure and layout passes.
- */
- protected var placeables = mutableMapOf<Measurable, Placeable>()
+ protected val placeables = mutableMapOf<Measurable, Placeable>()
private val lastMeasures = mutableMapOf<String, Array<Int>>()
-
- @Suppress("unused") // Exists for compatibility.
- @Deprecated(
- message = "Should not reference a Measurable.",
- replaceWith = ReplaceWith("frameCache2")
- )
- protected val frameCache = emptyMap<Measurable, WidgetFrame>()
- protected val frameCache2 = mutableMapOf<String, WidgetFrame>()
+ protected val frameCache = mutableMapOf<Measurable, WidgetFrame>()
protected val state = State(density)
@@ -1817,7 +1790,7 @@
val id = measurable.layoutId ?: measurable.constraintLayoutId
child.stringId = id?.toString()
}
- val frame = frameCache2[measurable.anyOrNullId]?.widget?.frame
+ val frame = frameCache[measurable]?.widget?.frame
if (frame == null) {
continue
}
@@ -1894,33 +1867,13 @@
this[2] = measure.measuredBaseline
}
- @Suppress("DeprecatedCallableAddReplaceWith") // Requires manual replacement
- @Deprecated("Should receive placeable Map from caller.")
fun performMeasure(
constraints: Constraints,
layoutDirection: LayoutDirection,
constraintSet: ConstraintSet,
measurables: List<Measurable>,
optimizationLevel: Int
- ): IntSize =
- performMeasure(
- constraints = constraints,
- layoutDirection = layoutDirection,
- constraintSet = constraintSet,
- measurables = measurables,
- placeableMap = placeables,
- optimizationLevel = optimizationLevel
- )
-
- fun performMeasure(
- constraints: Constraints,
- layoutDirection: LayoutDirection,
- constraintSet: ConstraintSet,
- measurables: List<Measurable>,
- placeableMap: MutableMap<Measurable, Placeable>,
- optimizationLevel: Int
): IntSize {
- this.placeables = placeableMap
if (measurables.isEmpty()) {
// TODO(b/335524398): Behavior with zero children is unexpected. It's also inconsistent
// with ViewGroup, so this is a workaround to handle those cases the way it seems
@@ -1985,7 +1938,7 @@
internal fun resetMeasureState() {
placeables.clear()
lastMeasures.clear()
- frameCache2.clear()
+ frameCache.clear()
}
protected fun applyRootSize(constraints: Constraints) {
@@ -2024,30 +1977,37 @@
}
}
- @Suppress("DeprecatedCallableAddReplaceWith") // Requires manual replacement
- @Deprecated("Should receive placeable Map from caller.")
fun Placeable.PlacementScope.performLayout(measurables: List<Measurable>) {
- performLayout(measurables, placeables)
- }
-
- fun Placeable.PlacementScope.performLayout(
- measurables: List<Measurable>,
- placeableMap: MutableMap<Measurable, Placeable>,
- ) {
- if (frameCache2.isEmpty()) {
+ if (frameCache.isEmpty()) {
root.children.fastForEach { child ->
val measurable = child.companionWidget
if (measurable !is Measurable) return@fastForEach
val frame = WidgetFrame(child.frame.update())
- frameCache2[measurable.anyOrNullId] = frame
+ frameCache[measurable] = frame
}
}
measurables.fastForEach { measurable ->
- val frame = frameCache2[measurable.anyOrNullId] ?: return@fastForEach
- // Don't use `placeables` map from Measurer, is not guaranteed to correspond to this
- // Layout pass
- val placeable = placeableMap[measurable] ?: return@fastForEach
- placeWithFrameTransform(placeable, frame)
+ val matchedMeasurable: Measurable =
+ if (!frameCache.containsKey(measurable)) {
+ // TODO: Workaround for lookaheadLayout, the measurable is a different instance
+ frameCache.keys.firstOrNull {
+ it.layoutId != null && it.layoutId == measurable.layoutId
+ } ?: return@fastForEach
+ } else {
+ measurable
+ }
+ val frame = frameCache[matchedMeasurable] ?: return
+ val placeable = placeables[matchedMeasurable] ?: return
+ if (!frameCache.containsKey(measurable)) {
+ // TODO: Workaround for lookaheadLayout, the measurable is a different instance and
+ // the placeable should be a result of the given measurable
+ placeWithFrameTransform(
+ measurable.measure(Constraints.fixed(placeable.width, placeable.height)),
+ frame
+ )
+ } else {
+ placeWithFrameTransform(placeable, frame)
+ }
}
if (layoutInformationReceiver?.getLayoutInformationMode() == LayoutInfoFlags.BOUNDS) {
computeLayoutResult()
@@ -2096,8 +2056,7 @@
IntIntPair(constraintWidget.measuredWidth, constraintWidget.measuredHeight)
}
measurable is Measurable -> {
- val result = measurable.measure(constraints)
- placeables[measurable] = result
+ val result = measurable.measure(constraints).also { placeables[measurable] = it }
IntIntPair(result.width, result.height)
}
else -> {
@@ -2316,10 +2275,6 @@
}
}
-/** Returns either [LayoutIdParentData] or [ConstraintLayoutParentData] id. Otherwise "null". */
-internal val Measurable.anyOrNullId: String
- get() = (this.layoutId ?: this.constraintLayoutId)?.toString() ?: "null"
-
internal typealias SolverDimension = androidx.constraintlayout.core.state.Dimension
internal typealias SolverState = androidx.constraintlayout.core.state.State
diff --git a/constraintlayout/constraintlayout-compose/src/androidMain/kotlin/androidx/constraintlayout/compose/LateMotionLayout.kt b/constraintlayout/constraintlayout-compose/src/androidMain/kotlin/androidx/constraintlayout/compose/LateMotionLayout.kt
index 46b7f27..8287e20 100644
--- a/constraintlayout/constraintlayout-compose/src/androidMain/kotlin/androidx/constraintlayout/compose/LateMotionLayout.kt
+++ b/constraintlayout/constraintlayout-compose/src/androidMain/kotlin/androidx/constraintlayout/compose/LateMotionLayout.kt
@@ -25,10 +25,8 @@
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
-import androidx.compose.ui.layout.Measurable
import androidx.compose.ui.layout.MeasurePolicy
import androidx.compose.ui.layout.MultiMeasureLayout
-import androidx.compose.ui.layout.Placeable
import androidx.compose.ui.node.Ref
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.semantics
@@ -119,9 +117,6 @@
measurer: MotionMeasurer,
optimizationLevel: Int,
): MeasurePolicy = MeasurePolicy { measurables, constraints ->
- // Map to properly capture Placeables across Measure and Layout passes
- val placeableMap = mutableMapOf<Measurable, Placeable>()
-
// Do a state read, to guarantee that we control measure when the content recomposes without
// notifying our Composable caller
contentTracker.value
@@ -134,7 +129,6 @@
constraintSetEnd = endProvider(),
transition = TransitionImpl.EMPTY,
measurables = measurables,
- placeableMap = placeableMap,
optimizationLevel = optimizationLevel,
progress = motionProgress.value,
compositionSource = compositionSource.value ?: CompositionSource.Unknown,
@@ -142,7 +136,5 @@
)
compositionSource.value = CompositionSource.Unknown // Reset after measuring
- layout(layoutSize.width, layoutSize.height) {
- with(measurer) { performLayout(measurables = measurables, placeableMap = placeableMap) }
- }
+ layout(layoutSize.width, layoutSize.height) { with(measurer) { performLayout(measurables) } }
}
diff --git a/constraintlayout/constraintlayout-compose/src/androidMain/kotlin/androidx/constraintlayout/compose/MotionCarousel.kt b/constraintlayout/constraintlayout-compose/src/androidMain/kotlin/androidx/constraintlayout/compose/MotionCarousel.kt
index 43db9f6..d8d5f55 100644
--- a/constraintlayout/constraintlayout-compose/src/androidMain/kotlin/androidx/constraintlayout/compose/MotionCarousel.kt
+++ b/constraintlayout/constraintlayout-compose/src/androidMain/kotlin/androidx/constraintlayout/compose/MotionCarousel.kt
@@ -125,6 +125,7 @@
* index than startIndex, and at the end of the Carousel, we will not populate the slots that have a
* higher index than startIndex.
*
+ * @param motionScene the MotionScene which holds slots and can be used to customize the carousel
* @param initialSlotIndex the slot index that holds the current element
* @param numSlots the number of slots in the scene
* @param backwardTransition the name of the previous transition (default "previous")
@@ -137,7 +138,7 @@
@Composable
@Suppress("UnavailableSymbol")
fun MotionCarousel(
- @Suppress("HiddenTypeParameter") motionScene: MotionScene,
+ motionScene: MotionScene,
initialSlotIndex: Int,
numSlots: Int,
backwardTransition: String = "backward",
diff --git a/constraintlayout/constraintlayout-compose/src/androidMain/kotlin/androidx/constraintlayout/compose/MotionLayout.kt b/constraintlayout/constraintlayout-compose/src/androidMain/kotlin/androidx/constraintlayout/compose/MotionLayout.kt
index 8b0e892..280dca8 100644
--- a/constraintlayout/constraintlayout-compose/src/androidMain/kotlin/androidx/constraintlayout/compose/MotionLayout.kt
+++ b/constraintlayout/constraintlayout-compose/src/androidMain/kotlin/androidx/constraintlayout/compose/MotionLayout.kt
@@ -42,10 +42,8 @@
import androidx.compose.ui.draw.scale
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.layout.Measurable
import androidx.compose.ui.layout.MeasurePolicy
import androidx.compose.ui.layout.MultiMeasureLayout
-import androidx.compose.ui.layout.Placeable
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onPlaced
import androidx.compose.ui.node.Ref
@@ -980,9 +978,6 @@
optimizationLevel: Int,
invalidationStrategy: InvalidationStrategy
): MeasurePolicy = MeasurePolicy { measurables, constraints ->
- // Map to properly capture Placeables across Measure and Layout passes
- val placeableMap = mutableMapOf<Measurable, Placeable>()
-
// Do a state read, to guarantee that we control measure when the content recomposes without
// notifying our Composable caller
contentTracker.value
@@ -995,7 +990,6 @@
constraintSetEnd = constraintSetEnd,
transition = transition,
measurables = measurables,
- placeableMap = placeableMap,
optimizationLevel = optimizationLevel,
progress = motionProgress.floatValue,
compositionSource = compositionSource.value ?: CompositionSource.Unknown,
@@ -1003,9 +997,7 @@
)
compositionSource.value = CompositionSource.Unknown // Reset after measuring
- layout(layoutSize.width, layoutSize.height) {
- with(measurer) { performLayout(measurables = measurables, placeableMap = placeableMap) }
- }
+ layout(layoutSize.width, layoutSize.height) { with(measurer) { performLayout(measurables) } }
}
/**
diff --git a/constraintlayout/constraintlayout-compose/src/androidMain/kotlin/androidx/constraintlayout/compose/MotionMeasurer.kt b/constraintlayout/constraintlayout-compose/src/androidMain/kotlin/androidx/constraintlayout/compose/MotionMeasurer.kt
index b077a37..ecd807c 100644
--- a/constraintlayout/constraintlayout-compose/src/androidMain/kotlin/androidx/constraintlayout/compose/MotionMeasurer.kt
+++ b/constraintlayout/constraintlayout-compose/src/androidMain/kotlin/androidx/constraintlayout/compose/MotionMeasurer.kt
@@ -27,7 +27,6 @@
import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.layout.Measurable
-import androidx.compose.ui.layout.Placeable
import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
@@ -85,13 +84,11 @@
constraintSetEnd: ConstraintSet,
@SuppressWarnings("HiddenTypeParameter") transition: TransitionImpl,
measurables: List<Measurable>,
- placeableMap: MutableMap<Measurable, Placeable>,
optimizationLevel: Int,
progress: Float,
compositionSource: CompositionSource,
invalidateOnConstraintsCallback: ShouldInvalidateCallback?
): IntSize {
- placeables = placeableMap
val needsRemeasure =
needsRemeasure(
constraints = constraints,
@@ -142,7 +139,7 @@
source: CompositionSource,
invalidateOnConstraintsCallback: ShouldInvalidateCallback?
): Boolean {
- if (this.transition.isEmpty || frameCache2.isEmpty()) {
+ if (this.transition.isEmpty || frameCache.isEmpty()) {
// Nothing measured (by MotionMeasurer)
return true
}
@@ -229,7 +226,7 @@
measurable.measure(
Constraints.fixed(interpolatedFrame.width(), interpolatedFrame.height())
)
- frameCache2[measurable.anyOrNullId] = interpolatedFrame
+ frameCache[measurable] = interpolatedFrame
}
if (layoutInformationReceiver?.getLayoutInformationMode() == LayoutInfoFlags.BOUNDS) {
@@ -542,7 +539,7 @@
fun clearConstraintSets() {
transition.clear()
- frameCache2.clear()
+ frameCache.clear()
}
@Suppress("UnavailableSymbol")
diff --git a/constraintlayout/constraintlayout-core/src/main/java/androidx/constraintlayout/core/ArrayLinkedVariables.java b/constraintlayout/constraintlayout-core/src/main/java/androidx/constraintlayout/core/ArrayLinkedVariables.java
index 3575eb4..fef3c9c 100644
--- a/constraintlayout/constraintlayout-core/src/main/java/androidx/constraintlayout/core/ArrayLinkedVariables.java
+++ b/constraintlayout/constraintlayout-core/src/main/java/androidx/constraintlayout/core/ArrayLinkedVariables.java
@@ -230,8 +230,10 @@
* The code is broadly identical to the put() method, only differing
* in in-line deletion, and of course doing an add rather than a put
*
- * @param variable the variable we want to add
- * @param value its value
+ * @param variable the variable we want to add
+ * @param value its value
+ * @param removeFromDefinition if a variable's value becomes zero, it is removed. This parameter
+ * is whether the removal is deep now or left shallow until cleanup
*/
@Override
public void add(SolverVariable variable, float value, boolean removeFromDefinition) {
@@ -355,7 +357,9 @@
/**
* Update the current list with a new definition
*
- * @param definition the row containing the definition
+ * @param definition the row containing the definition
+ * @param removeFromDefinition if a variable's value becomes zero, it is removed. This parameter
+ * is whether the removal is deep now or left shallow until cleanup
*/
@Override
public float use(ArrayRow definition, boolean removeFromDefinition) {
@@ -374,7 +378,9 @@
/**
* Remove a variable from the list
*
- * @param variable the variable we want to remove
+ * @param variable the variable we want to remove
+ * @param removeFromDefinition if a variable's value becomes zero, it is removed. This parameter
+ * is whether the removal is deep now or left shallow until cleanup
* @return the value of the removed variable
*/
@Override
diff --git a/constraintlayout/constraintlayout-core/src/main/java/androidx/constraintlayout/core/LinearSystem.java b/constraintlayout/constraintlayout-core/src/main/java/androidx/constraintlayout/core/LinearSystem.java
index 5706151..e4e1c7af 100644
--- a/constraintlayout/constraintlayout-core/src/main/java/androidx/constraintlayout/core/LinearSystem.java
+++ b/constraintlayout/constraintlayout-core/src/main/java/androidx/constraintlayout/core/LinearSystem.java
@@ -1509,6 +1509,8 @@
* Add the equations constraining a widget center to another widget center, positioned
* on a circle, following an angle and radius
*
+ * @param widget the constrained widget
+ * @param target the constrained-to widget
* @param angle from 0 to 360
* @param radius the distance between the two centers
*/
diff --git a/constraintlayout/constraintlayout-core/src/main/java/androidx/constraintlayout/core/motion/Motion.java b/constraintlayout/constraintlayout-core/src/main/java/androidx/constraintlayout/core/motion/Motion.java
index f2dc0ee..ba98915 100644
--- a/constraintlayout/constraintlayout-core/src/main/java/androidx/constraintlayout/core/motion/Motion.java
+++ b/constraintlayout/constraintlayout-core/src/main/java/androidx/constraintlayout/core/motion/Motion.java
@@ -280,7 +280,8 @@
* x coordinate of "time" 0.0 mPoints[point.length-1] is filled with the y coordinate of "time"
* 1.0
*
- * @param points array to fill (should be 2x the number of mPoints
+ * @param points array to fill (should be 2x pointCount)
+ * @param pointCount truncate mPoints to this length. Must be > 1 and <= mPoints.length
*/
public void buildPath(float[] points, int pointCount) {
float mils = 1.0f / (pointCount - 1);
@@ -1646,6 +1647,7 @@
* ...
* length
*
+ * @param type if type is -1, skip all keyframes with type != -1
* @param info is a data structure array of int that holds info on each keyframe
* @return Number of keyFrames found
*/
diff --git a/constraintlayout/constraintlayout-core/src/main/java/androidx/constraintlayout/core/motion/utils/KeyCycleOscillator.java b/constraintlayout/constraintlayout-core/src/main/java/androidx/constraintlayout/core/motion/utils/KeyCycleOscillator.java
index 943b41d..b3c5443 100644
--- a/constraintlayout/constraintlayout-core/src/main/java/androidx/constraintlayout/core/motion/utils/KeyCycleOscillator.java
+++ b/constraintlayout/constraintlayout-core/src/main/java/androidx/constraintlayout/core/motion/utils/KeyCycleOscillator.java
@@ -139,9 +139,12 @@
* sets a oscillator wave point
*
* @param framePosition the position
+ * @param shape the wave shape
+ * @param waveString the wave string
* @param variesBy only varies by path supported for now
* @param period the period of the wave
* @param offset the offset value
+ * @param phase the phase of the new wavepoint
* @param value the adder
* @param custom The ConstraintAttribute used to set the value
*/
@@ -167,9 +170,12 @@
* sets a oscillator wave point
*
* @param framePosition the position
+ * @param shape the wave shape
+ * @param waveString the wave string
* @param variesBy only varies by path supported for now
* @param period the period of the wave
* @param offset the offset value
+ * @param phase the phase of the new wavepoint
* @param value the adder
*/
public void setPoint(int framePosition,
diff --git a/constraintlayout/constraintlayout-core/src/main/java/androidx/constraintlayout/core/state/CoreMotionScene.java b/constraintlayout/constraintlayout-core/src/main/java/androidx/constraintlayout/core/state/CoreMotionScene.java
index 5b7d71d..76b5128 100644
--- a/constraintlayout/constraintlayout-core/src/main/java/androidx/constraintlayout/core/state/CoreMotionScene.java
+++ b/constraintlayout/constraintlayout-core/src/main/java/androidx/constraintlayout/core/state/CoreMotionScene.java
@@ -25,6 +25,7 @@
* set the Transitions string onto the MotionScene
*
* @param elementName the name of the element
+ * @param toJSON the json string of the transitioncontent
*/
void setTransitionContent(String elementName, String toJSON);
diff --git a/constraintlayout/constraintlayout-core/src/main/java/androidx/constraintlayout/core/state/Transition.java b/constraintlayout/constraintlayout-core/src/main/java/androidx/constraintlayout/core/state/Transition.java
index 98dbf1f..cf66b93 100644
--- a/constraintlayout/constraintlayout-core/src/main/java/androidx/constraintlayout/core/state/Transition.java
+++ b/constraintlayout/constraintlayout-core/src/main/java/androidx/constraintlayout/core/state/Transition.java
@@ -432,10 +432,11 @@
* Converts from xy drag to progress
* This should be used till touch up
*
- * @param baseW parent width
- * @param baseH parent height
- * @param dx change in x
- * @param dy change in y
+ * @param currentProgress 0...1 progress in
+ * @param baseW parent width
+ * @param baseH parent height
+ * @param dx change in x
+ * @param dy change in y
* @return the change in progress
*/
public float dragToProgress(float currentProgress, int baseW, int baseH, float dx, float dy) {
diff --git a/constraintlayout/constraintlayout-core/src/main/java/androidx/constraintlayout/core/widgets/Chain.java b/constraintlayout/constraintlayout-core/src/main/java/androidx/constraintlayout/core/widgets/Chain.java
index e06973f..a60a9d3 100644
--- a/constraintlayout/constraintlayout-core/src/main/java/androidx/constraintlayout/core/widgets/Chain.java
+++ b/constraintlayout/constraintlayout-core/src/main/java/androidx/constraintlayout/core/widgets/Chain.java
@@ -41,6 +41,8 @@
*
* @param constraintWidgetContainer root container
* @param system the linear system we add the equations to
+ * @param widgets if this is null or contains any chainheads' widgets,
+ * constrain all chains / the corresponding chains
* @param orientation HORIZONTAL or VERTICAL
*/
public static void applyChainConstraints(
diff --git a/constraintlayout/constraintlayout-core/src/main/java/androidx/constraintlayout/core/widgets/ConstraintWidgetContainer.java b/constraintlayout/constraintlayout-core/src/main/java/androidx/constraintlayout/core/widgets/ConstraintWidgetContainer.java
index 6644ed9d..af42a35 100644
--- a/constraintlayout/constraintlayout-core/src/main/java/androidx/constraintlayout/core/widgets/ConstraintWidgetContainer.java
+++ b/constraintlayout/constraintlayout-core/src/main/java/androidx/constraintlayout/core/widgets/ConstraintWidgetContainer.java
@@ -451,6 +451,7 @@
* Update the frame of the layout and its children from the solver
*
* @param system the solver we get the values from.
+ * @param flags the flag set associated with this solver
*/
public boolean updateChildrenFromSolver(LinearSystem system, boolean[] flags) {
flags[Optimizer.FLAG_RECOMPUTE_BOUNDS] = false;
diff --git a/constraintlayout/constraintlayout/src/main/java/androidx/constraintlayout/motion/widget/DesignTool.java b/constraintlayout/constraintlayout/src/main/java/androidx/constraintlayout/motion/widget/DesignTool.java
index ef67572..8f96e8f 100644
--- a/constraintlayout/constraintlayout/src/main/java/androidx/constraintlayout/motion/widget/DesignTool.java
+++ b/constraintlayout/constraintlayout/src/main/java/androidx/constraintlayout/motion/widget/DesignTool.java
@@ -184,6 +184,7 @@
*
* @param view view to getMap the animation of
* @param path array to be filled (x1,y1,x2,y2...)
+ * @param len the desired number of point along animation
* @return -1 if not under and animation 0 if not animated or number of point along animation
*/
public int getAnimationPath(Object view, float[] path, int len) {
@@ -446,7 +447,8 @@
* The call is designed to be efficient because it will be called 30x Number of views a second
*
* @param view the view to return keyframe positions
- * @param info
+ * @param type if type is -1, skip all keyframes with type != -1
+ * @param info array to fill with info on each keyframe
* @return Number of keyFrames found
*/
public int getKeyFrameInfo(Object view, int type, int[] info) {
diff --git a/constraintlayout/constraintlayout/src/main/java/androidx/constraintlayout/motion/widget/MotionController.java b/constraintlayout/constraintlayout/src/main/java/androidx/constraintlayout/motion/widget/MotionController.java
index 4dc61c2..a1cdd45 100644
--- a/constraintlayout/constraintlayout/src/main/java/androidx/constraintlayout/motion/widget/MotionController.java
+++ b/constraintlayout/constraintlayout/src/main/java/androidx/constraintlayout/motion/widget/MotionController.java
@@ -1675,6 +1675,7 @@
* ...
* length
*
+ * @param type if type is -1, skip all keyframes with type != -1
* @param info is a data structure array of int that holds info on each keyframe
* @return Number of keyFrames found
*/
diff --git a/constraintlayout/constraintlayout/src/main/java/androidx/constraintlayout/widget/ConstraintSet.java b/constraintlayout/constraintlayout/src/main/java/androidx/constraintlayout/widget/ConstraintSet.java
index f633da5a..b45ca83 100644
--- a/constraintlayout/constraintlayout/src/main/java/androidx/constraintlayout/widget/ConstraintSet.java
+++ b/constraintlayout/constraintlayout/src/main/java/androidx/constraintlayout/widget/ConstraintSet.java
@@ -3659,7 +3659,8 @@
* Elevation logic is based on style and animation. By default it is not used because it would
* lead to unexpected results.
*
- * @param apply true if this constraint set applies elevation to this view
+ * @param viewId ID of view to adjust the elevation
+ * @param apply true if this constraint set applies elevation to this view
*/
public void setApplyElevation(int viewId, boolean apply) {
if (Build.VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) {
@@ -4200,9 +4201,10 @@
/**
* Creates a ConstraintLayout Barrier object.
*
- * @param id
+ * @param id the id of the constraint to create or partially overwrite.
* @param direction Barrier.{LEFT,RIGHT,TOP,BOTTOM,START,END}
- * @param referenced
+ * @param margin the barrierMargin of the Barrier object
+ * @param referenced the referenceIds of the Barrier object
*/
public void createBarrier(int id, int direction, int margin, int... referenced) {
Constraint constraint = get(id);
diff --git a/health/connect/connect-client/src/main/java/androidx/health/connect/client/records/BloodPressureRecord.kt b/health/connect/connect-client/src/main/java/androidx/health/connect/client/records/BloodPressureRecord.kt
index 38cb5b4..d2923d4 100644
--- a/health/connect/connect-client/src/main/java/androidx/health/connect/client/records/BloodPressureRecord.kt
+++ b/health/connect/connect-client/src/main/java/androidx/health/connect/client/records/BloodPressureRecord.kt
@@ -15,9 +15,11 @@
*/
package androidx.health.connect.client.records
+import android.os.Build
import androidx.annotation.IntDef
import androidx.annotation.RestrictTo
import androidx.health.connect.client.aggregate.AggregateMetric
+import androidx.health.connect.client.impl.platform.records.toPlatformRecord
import androidx.health.connect.client.records.BloodPressureRecord.MeasurementLocation
import androidx.health.connect.client.records.metadata.Metadata
import androidx.health.connect.client.units.Pressure
@@ -60,11 +62,19 @@
override val metadata: Metadata = Metadata.EMPTY,
) : InstantaneousRecord {
+ /*
+ * Android U devices and later use the platform's validation instead of Jetpack validation.
+ * See b/316852544 for more context.
+ */
init {
- systolic.requireNotLess(other = MIN_SYSTOLIC, name = "systolic")
- systolic.requireNotMore(other = MAX_SYSTOLIC, name = "systolic")
- diastolic.requireNotLess(other = MIN_DIASTOLIC, name = "diastolic")
- diastolic.requireNotMore(other = MAX_DIASTOLIC, name = "diastolic")
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+ this.toPlatformRecord()
+ } else {
+ systolic.requireNotLess(other = MIN_SYSTOLIC, name = "systolic")
+ systolic.requireNotMore(other = MAX_SYSTOLIC, name = "systolic")
+ diastolic.requireNotLess(other = MIN_DIASTOLIC, name = "diastolic")
+ diastolic.requireNotMore(other = MAX_DIASTOLIC, name = "diastolic")
+ }
}
/*
diff --git a/health/connect/connect-client/src/test/java/androidx/health/connect/client/records/BloodPressureRecordTest.kt b/health/connect/connect-client/src/test/java/androidx/health/connect/client/records/BloodPressureRecordTest.kt
index 4dc9b41..1e1144e 100644
--- a/health/connect/connect-client/src/test/java/androidx/health/connect/client/records/BloodPressureRecordTest.kt
+++ b/health/connect/connect-client/src/test/java/androidx/health/connect/client/records/BloodPressureRecordTest.kt
@@ -18,14 +18,104 @@
import androidx.health.connect.client.records.metadata.Metadata
import androidx.health.connect.client.units.Pressure
+import androidx.health.connect.client.units.millimetersOfMercury
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import java.time.Instant
+import org.junit.Assert.assertThrows
import org.junit.Test
import org.junit.runner.RunWith
+import org.robolectric.annotation.Config
@RunWith(AndroidJUnit4::class)
class BloodPressureRecordTest {
+
+ @Config(minSdk = 34)
+ @Test
+ fun constructor_paramsValidatedUsingPlatformValidation_createsBloodPressureRecord() {
+ assertThat(
+ BloodPressureRecord(
+ time = Instant.ofEpochMilli(1234L),
+ zoneOffset = null,
+ systolic = 120.millimetersOfMercury,
+ diastolic = 112.millimetersOfMercury,
+ bodyPosition = BloodPressureRecord.BODY_POSITION_RECLINING,
+ measurementLocation = BloodPressureRecord.MEASUREMENT_LOCATION_LEFT_WRIST,
+ metadata = Metadata.EMPTY,
+ )
+ )
+ .isEqualTo(
+ BloodPressureRecord(
+ time = Instant.ofEpochMilli(1234L),
+ zoneOffset = null,
+ systolic = 120.millimetersOfMercury,
+ diastolic = 112.millimetersOfMercury,
+ bodyPosition = BloodPressureRecord.BODY_POSITION_RECLINING,
+ measurementLocation = BloodPressureRecord.MEASUREMENT_LOCATION_LEFT_WRIST,
+ metadata = Metadata.EMPTY,
+ )
+ )
+ }
+
+ @Config(minSdk = 34)
+ @Test
+ fun constructor_paramsInvalidSystolicAndDiastolicValues_platformValidationFailsWithAnException() {
+ assertThrows(IllegalArgumentException::class.java) {
+ BloodPressureRecord(
+ time = Instant.ofEpochMilli(1234L),
+ zoneOffset = null,
+ systolic = 10.millimetersOfMercury,
+ diastolic = 500.millimetersOfMercury,
+ bodyPosition = BloodPressureRecord.BODY_POSITION_RECLINING,
+ measurementLocation = BloodPressureRecord.MEASUREMENT_LOCATION_LEFT_WRIST,
+ metadata = Metadata.EMPTY,
+ )
+ }
+ }
+
+ @Config(maxSdk = 33)
+ @Test
+ fun constructor_paramsValidatedUsingAPKValidation_createsBloodPressureRecord() {
+ assertThat(
+ BloodPressureRecord(
+ time = Instant.ofEpochMilli(1234L),
+ zoneOffset = null,
+ systolic = 120.millimetersOfMercury,
+ diastolic = 112.millimetersOfMercury,
+ bodyPosition = BloodPressureRecord.BODY_POSITION_RECLINING,
+ measurementLocation = BloodPressureRecord.MEASUREMENT_LOCATION_LEFT_WRIST,
+ metadata = Metadata.EMPTY,
+ )
+ )
+ .isEqualTo(
+ BloodPressureRecord(
+ time = Instant.ofEpochMilli(1234L),
+ zoneOffset = null,
+ systolic = 120.millimetersOfMercury,
+ diastolic = 112.millimetersOfMercury,
+ bodyPosition = BloodPressureRecord.BODY_POSITION_RECLINING,
+ measurementLocation = BloodPressureRecord.MEASUREMENT_LOCATION_LEFT_WRIST,
+ metadata = Metadata.EMPTY,
+ )
+ )
+ }
+
+ @Config(maxSdk = 33)
+ @Test
+ fun constructor_paramsInvalidSystolicAndDiastolicValues_apkValidationFailsWithAnException() {
+ assertThrows(IllegalArgumentException::class.java) {
+ BloodPressureRecord(
+ time = Instant.ofEpochMilli(1234L),
+ zoneOffset = null,
+ systolic = 10.millimetersOfMercury,
+ diastolic = 200.millimetersOfMercury,
+ bodyPosition = BloodPressureRecord.BODY_POSITION_RECLINING,
+ measurementLocation = BloodPressureRecord.MEASUREMENT_LOCATION_LEFT_WRIST,
+ metadata = Metadata.EMPTY,
+ )
+ }
+ }
+
@Test
fun bodyPositionEnums_existInMapping() {
val allEnums = getAllIntDefEnums<BloodPressureRecord>("""BODY_POSITION.*(?<!UNKNOWN)$""")
diff --git a/libraryversions.toml b/libraryversions.toml
index 11546e0..59c4949 100644
--- a/libraryversions.toml
+++ b/libraryversions.toml
@@ -19,7 +19,7 @@
CAMERA_TESTING = "1.0.0-alpha01"
CAMERA_VIEWFINDER = "1.4.0-alpha12"
CARDVIEW = "1.1.0-alpha01"
-CAR_APP = "1.7.0-rc01"
+CAR_APP = "1.8.0-alpha01"
COLLECTION = "1.5.0-beta02"
COMPOSE = "1.8.0-alpha08"
COMPOSE_MATERIAL3 = "1.4.0-alpha06"
@@ -64,7 +64,7 @@
EMOJI = "1.2.0-alpha03"
EMOJI2 = "1.5.0-rc01"
ENTERPRISE = "1.1.0-rc01"
-EXIFINTERFACE = "1.4.0-beta01"
+EXIFINTERFACE = "1.4.0-rc01"
FRAGMENT = "1.9.0-alpha01"
FUTURES = "1.3.0-alpha01"
GLANCE = "1.2.0-alpha01"
@@ -168,10 +168,10 @@
WEAR_INPUT = "1.2.0-alpha03"
WEAR_INPUT_TESTING = "1.2.0-alpha03"
WEAR_ONGOING = "1.1.0-alpha02"
-WEAR_PHONE_INTERACTIONS = "1.1.0-alpha05"
-WEAR_PROTOLAYOUT = "1.3.0-alpha06"
+WEAR_PHONE_INTERACTIONS = "1.1.0-beta01"
+WEAR_PROTOLAYOUT = "1.3.0-alpha07"
WEAR_REMOTE_INTERACTIONS = "1.1.0-rc01"
-WEAR_TILES = "1.5.0-alpha06"
+WEAR_TILES = "1.5.0-alpha07"
WEAR_TOOLING_PREVIEW = "1.0.0-rc01"
WEAR_WATCHFACE = "1.3.0-alpha05"
WEBKIT = "1.13.0-alpha03"
diff --git a/lifecycle/lifecycle-viewmodel-navigation3/api/current.txt b/lifecycle/lifecycle-viewmodel-navigation3/api/current.txt
index b6bdb70..927612c 100644
--- a/lifecycle/lifecycle-viewmodel-navigation3/api/current.txt
+++ b/lifecycle/lifecycle-viewmodel-navigation3/api/current.txt
@@ -1,10 +1,10 @@
// Signature format: 4.0
package androidx.lifecycle.viewmodel.navigation3 {
- public final class ViewModelStoreNavContentWrapper implements androidx.navigation3.NavContentWrapper {
- method @androidx.compose.runtime.Composable public void WrapBackStack(java.util.List<?> backStack);
- method @androidx.compose.runtime.Composable public <T> void WrapContent(androidx.navigation3.NavRecord<T> record);
- field public static final androidx.lifecycle.viewmodel.navigation3.ViewModelStoreNavContentWrapper INSTANCE;
+ public final class ViewModelStoreNavLocalProvider implements androidx.navigation3.NavLocalProvider {
+ method @androidx.compose.runtime.Composable public void ProvideToBackStack(java.util.List<?> backStack);
+ method @androidx.compose.runtime.Composable public <T> void ProvideToEntry(androidx.navigation3.NavEntry<T> entry);
+ field public static final androidx.lifecycle.viewmodel.navigation3.ViewModelStoreNavLocalProvider INSTANCE;
}
}
diff --git a/lifecycle/lifecycle-viewmodel-navigation3/api/restricted_current.txt b/lifecycle/lifecycle-viewmodel-navigation3/api/restricted_current.txt
index b6bdb70..927612c 100644
--- a/lifecycle/lifecycle-viewmodel-navigation3/api/restricted_current.txt
+++ b/lifecycle/lifecycle-viewmodel-navigation3/api/restricted_current.txt
@@ -1,10 +1,10 @@
// Signature format: 4.0
package androidx.lifecycle.viewmodel.navigation3 {
- public final class ViewModelStoreNavContentWrapper implements androidx.navigation3.NavContentWrapper {
- method @androidx.compose.runtime.Composable public void WrapBackStack(java.util.List<?> backStack);
- method @androidx.compose.runtime.Composable public <T> void WrapContent(androidx.navigation3.NavRecord<T> record);
- field public static final androidx.lifecycle.viewmodel.navigation3.ViewModelStoreNavContentWrapper INSTANCE;
+ public final class ViewModelStoreNavLocalProvider implements androidx.navigation3.NavLocalProvider {
+ method @androidx.compose.runtime.Composable public void ProvideToBackStack(java.util.List<?> backStack);
+ method @androidx.compose.runtime.Composable public <T> void ProvideToEntry(androidx.navigation3.NavEntry<T> entry);
+ field public static final androidx.lifecycle.viewmodel.navigation3.ViewModelStoreNavLocalProvider INSTANCE;
}
}
diff --git a/lifecycle/lifecycle-viewmodel-navigation3/src/androidInstrumentedTest/kotlin/androidx/lifecycle/viewmodel/navigation3/ViewModelStoreNavContentWrapperTest.kt b/lifecycle/lifecycle-viewmodel-navigation3/src/androidInstrumentedTest/kotlin/androidx/lifecycle/viewmodel/navigation3/ViewModelStoreNavContentWrapperTest.kt
index f0efd6c..f59f4fa 100644
--- a/lifecycle/lifecycle-viewmodel-navigation3/src/androidInstrumentedTest/kotlin/androidx/lifecycle/viewmodel/navigation3/ViewModelStoreNavContentWrapperTest.kt
+++ b/lifecycle/lifecycle-viewmodel-navigation3/src/androidInstrumentedTest/kotlin/androidx/lifecycle/viewmodel/navigation3/ViewModelStoreNavContentWrapperTest.kt
@@ -26,9 +26,8 @@
import androidx.lifecycle.createSavedStateHandle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation3.NavDisplay
-import androidx.navigation3.NavRecord
-import androidx.navigation3.SavedStateNavContentWrapper
-import androidx.navigation3.rememberNavWrapperManager
+import androidx.navigation3.NavEntry
+import androidx.navigation3.SavedStateNavLocalProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import kotlin.test.Test
@@ -37,65 +36,65 @@
@LargeTest
@RunWith(AndroidJUnit4::class)
-class ViewModelStoreNavContentWrapperTest {
+class ViewModelStoreNavLocalProviderTest {
@get:Rule val composeTestRule = createComposeRule()
@Test
fun testViewModelProvided() {
- val savedStateWrapper = SavedStateNavContentWrapper
- val viewModelWrapper = ViewModelStoreNavContentWrapper
+ val savedStateWrapper = SavedStateNavLocalProvider
+ val viewModelWrapper = ViewModelStoreNavLocalProvider
lateinit var viewModel1: MyViewModel
lateinit var viewModel2: MyViewModel
- val record1Arg = "record1 Arg"
- val record2Arg = "record2 Arg"
- val record1 =
- NavRecord("key1") {
+ val entry1Arg = "entry1 Arg"
+ val entry2Arg = "entry2 Arg"
+ val entry1 =
+ NavEntry("key1") {
viewModel1 = viewModel<MyViewModel>()
- viewModel1.myArg = record1Arg
+ viewModel1.myArg = entry1Arg
}
- val record2 =
- NavRecord("key2") {
+ val entry2 =
+ NavEntry("key2") {
viewModel2 = viewModel<MyViewModel>()
- viewModel2.myArg = record2Arg
+ viewModel2.myArg = entry2Arg
}
composeTestRule.setContent {
- savedStateWrapper.WrapContent(
- NavRecord(record1.key) { viewModelWrapper.WrapContent(record1) }
+ savedStateWrapper.ProvideToEntry(
+ NavEntry(entry1.key) { viewModelWrapper.ProvideToEntry(entry1) }
)
- savedStateWrapper.WrapContent(
- NavRecord(record2.key) { viewModelWrapper.WrapContent(record2) }
+ savedStateWrapper.ProvideToEntry(
+ NavEntry(entry2.key) { viewModelWrapper.ProvideToEntry(entry2) }
)
}
composeTestRule.runOnIdle {
- assertWithMessage("Incorrect arg for record 1")
+ assertWithMessage("Incorrect arg for entry 1")
.that(viewModel1.myArg)
- .isEqualTo(record1Arg)
- assertWithMessage("Incorrect arg for record 2")
+ .isEqualTo(entry1Arg)
+ assertWithMessage("Incorrect arg for entry 2")
.that(viewModel2.myArg)
- .isEqualTo(record2Arg)
+ .isEqualTo(entry2Arg)
}
}
@Test
- fun testViewModelNoSavedStateNavContentWrapper() {
- val viewModelWrapper = ViewModelStoreNavContentWrapper
+ fun testViewModelNoSavedStateNavLocalProvider() {
+ val viewModelWrapper = ViewModelStoreNavLocalProvider
lateinit var viewModel1: MyViewModel
- val record1Arg = "record1 Arg"
- val record1 =
- NavRecord("key1") {
+ val entry1Arg = "entry1 Arg"
+ val entry1 =
+ NavEntry("key1") {
viewModel1 = viewModel<MyViewModel>()
- viewModel1.myArg = record1Arg
+ viewModel1.myArg = entry1Arg
}
try {
- composeTestRule.setContent { viewModelWrapper.WrapContent(record1) }
+ composeTestRule.setContent { viewModelWrapper.ProvideToEntry(entry1) }
} catch (e: Exception) {
assertThat(e)
.hasMessageThat()
.isEqualTo(
"The Lifecycle state is already beyond INITIALIZED. The " +
- "ViewModelStoreNavContentWrapper requires adding the " +
- "SavedStateNavContentWrapper to ensure support for " +
+ "ViewModelStoreNavLocalProvider requires adding the " +
+ "SavedStateNavLocalProvider to ensure support for " +
"SavedStateHandles."
)
}
@@ -106,21 +105,17 @@
lateinit var backStack: MutableList<Any>
composeTestRule.setContent {
backStack = remember { mutableStateListOf("Home") }
- val manager =
- rememberNavWrapperManager(
- listOf(SavedStateNavContentWrapper, ViewModelStoreNavContentWrapper)
- )
NavDisplay(
backstack = backStack,
- wrapperManager = manager,
+ localProviders = listOf(SavedStateNavLocalProvider, ViewModelStoreNavLocalProvider),
onBack = { backStack.removeAt(backStack.lastIndex) },
) { key ->
when (key) {
"Home" -> {
- NavRecord(key) { viewModel<HomeViewModel>() }
+ NavEntry(key) { viewModel<HomeViewModel>() }
}
"AnotherScreen" -> {
- NavRecord(key) { viewModel<HomeViewModel>() }
+ NavEntry(key) { viewModel<HomeViewModel>() }
}
else -> error("Unknown key: $key")
}
@@ -160,18 +155,14 @@
lateinit var viewModel: SavedStateViewModel
composeTestRule.setContent {
backStack = remember { mutableStateListOf("Home") }
- val manager =
- rememberNavWrapperManager(
- listOf(SavedStateNavContentWrapper, ViewModelStoreNavContentWrapper)
- )
NavDisplay(
backstack = backStack,
- wrapperManager = manager,
+ localProviders = listOf(SavedStateNavLocalProvider, ViewModelStoreNavLocalProvider),
onBack = { backStack.removeAt(backStack.lastIndex) },
) { key ->
when (key) {
"Home" -> {
- NavRecord(key) {
+ NavEntry(key) {
viewModel =
viewModel<SavedStateViewModel> {
val handle = createSavedStateHandle()
diff --git a/lifecycle/lifecycle-viewmodel-navigation3/src/androidMain/kotlin/androidx/lifecycle/viewmodel/navigation3/ViewModelStoreNavContentWrapper.android.kt b/lifecycle/lifecycle-viewmodel-navigation3/src/androidMain/kotlin/androidx/lifecycle/viewmodel/navigation3/ViewModelStoreNavContentWrapper.android.kt
index 79402711..37b32ac 100644
--- a/lifecycle/lifecycle-viewmodel-navigation3/src/androidMain/kotlin/androidx/lifecycle/viewmodel/navigation3/ViewModelStoreNavContentWrapper.android.kt
+++ b/lifecycle/lifecycle-viewmodel-navigation3/src/androidMain/kotlin/androidx/lifecycle/viewmodel/navigation3/ViewModelStoreNavContentWrapper.android.kt
@@ -35,44 +35,44 @@
import androidx.lifecycle.viewmodel.MutableCreationExtras
import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
import androidx.lifecycle.viewmodel.compose.viewModel
-import androidx.navigation3.NavContentWrapper
-import androidx.navigation3.NavRecord
+import androidx.navigation3.NavEntry
+import androidx.navigation3.NavLocalProvider
import androidx.savedstate.SavedStateRegistryOwner
import androidx.savedstate.compose.LocalSavedStateRegistryOwner
/**
- * Provides the content of a [NavRecord] with a [ViewModelStoreOwner] and provides that
+ * Provides the content of a [NavEntry] with a [ViewModelStoreOwner] and provides that
* [ViewModelStoreOwner] as a [LocalViewModelStoreOwner] so that it is available within the content.
*
- * This requires that usage of the [SavedStateNavContentWrapper] to ensure that the [NavRecord]
- * scoped [ViewModel]s can properly provide access to [SavedStateHandle]s
+ * This requires that usage of the [SavedStateNavLocalProvider] to ensure that the [NavEntry] scoped
+ * [ViewModel]s can properly provide access to [SavedStateHandle]s
*/
-public object ViewModelStoreNavContentWrapper : NavContentWrapper {
+public object ViewModelStoreNavLocalProvider : NavLocalProvider {
@Composable
- override fun WrapBackStack(backStack: List<Any>) {
- val recordViewModelStoreProvider = viewModel { RecordViewModel() }
- recordViewModelStoreProvider.ownerInBackStack.clear()
- recordViewModelStoreProvider.ownerInBackStack.addAll(backStack)
+ override fun ProvideToBackStack(backStack: List<Any>) {
+ val entryViewModelStoreProvider = viewModel { EntryViewModel() }
+ entryViewModelStoreProvider.ownerInBackStack.clear()
+ entryViewModelStoreProvider.ownerInBackStack.addAll(backStack)
}
@Composable
- override fun <T : Any> WrapContent(record: NavRecord<T>) {
- val key = record.key
- val recordViewModelStoreProvider = viewModel { RecordViewModel() }
- val viewModelStore = recordViewModelStoreProvider.viewModelStoreForKey(key)
+ override fun <T : Any> ProvideToEntry(entry: NavEntry<T>) {
+ val key = entry.key
+ val entryViewModelStoreProvider = viewModel { EntryViewModel() }
+ val viewModelStore = entryViewModelStoreProvider.viewModelStoreForKey(key)
// This ensures we always keep viewModels on config changes.
val activity = LocalActivity.current
remember(key, viewModelStore) {
object : RememberObserver {
override fun onAbandoned() {
- if (!recordViewModelStoreProvider.ownerInBackStack.contains(key)) {
+ if (!entryViewModelStoreProvider.ownerInBackStack.contains(key)) {
disposeIfNotChangingConfiguration()
}
}
override fun onForgotten() {
- if (!recordViewModelStoreProvider.ownerInBackStack.contains(key)) {
+ if (!entryViewModelStoreProvider.ownerInBackStack.contains(key)) {
disposeIfNotChangingConfiguration()
}
}
@@ -81,7 +81,7 @@
fun disposeIfNotChangingConfiguration() {
if (activity?.isChangingConfigurations != true) {
- recordViewModelStoreProvider.removeViewModelStoreOwnerForKey(key)?.clear()
+ entryViewModelStoreProvider.removeViewModelStoreOwnerForKey(key)?.clear()
}
}
}
@@ -110,20 +110,20 @@
init {
require(this.lifecycle.currentState == Lifecycle.State.INITIALIZED) {
"The Lifecycle state is already beyond INITIALIZED. The " +
- "ViewModelStoreNavContentWrapper requires adding the " +
- "SavedStateNavContentWrapper to ensure support for " +
+ "ViewModelStoreNavLocalProvider requires adding the " +
+ "SavedStateNavLocalProvider to ensure support for " +
"SavedStateHandles."
}
enableSavedStateHandles()
}
}
) {
- record.content.invoke(key)
+ entry.content.invoke(key)
}
}
}
-private class RecordViewModel : ViewModel() {
+private class EntryViewModel : ViewModel() {
private val owners = mutableMapOf<Any, ViewModelStore>()
val ownerInBackStack = mutableListOf<Any>()
diff --git a/mediarouter/mediarouter/api/current.txt b/mediarouter/mediarouter/api/current.txt
index 8b9f3d9..644df6d 100644
--- a/mediarouter/mediarouter/api/current.txt
+++ b/mediarouter/mediarouter/api/current.txt
@@ -406,7 +406,7 @@
field public static final int CALLBACK_FLAG_PERFORM_ACTIVE_SCAN = 1; // 0x1
field public static final int CALLBACK_FLAG_REQUEST_DISCOVERY = 4; // 0x4
field public static final int CALLBACK_FLAG_UNFILTERED_EVENTS = 2; // 0x2
- field public static final int REASON_DISCONNECTED = 1; // 0x1
+ field public static final int REASON_DISCONNECT_CALLED = 1; // 0x1
field public static final int REASON_FAILED_TO_CREATE_DYNAMIC_GROUP_ROUTE_CONTROLLER = 6; // 0x6
field public static final int REASON_REJECTED_FOR_SELECTED_ROUTE = 4; // 0x4
field public static final int REASON_ROUTE_CONNECTION_TIMEOUT = 7; // 0x7
diff --git a/mediarouter/mediarouter/api/restricted_current.txt b/mediarouter/mediarouter/api/restricted_current.txt
index 8b9f3d9..644df6d 100644
--- a/mediarouter/mediarouter/api/restricted_current.txt
+++ b/mediarouter/mediarouter/api/restricted_current.txt
@@ -406,7 +406,7 @@
field public static final int CALLBACK_FLAG_PERFORM_ACTIVE_SCAN = 1; // 0x1
field public static final int CALLBACK_FLAG_REQUEST_DISCOVERY = 4; // 0x4
field public static final int CALLBACK_FLAG_UNFILTERED_EVENTS = 2; // 0x2
- field public static final int REASON_DISCONNECTED = 1; // 0x1
+ field public static final int REASON_DISCONNECT_CALLED = 1; // 0x1
field public static final int REASON_FAILED_TO_CREATE_DYNAMIC_GROUP_ROUTE_CONTROLLER = 6; // 0x6
field public static final int REASON_REJECTED_FOR_SELECTED_ROUTE = 4; // 0x4
field public static final int REASON_ROUTE_CONNECTION_TIMEOUT = 7; // 0x7
diff --git a/mediarouter/mediarouter/src/androidTest/java/androidx/mediarouter/media/MediaRouterDynamicProviderTest.java b/mediarouter/mediarouter/src/androidTest/java/androidx/mediarouter/media/MediaRouterDynamicProviderTest.java
index 5722db5..141a97b 100644
--- a/mediarouter/mediarouter/src/androidTest/java/androidx/mediarouter/media/MediaRouterDynamicProviderTest.java
+++ b/mediarouter/mediarouter/src/androidTest/java/androidx/mediarouter/media/MediaRouterDynamicProviderTest.java
@@ -196,7 +196,7 @@
assertNotNull(mRequestedRoute);
assertEquals(ROUTE_ID_2, mRequestedRoute.getDescriptorId());
assertEquals(RouteConnectionState.STATE_DISCONNECTED, mRouteConnectionState);
- assertEquals(MediaRouter.REASON_DISCONNECTED, mRouteDisconnectedReason);
+ assertEquals(MediaRouter.REASON_DISCONNECT_CALLED, mRouteDisconnectedReason);
}
@Test()
diff --git a/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/GlobalMediaRouter.java b/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/GlobalMediaRouter.java
index c7ea94e..e2e0825 100644
--- a/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/GlobalMediaRouter.java
+++ b/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/GlobalMediaRouter.java
@@ -1686,7 +1686,7 @@
/* package */ void disconnect() {
mController.onUnselect(UNSELECT_REASON_DISCONNECTED);
mController.onRelease();
- notifyRouteDisconnected(MediaRouter.REASON_DISCONNECTED);
+ notifyRouteDisconnected(MediaRouter.REASON_DISCONNECT_CALLED);
}
@Override
diff --git a/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/MediaRouter.java b/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/MediaRouter.java
index af4bd6d..0b481f2 100644
--- a/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/MediaRouter.java
+++ b/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/MediaRouter.java
@@ -130,7 +130,7 @@
public static final int UNSELECT_REASON_ROUTE_CHANGED = 3;
@IntDef({
- REASON_DISCONNECTED,
+ REASON_DISCONNECT_CALLED,
REASON_ROUTE_NOT_AVAILABLE,
REASON_ROUTE_NOT_ENABLED,
REASON_REJECTED_FOR_SELECTED_ROUTE,
@@ -146,7 +146,7 @@
*
* @see Callback#onRouteDisconnected(MediaRouter, RouteInfo, RouteInfo, int)
*/
- public static final int REASON_DISCONNECTED = 1;
+ public static final int REASON_DISCONNECT_CALLED = 1;
/**
* The route connection has failed because the requested route is no longer available.
@@ -165,6 +165,9 @@
/**
* The route connection has failed because the requested route is a selected route.
*
+ * <p>If a route is already selected, then calling {@link RouteInfo#connect()} on the selected
+ * route will be rejected and do nothing.
+ *
* @see Callback#onRouteDisconnected(MediaRouter, RouteInfo, RouteInfo, int)
*/
public static final int REASON_REJECTED_FOR_SELECTED_ROUTE = 4;
@@ -1615,15 +1618,19 @@
/**
* Returns true if this route is currently selected.
*
+ * <p>Only one representative route can return true. For instance:
+ *
+ * <ul>
+ * <li>If this route is a selected (non-group) route, it returns true.
+ * <li>If this route is a selected group route, it returns true.
+ * <li>If this route is a selected member route of a group, it returns false.
+ * </ul>
+ *
* <p>Must be called on the main thread.
*
* @return True if this route is currently selected.
* @see MediaRouter#getSelectedRoute
*/
- // Note: Only one representative route can return true. For instance:
- // - If this route is a selected (non-group) route, it returns true.
- // - If this route is a selected group route, it returns true.
- // - If this route is a selected member route of a group, it returns false.
@MainThread
public boolean isSelected() {
checkCallingThread();
@@ -2062,7 +2069,10 @@
/**
* Connects this route without selecting it.
*
- * <p>If the route is already selected, connecting this route will do nothing.
+ * <p>Apps can select a route by calling {@link #select()}. If apps want to keep the
+ * selected route unchanged and connect to additional routes, then they can use this method
+ * to connect additional routes. If the route is already selected, connecting this route
+ * will do nothing.
*
* <p>Must be called on the main thread.
*/
@@ -2849,7 +2859,8 @@
* routes while connecting to other routes.
*
* <p>The connected route could be different from the route requested by {@link
- * RouteInfo#connect()}.
+ * RouteInfo#connect()}. This can happen when the {@link MediaTransferReceiver media
+ * transfer feature} is enabled.
*
* @param router the media router reporting the event.
* @param connectedRoute the route that has been connected.
diff --git a/navigation3/navigation3/api/current.txt b/navigation3/navigation3/api/current.txt
index ec7e433..d8d07e0 100644
--- a/navigation3/navigation3/api/current.txt
+++ b/navigation3/navigation3/api/current.txt
@@ -1,9 +1,48 @@
// Signature format: 4.0
package androidx.navigation3 {
- public interface NavContentWrapper {
- method @androidx.compose.runtime.Composable public default void WrapBackStack(java.util.List<?> backStack);
- method @androidx.compose.runtime.Composable public <T> void WrapContent(androidx.navigation3.NavRecord<T> record);
+ public final class EntryClassProvider<T> {
+ ctor public EntryClassProvider(kotlin.reflect.KClass<T> clazz, java.util.Map<java.lang.String,?> featureMap, kotlin.jvm.functions.Function1<? super T,kotlin.Unit> content);
+ method public kotlin.reflect.KClass<T> component1();
+ method public java.util.Map<java.lang.String,java.lang.Object> component2();
+ method public kotlin.jvm.functions.Function1<T,kotlin.Unit> component3();
+ method public androidx.navigation3.EntryClassProvider<T> copy(kotlin.reflect.KClass<T> clazz, java.util.Map<java.lang.String,?> featureMap, kotlin.jvm.functions.Function1<? super T,kotlin.Unit> content);
+ method public kotlin.reflect.KClass<T> getClazz();
+ method public kotlin.jvm.functions.Function1<T,kotlin.Unit> getContent();
+ method public java.util.Map<java.lang.String,java.lang.Object> getFeatureMap();
+ property public final kotlin.reflect.KClass<T> clazz;
+ property public final kotlin.jvm.functions.Function1<T,kotlin.Unit> content;
+ property public final java.util.Map<java.lang.String,java.lang.Object> featureMap;
+ }
+
+ @kotlin.DslMarker public @interface EntryDsl {
+ }
+
+ public final class EntryProvider<T> {
+ ctor public EntryProvider(T key, java.util.Map<java.lang.String,?> featureMap, kotlin.jvm.functions.Function1<? super T,kotlin.Unit> content);
+ method public T component1();
+ method public java.util.Map<java.lang.String,java.lang.Object> component2();
+ method public kotlin.jvm.functions.Function1<T,kotlin.Unit> component3();
+ method public androidx.navigation3.EntryProvider<T> copy(T key, java.util.Map<java.lang.String,?> featureMap, kotlin.jvm.functions.Function1<? super T,kotlin.Unit> content);
+ method public kotlin.jvm.functions.Function1<T,kotlin.Unit> getContent();
+ method public java.util.Map<java.lang.String,java.lang.Object> getFeatureMap();
+ method public T getKey();
+ property public final kotlin.jvm.functions.Function1<T,kotlin.Unit> content;
+ property public final java.util.Map<java.lang.String,java.lang.Object> featureMap;
+ property public final T key;
+ }
+
+ @androidx.navigation3.EntryDsl public final class EntryProviderBuilder {
+ ctor public EntryProviderBuilder(kotlin.jvm.functions.Function1<java.lang.Object,? extends androidx.navigation3.NavEntry<? extends java.lang.Object?>> fallback);
+ method public <T> void addEntryProvider(kotlin.reflect.KClass<T> clazz, optional java.util.Map<java.lang.String,?> featureMap, kotlin.jvm.functions.Function1<? super T,kotlin.Unit> content);
+ method public <T> void addEntryProvider(T key, optional java.util.Map<java.lang.String,?> featureMap, kotlin.jvm.functions.Function1<? super T,kotlin.Unit> content);
+ method public kotlin.jvm.functions.Function1<java.lang.Object,androidx.navigation3.NavEntry<? extends java.lang.Object?>> build();
+ }
+
+ public final class EntryProviderKt {
+ method public static inline <reified T> void entry(androidx.navigation3.EntryProviderBuilder, optional java.util.Map<java.lang.String,?> featureMap, kotlin.jvm.functions.Function1<? super T,kotlin.Unit> content);
+ method public static <T> void entry(androidx.navigation3.EntryProviderBuilder, T key, optional java.util.Map<java.lang.String,?> featureMap, kotlin.jvm.functions.Function1<? super T,kotlin.Unit> content);
+ method public static inline kotlin.jvm.functions.Function1<java.lang.Object,androidx.navigation3.NavEntry<? extends java.lang.Object?>> entryProvider(optional kotlin.jvm.functions.Function1<java.lang.Object,? extends androidx.navigation3.NavEntry<? extends java.lang.Object?>> fallback, kotlin.jvm.functions.Function1<? super androidx.navigation3.EntryProviderBuilder,kotlin.Unit> builder);
}
public final class NavDisplay {
@@ -13,11 +52,11 @@
}
public final class NavDisplay_androidKt {
- method @androidx.compose.runtime.Composable public static <T> void NavDisplay(java.util.List<? extends T> backstack, optional androidx.compose.ui.Modifier modifier, optional androidx.navigation3.NavWrapperManager wrapperManager, optional androidx.compose.ui.Alignment contentAlignment, optional androidx.compose.animation.SizeTransform? sizeTransform, optional androidx.compose.animation.EnterTransition enterTransition, optional androidx.compose.animation.ExitTransition exitTransition, optional kotlin.jvm.functions.Function0<kotlin.Unit> onBack, kotlin.jvm.functions.Function1<? super T,? extends androidx.navigation3.NavRecord<? extends T>> recordProvider);
+ method @androidx.compose.runtime.Composable public static <T> void NavDisplay(java.util.List<? extends T> backstack, optional androidx.compose.ui.Modifier modifier, optional java.util.List<? extends androidx.navigation3.NavLocalProvider> localProviders, optional androidx.compose.ui.Alignment contentAlignment, optional androidx.compose.animation.SizeTransform? sizeTransform, optional androidx.compose.animation.EnterTransition enterTransition, optional androidx.compose.animation.ExitTransition exitTransition, optional kotlin.jvm.functions.Function0<kotlin.Unit> onBack, kotlin.jvm.functions.Function1<? super T,? extends androidx.navigation3.NavEntry<? extends T>> entryProvider);
}
- public final class NavRecord<T> {
- ctor public NavRecord(T key, optional java.util.Map<java.lang.String,?> featureMap, kotlin.jvm.functions.Function1<? super T,kotlin.Unit> content);
+ public final class NavEntry<T> {
+ ctor public NavEntry(T key, optional java.util.Map<java.lang.String,?> featureMap, kotlin.jvm.functions.Function1<? super T,kotlin.Unit> content);
method public kotlin.jvm.functions.Function1<T,kotlin.Unit> getContent();
method public java.util.Map<java.lang.String,java.lang.Object> getFeatureMap();
method public T getKey();
@@ -26,69 +65,30 @@
property public final T key;
}
+ public interface NavLocalProvider {
+ method @androidx.compose.runtime.Composable public default void ProvideToBackStack(java.util.List<?> backStack);
+ method @androidx.compose.runtime.Composable public <T> void ProvideToEntry(androidx.navigation3.NavEntry<T> entry);
+ }
+
public final class NavWrapperManager {
ctor public NavWrapperManager();
- ctor public NavWrapperManager(optional java.util.List<? extends androidx.navigation3.NavContentWrapper> navContentWrappers);
- method @androidx.compose.runtime.Composable public <T> void ContentForRecord(androidx.navigation3.NavRecord<T> record);
+ ctor public NavWrapperManager(optional java.util.List<? extends androidx.navigation3.NavLocalProvider> navLocalProviders);
+ method @androidx.compose.runtime.Composable public <T> void ContentForEntry(androidx.navigation3.NavEntry<T> entry);
method @androidx.compose.runtime.Composable public void PrepareBackStack(java.util.List<?> backStack);
}
public final class NavWrapperManagerKt {
- method @androidx.compose.runtime.Composable public static androidx.navigation3.NavWrapperManager rememberNavWrapperManager(java.util.List<? extends androidx.navigation3.NavContentWrapper> navContentWrappers);
+ method @androidx.compose.runtime.Composable public static androidx.navigation3.NavWrapperManager rememberNavWrapperManager(java.util.List<? extends androidx.navigation3.NavLocalProvider> navLocalProviders);
}
- public final class RecordClassProvider<T> {
- ctor public RecordClassProvider(kotlin.reflect.KClass<T> clazz, java.util.Map<java.lang.String,?> featureMap, kotlin.jvm.functions.Function1<? super T,kotlin.Unit> content);
- method public kotlin.reflect.KClass<T> component1();
- method public java.util.Map<java.lang.String,java.lang.Object> component2();
- method public kotlin.jvm.functions.Function1<T,kotlin.Unit> component3();
- method public androidx.navigation3.RecordClassProvider<T> copy(kotlin.reflect.KClass<T> clazz, java.util.Map<java.lang.String,?> featureMap, kotlin.jvm.functions.Function1<? super T,kotlin.Unit> content);
- method public kotlin.reflect.KClass<T> getClazz();
- method public kotlin.jvm.functions.Function1<T,kotlin.Unit> getContent();
- method public java.util.Map<java.lang.String,java.lang.Object> getFeatureMap();
- property public final kotlin.reflect.KClass<T> clazz;
- property public final kotlin.jvm.functions.Function1<T,kotlin.Unit> content;
- property public final java.util.Map<java.lang.String,java.lang.Object> featureMap;
+ public final class SaveableStateNavLocalProvider implements androidx.navigation3.NavLocalProvider {
+ ctor public SaveableStateNavLocalProvider();
+ method @androidx.compose.runtime.Composable public <T> void ProvideToEntry(androidx.navigation3.NavEntry<T> entry);
}
- @kotlin.DslMarker public @interface RecordDsl {
- }
-
- public final class RecordProvider<T> {
- ctor public RecordProvider(T key, java.util.Map<java.lang.String,?> featureMap, kotlin.jvm.functions.Function1<? super T,kotlin.Unit> content);
- method public T component1();
- method public java.util.Map<java.lang.String,java.lang.Object> component2();
- method public kotlin.jvm.functions.Function1<T,kotlin.Unit> component3();
- method public androidx.navigation3.RecordProvider<T> copy(T key, java.util.Map<java.lang.String,?> featureMap, kotlin.jvm.functions.Function1<? super T,kotlin.Unit> content);
- method public kotlin.jvm.functions.Function1<T,kotlin.Unit> getContent();
- method public java.util.Map<java.lang.String,java.lang.Object> getFeatureMap();
- method public T getKey();
- property public final kotlin.jvm.functions.Function1<T,kotlin.Unit> content;
- property public final java.util.Map<java.lang.String,java.lang.Object> featureMap;
- property public final T key;
- }
-
- @androidx.navigation3.RecordDsl public final class RecordProviderBuilder {
- ctor public RecordProviderBuilder(kotlin.jvm.functions.Function1<java.lang.Object,? extends androidx.navigation3.NavRecord<? extends java.lang.Object?>> fallback);
- method public <T> void addRecordProvider(kotlin.reflect.KClass<T> clazz, optional java.util.Map<java.lang.String,?> featureMap, kotlin.jvm.functions.Function1<? super T,kotlin.Unit> content);
- method public <T> void addRecordProvider(T key, optional java.util.Map<java.lang.String,?> featureMap, kotlin.jvm.functions.Function1<? super T,kotlin.Unit> content);
- method public kotlin.jvm.functions.Function1<java.lang.Object,androidx.navigation3.NavRecord<? extends java.lang.Object?>> build();
- }
-
- public final class RecordProviderKt {
- method public static inline <reified T> void record(androidx.navigation3.RecordProviderBuilder, optional java.util.Map<java.lang.String,?> featureMap, kotlin.jvm.functions.Function1<? super T,kotlin.Unit> content);
- method public static <T> void record(androidx.navigation3.RecordProviderBuilder, T key, optional java.util.Map<java.lang.String,?> featureMap, kotlin.jvm.functions.Function1<? super T,kotlin.Unit> content);
- method public static inline kotlin.jvm.functions.Function1<java.lang.Object,androidx.navigation3.NavRecord<? extends java.lang.Object?>> recordProvider(optional kotlin.jvm.functions.Function1<java.lang.Object,? extends androidx.navigation3.NavRecord<? extends java.lang.Object?>> fallback, kotlin.jvm.functions.Function1<? super androidx.navigation3.RecordProviderBuilder,kotlin.Unit> builder);
- }
-
- public final class SaveableStateNavContentWrapper implements androidx.navigation3.NavContentWrapper {
- ctor public SaveableStateNavContentWrapper();
- method @androidx.compose.runtime.Composable public <T> void WrapContent(androidx.navigation3.NavRecord<T> record);
- }
-
- public final class SavedStateNavContentWrapper implements androidx.navigation3.NavContentWrapper {
- method @androidx.compose.runtime.Composable public <T> void WrapContent(androidx.navigation3.NavRecord<T> record);
- field public static final androidx.navigation3.SavedStateNavContentWrapper INSTANCE;
+ public final class SavedStateNavLocalProvider implements androidx.navigation3.NavLocalProvider {
+ method @androidx.compose.runtime.Composable public <T> void ProvideToEntry(androidx.navigation3.NavEntry<T> entry);
+ field public static final androidx.navigation3.SavedStateNavLocalProvider INSTANCE;
}
}
diff --git a/navigation3/navigation3/api/restricted_current.txt b/navigation3/navigation3/api/restricted_current.txt
index ec7e433..d8d07e0 100644
--- a/navigation3/navigation3/api/restricted_current.txt
+++ b/navigation3/navigation3/api/restricted_current.txt
@@ -1,9 +1,48 @@
// Signature format: 4.0
package androidx.navigation3 {
- public interface NavContentWrapper {
- method @androidx.compose.runtime.Composable public default void WrapBackStack(java.util.List<?> backStack);
- method @androidx.compose.runtime.Composable public <T> void WrapContent(androidx.navigation3.NavRecord<T> record);
+ public final class EntryClassProvider<T> {
+ ctor public EntryClassProvider(kotlin.reflect.KClass<T> clazz, java.util.Map<java.lang.String,?> featureMap, kotlin.jvm.functions.Function1<? super T,kotlin.Unit> content);
+ method public kotlin.reflect.KClass<T> component1();
+ method public java.util.Map<java.lang.String,java.lang.Object> component2();
+ method public kotlin.jvm.functions.Function1<T,kotlin.Unit> component3();
+ method public androidx.navigation3.EntryClassProvider<T> copy(kotlin.reflect.KClass<T> clazz, java.util.Map<java.lang.String,?> featureMap, kotlin.jvm.functions.Function1<? super T,kotlin.Unit> content);
+ method public kotlin.reflect.KClass<T> getClazz();
+ method public kotlin.jvm.functions.Function1<T,kotlin.Unit> getContent();
+ method public java.util.Map<java.lang.String,java.lang.Object> getFeatureMap();
+ property public final kotlin.reflect.KClass<T> clazz;
+ property public final kotlin.jvm.functions.Function1<T,kotlin.Unit> content;
+ property public final java.util.Map<java.lang.String,java.lang.Object> featureMap;
+ }
+
+ @kotlin.DslMarker public @interface EntryDsl {
+ }
+
+ public final class EntryProvider<T> {
+ ctor public EntryProvider(T key, java.util.Map<java.lang.String,?> featureMap, kotlin.jvm.functions.Function1<? super T,kotlin.Unit> content);
+ method public T component1();
+ method public java.util.Map<java.lang.String,java.lang.Object> component2();
+ method public kotlin.jvm.functions.Function1<T,kotlin.Unit> component3();
+ method public androidx.navigation3.EntryProvider<T> copy(T key, java.util.Map<java.lang.String,?> featureMap, kotlin.jvm.functions.Function1<? super T,kotlin.Unit> content);
+ method public kotlin.jvm.functions.Function1<T,kotlin.Unit> getContent();
+ method public java.util.Map<java.lang.String,java.lang.Object> getFeatureMap();
+ method public T getKey();
+ property public final kotlin.jvm.functions.Function1<T,kotlin.Unit> content;
+ property public final java.util.Map<java.lang.String,java.lang.Object> featureMap;
+ property public final T key;
+ }
+
+ @androidx.navigation3.EntryDsl public final class EntryProviderBuilder {
+ ctor public EntryProviderBuilder(kotlin.jvm.functions.Function1<java.lang.Object,? extends androidx.navigation3.NavEntry<? extends java.lang.Object?>> fallback);
+ method public <T> void addEntryProvider(kotlin.reflect.KClass<T> clazz, optional java.util.Map<java.lang.String,?> featureMap, kotlin.jvm.functions.Function1<? super T,kotlin.Unit> content);
+ method public <T> void addEntryProvider(T key, optional java.util.Map<java.lang.String,?> featureMap, kotlin.jvm.functions.Function1<? super T,kotlin.Unit> content);
+ method public kotlin.jvm.functions.Function1<java.lang.Object,androidx.navigation3.NavEntry<? extends java.lang.Object?>> build();
+ }
+
+ public final class EntryProviderKt {
+ method public static inline <reified T> void entry(androidx.navigation3.EntryProviderBuilder, optional java.util.Map<java.lang.String,?> featureMap, kotlin.jvm.functions.Function1<? super T,kotlin.Unit> content);
+ method public static <T> void entry(androidx.navigation3.EntryProviderBuilder, T key, optional java.util.Map<java.lang.String,?> featureMap, kotlin.jvm.functions.Function1<? super T,kotlin.Unit> content);
+ method public static inline kotlin.jvm.functions.Function1<java.lang.Object,androidx.navigation3.NavEntry<? extends java.lang.Object?>> entryProvider(optional kotlin.jvm.functions.Function1<java.lang.Object,? extends androidx.navigation3.NavEntry<? extends java.lang.Object?>> fallback, kotlin.jvm.functions.Function1<? super androidx.navigation3.EntryProviderBuilder,kotlin.Unit> builder);
}
public final class NavDisplay {
@@ -13,11 +52,11 @@
}
public final class NavDisplay_androidKt {
- method @androidx.compose.runtime.Composable public static <T> void NavDisplay(java.util.List<? extends T> backstack, optional androidx.compose.ui.Modifier modifier, optional androidx.navigation3.NavWrapperManager wrapperManager, optional androidx.compose.ui.Alignment contentAlignment, optional androidx.compose.animation.SizeTransform? sizeTransform, optional androidx.compose.animation.EnterTransition enterTransition, optional androidx.compose.animation.ExitTransition exitTransition, optional kotlin.jvm.functions.Function0<kotlin.Unit> onBack, kotlin.jvm.functions.Function1<? super T,? extends androidx.navigation3.NavRecord<? extends T>> recordProvider);
+ method @androidx.compose.runtime.Composable public static <T> void NavDisplay(java.util.List<? extends T> backstack, optional androidx.compose.ui.Modifier modifier, optional java.util.List<? extends androidx.navigation3.NavLocalProvider> localProviders, optional androidx.compose.ui.Alignment contentAlignment, optional androidx.compose.animation.SizeTransform? sizeTransform, optional androidx.compose.animation.EnterTransition enterTransition, optional androidx.compose.animation.ExitTransition exitTransition, optional kotlin.jvm.functions.Function0<kotlin.Unit> onBack, kotlin.jvm.functions.Function1<? super T,? extends androidx.navigation3.NavEntry<? extends T>> entryProvider);
}
- public final class NavRecord<T> {
- ctor public NavRecord(T key, optional java.util.Map<java.lang.String,?> featureMap, kotlin.jvm.functions.Function1<? super T,kotlin.Unit> content);
+ public final class NavEntry<T> {
+ ctor public NavEntry(T key, optional java.util.Map<java.lang.String,?> featureMap, kotlin.jvm.functions.Function1<? super T,kotlin.Unit> content);
method public kotlin.jvm.functions.Function1<T,kotlin.Unit> getContent();
method public java.util.Map<java.lang.String,java.lang.Object> getFeatureMap();
method public T getKey();
@@ -26,69 +65,30 @@
property public final T key;
}
+ public interface NavLocalProvider {
+ method @androidx.compose.runtime.Composable public default void ProvideToBackStack(java.util.List<?> backStack);
+ method @androidx.compose.runtime.Composable public <T> void ProvideToEntry(androidx.navigation3.NavEntry<T> entry);
+ }
+
public final class NavWrapperManager {
ctor public NavWrapperManager();
- ctor public NavWrapperManager(optional java.util.List<? extends androidx.navigation3.NavContentWrapper> navContentWrappers);
- method @androidx.compose.runtime.Composable public <T> void ContentForRecord(androidx.navigation3.NavRecord<T> record);
+ ctor public NavWrapperManager(optional java.util.List<? extends androidx.navigation3.NavLocalProvider> navLocalProviders);
+ method @androidx.compose.runtime.Composable public <T> void ContentForEntry(androidx.navigation3.NavEntry<T> entry);
method @androidx.compose.runtime.Composable public void PrepareBackStack(java.util.List<?> backStack);
}
public final class NavWrapperManagerKt {
- method @androidx.compose.runtime.Composable public static androidx.navigation3.NavWrapperManager rememberNavWrapperManager(java.util.List<? extends androidx.navigation3.NavContentWrapper> navContentWrappers);
+ method @androidx.compose.runtime.Composable public static androidx.navigation3.NavWrapperManager rememberNavWrapperManager(java.util.List<? extends androidx.navigation3.NavLocalProvider> navLocalProviders);
}
- public final class RecordClassProvider<T> {
- ctor public RecordClassProvider(kotlin.reflect.KClass<T> clazz, java.util.Map<java.lang.String,?> featureMap, kotlin.jvm.functions.Function1<? super T,kotlin.Unit> content);
- method public kotlin.reflect.KClass<T> component1();
- method public java.util.Map<java.lang.String,java.lang.Object> component2();
- method public kotlin.jvm.functions.Function1<T,kotlin.Unit> component3();
- method public androidx.navigation3.RecordClassProvider<T> copy(kotlin.reflect.KClass<T> clazz, java.util.Map<java.lang.String,?> featureMap, kotlin.jvm.functions.Function1<? super T,kotlin.Unit> content);
- method public kotlin.reflect.KClass<T> getClazz();
- method public kotlin.jvm.functions.Function1<T,kotlin.Unit> getContent();
- method public java.util.Map<java.lang.String,java.lang.Object> getFeatureMap();
- property public final kotlin.reflect.KClass<T> clazz;
- property public final kotlin.jvm.functions.Function1<T,kotlin.Unit> content;
- property public final java.util.Map<java.lang.String,java.lang.Object> featureMap;
+ public final class SaveableStateNavLocalProvider implements androidx.navigation3.NavLocalProvider {
+ ctor public SaveableStateNavLocalProvider();
+ method @androidx.compose.runtime.Composable public <T> void ProvideToEntry(androidx.navigation3.NavEntry<T> entry);
}
- @kotlin.DslMarker public @interface RecordDsl {
- }
-
- public final class RecordProvider<T> {
- ctor public RecordProvider(T key, java.util.Map<java.lang.String,?> featureMap, kotlin.jvm.functions.Function1<? super T,kotlin.Unit> content);
- method public T component1();
- method public java.util.Map<java.lang.String,java.lang.Object> component2();
- method public kotlin.jvm.functions.Function1<T,kotlin.Unit> component3();
- method public androidx.navigation3.RecordProvider<T> copy(T key, java.util.Map<java.lang.String,?> featureMap, kotlin.jvm.functions.Function1<? super T,kotlin.Unit> content);
- method public kotlin.jvm.functions.Function1<T,kotlin.Unit> getContent();
- method public java.util.Map<java.lang.String,java.lang.Object> getFeatureMap();
- method public T getKey();
- property public final kotlin.jvm.functions.Function1<T,kotlin.Unit> content;
- property public final java.util.Map<java.lang.String,java.lang.Object> featureMap;
- property public final T key;
- }
-
- @androidx.navigation3.RecordDsl public final class RecordProviderBuilder {
- ctor public RecordProviderBuilder(kotlin.jvm.functions.Function1<java.lang.Object,? extends androidx.navigation3.NavRecord<? extends java.lang.Object?>> fallback);
- method public <T> void addRecordProvider(kotlin.reflect.KClass<T> clazz, optional java.util.Map<java.lang.String,?> featureMap, kotlin.jvm.functions.Function1<? super T,kotlin.Unit> content);
- method public <T> void addRecordProvider(T key, optional java.util.Map<java.lang.String,?> featureMap, kotlin.jvm.functions.Function1<? super T,kotlin.Unit> content);
- method public kotlin.jvm.functions.Function1<java.lang.Object,androidx.navigation3.NavRecord<? extends java.lang.Object?>> build();
- }
-
- public final class RecordProviderKt {
- method public static inline <reified T> void record(androidx.navigation3.RecordProviderBuilder, optional java.util.Map<java.lang.String,?> featureMap, kotlin.jvm.functions.Function1<? super T,kotlin.Unit> content);
- method public static <T> void record(androidx.navigation3.RecordProviderBuilder, T key, optional java.util.Map<java.lang.String,?> featureMap, kotlin.jvm.functions.Function1<? super T,kotlin.Unit> content);
- method public static inline kotlin.jvm.functions.Function1<java.lang.Object,androidx.navigation3.NavRecord<? extends java.lang.Object?>> recordProvider(optional kotlin.jvm.functions.Function1<java.lang.Object,? extends androidx.navigation3.NavRecord<? extends java.lang.Object?>> fallback, kotlin.jvm.functions.Function1<? super androidx.navigation3.RecordProviderBuilder,kotlin.Unit> builder);
- }
-
- public final class SaveableStateNavContentWrapper implements androidx.navigation3.NavContentWrapper {
- ctor public SaveableStateNavContentWrapper();
- method @androidx.compose.runtime.Composable public <T> void WrapContent(androidx.navigation3.NavRecord<T> record);
- }
-
- public final class SavedStateNavContentWrapper implements androidx.navigation3.NavContentWrapper {
- method @androidx.compose.runtime.Composable public <T> void WrapContent(androidx.navigation3.NavRecord<T> record);
- field public static final androidx.navigation3.SavedStateNavContentWrapper INSTANCE;
+ public final class SavedStateNavLocalProvider implements androidx.navigation3.NavLocalProvider {
+ method @androidx.compose.runtime.Composable public <T> void ProvideToEntry(androidx.navigation3.NavEntry<T> entry);
+ field public static final androidx.navigation3.SavedStateNavLocalProvider INSTANCE;
}
}
diff --git a/navigation3/navigation3/samples/src/main/kotlin/androidx/navigation3/samples/NavDisplaySamples.kt b/navigation3/navigation3/samples/src/main/kotlin/androidx/navigation3/samples/NavDisplaySamples.kt
index fa6ff47..d4728e8 100644
--- a/navigation3/navigation3/samples/src/main/kotlin/androidx/navigation3/samples/NavDisplaySamples.kt
+++ b/navigation3/navigation3/samples/src/main/kotlin/androidx/navigation3/samples/NavDisplaySamples.kt
@@ -23,13 +23,12 @@
import androidx.compose.runtime.Composable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewmodel.compose.viewModel
-import androidx.lifecycle.viewmodel.navigation3.ViewModelStoreNavContentWrapper
+import androidx.lifecycle.viewmodel.navigation3.ViewModelStoreNavLocalProvider
import androidx.navigation3.NavDisplay
-import androidx.navigation3.NavRecord
-import androidx.navigation3.SavedStateNavContentWrapper
-import androidx.navigation3.record
-import androidx.navigation3.recordProvider
-import androidx.navigation3.rememberNavWrapperManager
+import androidx.navigation3.NavEntry
+import androidx.navigation3.SavedStateNavLocalProvider
+import androidx.navigation3.entry
+import androidx.navigation3.entryProvider
class ProfileViewModel : ViewModel() {
val name = "no user"
@@ -39,31 +38,27 @@
@Composable
fun BaseNav() {
val backStack = rememberMutableStateListOf(Profile)
- val manager =
- rememberNavWrapperManager(
- listOf(SavedStateNavContentWrapper, ViewModelStoreNavContentWrapper)
- )
NavDisplay(
backstack = backStack,
- wrapperManager = manager,
+ localProviders = listOf(SavedStateNavLocalProvider, ViewModelStoreNavLocalProvider),
onBack = { backStack.removeLast() },
- recordProvider =
- recordProvider({ NavRecord(Unit) { Text(text = "Invalid Key") } }) {
- record<Profile>(
+ entryProvider =
+ entryProvider({ NavEntry(Unit) { Text(text = "Invalid Key") } }) {
+ entry<Profile>(
NavDisplay.transition(slideInHorizontally { it }, slideOutHorizontally { it })
) {
val viewModel = viewModel<ProfileViewModel>()
Profile(viewModel, { backStack.add(it) }) { backStack.removeLast() }
}
- record<Scrollable>(
+ entry<Scrollable>(
NavDisplay.transition(slideInHorizontally { it }, slideOutHorizontally { it })
) {
Scrollable({ backStack.add(it) }) { backStack.removeLast() }
}
- record<Dialog>(featureMap = NavDisplay.isDialog(true)) {
+ entry<Dialog>(featureMap = NavDisplay.isDialog(true)) {
DialogContent { backStack.removeLast() }
}
- record<Dashboard>(
+ entry<Dashboard>(
NavDisplay.transition(slideInHorizontally { it }, slideOutHorizontally { it })
) { dashboardArgs ->
val userId = dashboardArgs.userId
diff --git a/navigation3/navigation3/src/androidInstrumentedTest/kotlin/androidx/navigation3/AnimatedTest.kt b/navigation3/navigation3/src/androidInstrumentedTest/kotlin/androidx/navigation3/AnimatedTest.kt
index d7da926..6fb5955 100644
--- a/navigation3/navigation3/src/androidInstrumentedTest/kotlin/androidx/navigation3/AnimatedTest.kt
+++ b/navigation3/navigation3/src/androidInstrumentedTest/kotlin/androidx/navigation3/AnimatedTest.kt
@@ -49,8 +49,8 @@
backstack = remember { mutableStateListOf(first) }
NavDisplay(backstack) {
when (it) {
- first -> NavRecord(first) { Text(first) }
- second -> NavRecord(second) { Text(second) }
+ first -> NavEntry(first) { Text(first) }
+ second -> NavEntry(second) { Text(second) }
else -> error("Invalid key passed")
}
}
@@ -93,13 +93,13 @@
NavDisplay(backstack) {
when (it) {
first ->
- NavRecord(
+ NavEntry(
first,
) {
Text(first)
}
second ->
- NavRecord(
+ NavEntry(
second,
featureMap =
NavDisplay.transition(
diff --git a/navigation3/navigation3/src/androidInstrumentedTest/kotlin/androidx/navigation3/NavDisplayTest.kt b/navigation3/navigation3/src/androidInstrumentedTest/kotlin/androidx/navigation3/NavDisplayTest.kt
index fd5749a..614eb88 100644
--- a/navigation3/navigation3/src/androidInstrumentedTest/kotlin/androidx/navigation3/NavDisplayTest.kt
+++ b/navigation3/navigation3/src/androidInstrumentedTest/kotlin/androidx/navigation3/NavDisplayTest.kt
@@ -34,6 +34,7 @@
import androidx.test.filters.LargeTest
import com.google.common.truth.Truth.assertWithMessage
import kotlin.test.Test
+import kotlin.test.assertFailsWith
import org.junit.Rule
import org.junit.runner.RunWith
@@ -45,7 +46,7 @@
@Test
fun testContentShown() {
composeTestRule.setContent {
- NavDisplay(backstack = mutableStateListOf(first)) { NavRecord(first) { Text(first) } }
+ NavDisplay(backstack = mutableStateListOf(first)) { NavEntry(first) { Text(first) } }
}
assertThat(composeTestRule.onNodeWithText(first).isDisplayed()).isTrue()
@@ -58,8 +59,8 @@
backstack = remember { mutableStateListOf(first) }
NavDisplay(backstack = backstack) {
when (it) {
- first -> NavRecord(first) { Text(first) }
- second -> NavRecord(second) { Text(second) }
+ first -> NavEntry(first) { Text(first) }
+ second -> NavEntry(second) { Text(second) }
else -> error("Invalid key passed")
}
}
@@ -80,8 +81,8 @@
backstack = remember { mutableStateListOf(first) }
NavDisplay(backstack = backstack) {
when (it) {
- first -> NavRecord(first) { Text(first) }
- second -> NavRecord(second, NavDisplay.isDialog(true)) { Text(second) }
+ first -> NavEntry(first) { Text(first) }
+ second -> NavEntry(second, NavDisplay.isDialog(true)) { Text(second) }
else -> error("Invalid key passed")
}
}
@@ -105,8 +106,8 @@
backstack = remember { mutableStateListOf(first) }
NavDisplay(backstack = backstack) {
when (it) {
- first -> NavRecord(first) { Text(first) }
- second -> NavRecord(second) { Text(second) }
+ first -> NavEntry(first) { Text(first) }
+ second -> NavEntry(second) { Text(second) }
else -> error("Invalid key passed")
}
}
@@ -132,8 +133,8 @@
backstack = remember { mutableStateListOf(first) }
NavDisplay(backstack = backstack) {
when (it) {
- first -> NavRecord(first) { numberOnScreen1 = rememberSaveable { increment++ } }
- second -> NavRecord(second) {}
+ first -> NavEntry(first) { numberOnScreen1 = rememberSaveable { increment++ } }
+ second -> NavEntry(second) {}
else -> error("Invalid key passed")
}
}
@@ -142,14 +143,11 @@
composeTestRule.runOnIdle {
assertWithMessage("Initial number should be 0").that(numberOnScreen1).isEqualTo(0)
numberOnScreen1 = -1
+ assertWithMessage("The number should be -1").that(numberOnScreen1).isEqualTo(-1)
backstack.add(second)
}
- composeTestRule.runOnIdle {
- assertWithMessage("The number should be -1").that(numberOnScreen1).isEqualTo(-1)
- // removeLast requires API 35
- backstack.removeAt(backstack.size - 1)
- }
+ composeTestRule.runOnIdle { backstack.removeAt(backstack.size - 1) }
composeTestRule.runOnIdle {
assertWithMessage("The number should be restored").that(numberOnScreen1).isEqualTo(0)
@@ -165,15 +163,14 @@
composeTestRule.setContent {
mainRegistry = LocalSavedStateRegistryOwner.current.savedStateRegistry
backstack = remember { mutableStateListOf(first) }
- val manager = rememberNavWrapperManager(listOf(SavedStateNavContentWrapper))
- NavDisplay(backstack = backstack, wrapperManager = manager) {
+ NavDisplay(backstack = backstack, localProviders = listOf(SavedStateNavLocalProvider)) {
when (it) {
first ->
- NavRecord(first) {
+ NavEntry(first) {
registry1 = LocalSavedStateRegistryOwner.current.savedStateRegistry
}
second ->
- NavRecord(second) {
+ NavEntry(second) {
registry2 = LocalSavedStateRegistryOwner.current.savedStateRegistry
}
else -> error("Invalid key passed")
@@ -212,12 +209,12 @@
2 -> backStack2
else -> backStack3
},
- recordProvider =
- recordProvider {
- record(first) { Text(first) }
- record(second) { Text(second) }
- record(third) { Text(third) }
- record(forth) { Text(forth) }
+ entryProvider =
+ entryProvider {
+ entry(first) { Text(first) }
+ entry(second) { Text(second) }
+ entry(third) { Text(third) }
+ entry(forth) { Text(forth) }
}
)
}
@@ -238,6 +235,43 @@
assertThat(backStack3).containsExactly(third, forth)
assertThat(composeTestRule.onNodeWithText(forth).isDisplayed()).isTrue()
}
+
+ @Test
+ fun testInitEmptyBackstackThrows() {
+ lateinit var backstack: MutableList<Any>
+ val fail =
+ assertFailsWith<IllegalArgumentException> {
+ composeTestRule.setContent {
+ backstack = remember { mutableStateListOf() }
+ NavDisplay(backstack = backstack) { NavEntry(first) {} }
+ }
+ }
+ assertThat(fail.message).isEqualTo("NavDisplay backstack cannot be empty")
+ }
+
+ @Test
+ fun testPopToEmptyBackstackThrows() {
+ lateinit var backstack: MutableList<Any>
+ composeTestRule.setContent {
+ backstack = remember { mutableStateListOf(first) }
+ NavDisplay(backstack = backstack) {
+ when (it) {
+ first -> NavEntry(first) { Text(first) }
+ second -> NavEntry(second) { Text(second) }
+ else -> error("Invalid key passed")
+ }
+ }
+ }
+
+ assertThat(composeTestRule.onNodeWithText(first).isDisplayed()).isTrue()
+
+ val fail =
+ assertFailsWith<IllegalArgumentException> {
+ composeTestRule.runOnIdle { backstack.clear() }
+ composeTestRule.waitForIdle()
+ }
+ assertThat(fail.message).isEqualTo("NavDisplay backstack cannot be empty")
+ }
}
private const val first = "first"
diff --git a/navigation3/navigation3/src/androidInstrumentedTest/kotlin/androidx/navigation3/NavWrapperManagerTest.kt b/navigation3/navigation3/src/androidInstrumentedTest/kotlin/androidx/navigation3/NavWrapperManagerTest.kt
index be41543..646df38 100644
--- a/navigation3/navigation3/src/androidInstrumentedTest/kotlin/androidx/navigation3/NavWrapperManagerTest.kt
+++ b/navigation3/navigation3/src/androidInstrumentedTest/kotlin/androidx/navigation3/NavWrapperManagerTest.kt
@@ -35,14 +35,14 @@
var calledWrapBackStack = false
var calledWrapContent = false
val wrapper =
- object : NavContentWrapper {
+ object : NavLocalProvider {
@Composable
- override fun WrapBackStack(backStack: List<Any>) {
+ override fun ProvideToBackStack(backStack: List<Any>) {
calledWrapBackStack = true
}
@Composable
- override fun <T : Any> WrapContent(record: NavRecord<T>) {
+ override fun <T : Any> ProvideToEntry(entry: NavEntry<T>) {
calledWrapContent = true
}
}
@@ -51,7 +51,7 @@
composeTestRule.setContent {
manager.PrepareBackStack(listOf("something"))
- manager.ContentForRecord(NavRecord("myKey") {})
+ manager.ContentForEntry(NavEntry("myKey") {})
}
assertThat(calledWrapBackStack).isTrue()
@@ -63,14 +63,14 @@
var calledWrapBackStackCount = 0
var calledWrapContentCount = 0
val wrapper =
- object : NavContentWrapper {
+ object : NavLocalProvider {
@Composable
- override fun WrapBackStack(backStack: List<Any>) {
+ override fun ProvideToBackStack(backStack: List<Any>) {
calledWrapBackStackCount++
}
@Composable
- override fun <T : Any> WrapContent(record: NavRecord<T>) {
+ override fun <T : Any> ProvideToEntry(entry: NavEntry<T>) {
calledWrapContentCount++
}
}
@@ -79,7 +79,7 @@
composeTestRule.setContent {
manager.PrepareBackStack(listOf("something"))
- manager.ContentForRecord(NavRecord("myKey") {})
+ manager.ContentForEntry(NavEntry("myKey") {})
}
assertThat(calledWrapBackStackCount).isEqualTo(1)
diff --git a/navigation3/navigation3/src/androidMain/kotlin/androidx/navigation3/NavDisplay.android.kt b/navigation3/navigation3/src/androidMain/kotlin/androidx/navigation3/NavDisplay.android.kt
index cdcff77..fdc1e24 100644
--- a/navigation3/navigation3/src/androidMain/kotlin/androidx/navigation3/NavDisplay.android.kt
+++ b/navigation3/navigation3/src/androidMain/kotlin/androidx/navigation3/NavDisplay.android.kt
@@ -34,7 +34,7 @@
/** Object that indicates the features that can be handled by the [NavDisplay] */
public object NavDisplay {
/**
- * Function to be called on the [NavRecord.featureMap] to notify the [NavDisplay] that the
+ * Function to be called on the [NavEntry.featureMap] to notify the [NavDisplay] that the
* content should be animated using the provided transitions.
*/
public fun transition(enter: EnterTransition?, exit: ExitTransition?): Map<String, Any> =
@@ -42,7 +42,7 @@
else mapOf(ENTER_TRANSITION_KEY to enter, EXIT_TRANSITION_KEY to exit)
/**
- * Function to be called on the [NavRecord.featureMap] to notify the [NavDisplay] that the
+ * Function to be called on the [NavEntry.featureMap] to notify the [NavDisplay] that the
* content should be displayed inside of a [Dialog]
*/
public fun isDialog(boolean: Boolean): Map<String, Any> =
@@ -60,29 +60,28 @@
*
* The NavDisplay displays the content associated with the last key on the back stack in most
* circumstances. If that content wants to be displayed as a dialog, as communicated by adding
- * [NavDisplay.isDialog] to a [NavRecord.featureMap], then the last key's content is a dialog and
- * the second to last key is a displayed in the background.
+ * [NavDisplay.isDialog] to a [NavEntry.featureMap], then the last key's content is a dialog and the
+ * second to last key is a displayed in the background.
*
* @param backstack the collection of keys that represents the state that needs to be handled
- * @param wrapperManager the manager that combines all of the [NavContentWrapper]s
+ * @param localProviders list of [NavLocalProvider] to add information to the provided entriess
* @param modifier the modifier to be applied to the layout.
* @param contentAlignment The [Alignment] of the [AnimatedContent]
- * * @param enterTransition Default [EnterTransition] for all [NavRecord]s. Can be overridden
- * * individually for each [NavRecord] by passing in the record's transitions through
- * * [NavRecord.featureMap].
- * * @param exitTransition Default [ExitTransition] for all [NavRecord]s. Can be overridden
- * * individually for each [NavRecord] by passing in the record's transitions through
- * * [NavRecord.featureMap].
- *
+ * @param enterTransition Default [EnterTransition] for all [NavEntry]s. Can be overridden
+ * individually for each [NavEntry] by passing in the entry's transitions through
+ * [NavEntry.featureMap].
+ * @param exitTransition Default [ExitTransition] for all [NavEntry]s. Can be overridden
+ * individually for each [NavEntry] by passing in the entry's transitions through
+ * [NavEntry.featureMap].
* @param onBack a callback for handling system back presses
- * @param recordProvider lambda used to construct each possible [NavRecord]
+ * @param entryProvider lambda used to construct each possible [NavEntry]
* @sample androidx.navigation3.samples.BaseNav
*/
@Composable
public fun <T : Any> NavDisplay(
backstack: List<T>,
modifier: Modifier = Modifier,
- wrapperManager: NavWrapperManager = rememberNavWrapperManager(emptyList()),
+ localProviders: List<NavLocalProvider> = emptyList(),
contentAlignment: Alignment = Alignment.TopStart,
sizeTransform: SizeTransform? = null,
enterTransition: EnterTransition =
@@ -100,33 +99,36 @@
)
),
onBack: () -> Unit = { if (backstack is MutableList) backstack.removeAt(backstack.size - 1) },
- recordProvider: (key: T) -> NavRecord<out T>
+ entryProvider: (key: T) -> NavEntry<out T>
) {
+ require(backstack.isNotEmpty()) { "NavDisplay backstack cannot be empty" }
+
+ val wrapperManager: NavWrapperManager = rememberNavWrapperManager(localProviders)
BackHandler(backstack.size > 1, onBack)
wrapperManager.PrepareBackStack(backStack = backstack)
val key = backstack.last()
- val record = recordProvider.invoke(key)
+ val entry = entryProvider.invoke(key)
- // Incoming record defines transitions, otherwise it uses default transitions from NavDisplay
+ // Incoming entry defines transitions, otherwise it uses default transitions from NavDisplay
val finalEnterTransition =
- record.featureMap[NavDisplay.ENTER_TRANSITION_KEY] as? EnterTransition ?: enterTransition
+ entry.featureMap[NavDisplay.ENTER_TRANSITION_KEY] as? EnterTransition ?: enterTransition
val finalExitTransition =
- record.featureMap[NavDisplay.EXIT_TRANSITION_KEY] as? ExitTransition ?: exitTransition
+ entry.featureMap[NavDisplay.EXIT_TRANSITION_KEY] as? ExitTransition ?: exitTransition
- val isDialog = record.featureMap[NavDisplay.DIALOG_KEY] == true
+ val isDialog = entry.featureMap[NavDisplay.DIALOG_KEY] == true
// if there is a dialog, we should create a transition with the next to last entry instead.
val transition =
if (isDialog) {
if (backstack.size > 1) {
val previousKey = backstack[backstack.size - 2]
- val previousRecord = recordProvider.invoke(previousKey)
- updateTransition(targetState = previousRecord, label = previousKey.toString())
+ val previousEntry = entryProvider.invoke(previousKey)
+ updateTransition(targetState = previousEntry, label = previousKey.toString())
} else {
null
}
} else {
- updateTransition(targetState = record, label = key.toString())
+ updateTransition(targetState = entry, label = key.toString())
}
transition?.AnimatedContent(
@@ -140,11 +142,11 @@
},
contentAlignment = contentAlignment,
contentKey = { it.key }
- ) { innerRecord ->
- wrapperManager.ContentForRecord(innerRecord)
+ ) { innerEntry ->
+ wrapperManager.ContentForEntry(innerEntry)
}
if (isDialog) {
- Dialog(onBack) { wrapperManager.ContentForRecord(record) }
+ Dialog(onBack) { wrapperManager.ContentForEntry(entry) }
}
}
diff --git a/navigation3/navigation3/src/commonMain/kotlin/androidx/navigation3/EntryProvider.kt b/navigation3/navigation3/src/commonMain/kotlin/androidx/navigation3/EntryProvider.kt
new file mode 100644
index 0000000..47471e4
--- /dev/null
+++ b/navigation3/navigation3/src/commonMain/kotlin/androidx/navigation3/EntryProvider.kt
@@ -0,0 +1,107 @@
+/*
+ * 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.navigation3
+
+import androidx.compose.runtime.Composable
+import kotlin.reflect.KClass
+
+@DslMarker public annotation class EntryDsl
+
+/** Creates an [EntryProviderBuilder] with the entry providers provided in the builder. */
+public inline fun entryProvider(
+ noinline fallback: (unknownScreen: Any) -> NavEntry<*> = {
+ throw IllegalStateException("Unknown screen $it")
+ },
+ builder: EntryProviderBuilder.() -> Unit
+): (Any) -> NavEntry<*> = EntryProviderBuilder(fallback).apply(builder).build()
+
+/** DSL for constructing a new [NavEntry] */
+@Suppress("TopLevelBuilder")
+@EntryDsl
+public class EntryProviderBuilder(private val fallback: (unknownScreen: Any) -> NavEntry<*>) {
+ private val clazzProviders = mutableMapOf<KClass<*>, EntryClassProvider<*>>()
+ private val providers = mutableMapOf<Any, EntryProvider<*>>()
+
+ /** Builds a [NavEntry] for the given [key] that displays [content]. */
+ @Suppress("SetterReturnsThis", "MissingGetterMatchingBuilder")
+ public fun <T : Any> addEntryProvider(
+ key: T,
+ featureMap: Map<String, Any> = emptyMap(),
+ content: @Composable (T) -> Unit,
+ ) {
+ require(key !in providers) {
+ "An `entry` with the key `key` has already been added: ${key}."
+ }
+ providers[key] = EntryProvider(key, featureMap, content)
+ }
+
+ /** Builds a [NavEntry] for the given [clazz] that displays [content]. */
+ @Suppress("SetterReturnsThis", "MissingGetterMatchingBuilder")
+ public fun <T : Any> addEntryProvider(
+ clazz: KClass<T>,
+ featureMap: Map<String, Any> = emptyMap(),
+ content: @Composable (T) -> Unit,
+ ) {
+ require(clazz !in clazzProviders) {
+ "An `entry` with the same `clazz` has already been added: ${clazz.simpleName}."
+ }
+ clazzProviders[clazz] = EntryClassProvider(clazz, featureMap, content)
+ }
+
+ /**
+ * Returns an instance of entryProvider created from the entry providers set on this builder.
+ */
+ @Suppress("UNCHECKED_CAST")
+ public fun build(): (Any) -> NavEntry<*> = { key ->
+ val entryClassProvider = clazzProviders[key::class] as? EntryClassProvider<Any>
+ val entryProvider = providers[key] as? EntryProvider<Any>
+ entryClassProvider?.run { NavEntry(key, featureMap, content) }
+ ?: entryProvider?.run { NavEntry(key, featureMap, content) }
+ ?: fallback.invoke(key)
+ }
+}
+
+/** Add an entry provider to the [EntryProviderBuilder] */
+public fun <T : Any> EntryProviderBuilder.entry(
+ key: T,
+ featureMap: Map<String, Any> = emptyMap(),
+ content: @Composable (T) -> Unit,
+) {
+ addEntryProvider(key, featureMap, content)
+}
+
+/** Add an entry provider to the [EntryProviderBuilder] */
+public inline fun <reified T : Any> EntryProviderBuilder.entry(
+ featureMap: Map<String, Any> = emptyMap(),
+ noinline content: @Composable (T) -> Unit,
+) {
+ addEntryProvider(T::class, featureMap, content)
+}
+
+/** Holds a Entry class, featureMap, and content for that class */
+public data class EntryClassProvider<T : Any>(
+ val clazz: KClass<T>,
+ val featureMap: Map<String, Any>,
+ val content: @Composable (T) -> Unit,
+)
+
+/** Holds a Entry class, featureMap, and content for that key */
+public data class EntryProvider<T : Any>(
+ val key: T,
+ val featureMap: Map<String, Any>,
+ val content: @Composable (T) -> Unit,
+)
diff --git a/navigation3/navigation3/src/commonMain/kotlin/androidx/navigation3/NavRecord.kt b/navigation3/navigation3/src/commonMain/kotlin/androidx/navigation3/NavEntry.kt
similarity index 72%
rename from navigation3/navigation3/src/commonMain/kotlin/androidx/navigation3/NavRecord.kt
rename to navigation3/navigation3/src/commonMain/kotlin/androidx/navigation3/NavEntry.kt
index 71cd4e0..e5d19d9 100644
--- a/navigation3/navigation3/src/commonMain/kotlin/androidx/navigation3/NavRecord.kt
+++ b/navigation3/navigation3/src/commonMain/kotlin/androidx/navigation3/NavEntry.kt
@@ -19,14 +19,14 @@
import androidx.compose.runtime.Composable
/**
- * Record maintains the store the key and the content represented by that key. Records should be
- * created as part of a [NavDisplay.recordProvider](reference/androidx/navigation/NavDisplay).
+ * Entry maintains and stores the key and the content represented by that key. Entries should be
+ * created as part of a [NavDisplay.entryProvider](reference/androidx/navigation/NavDisplay).
*
- * @param key key for this record
+ * @param key key for this entry
* @param featureMap map of the available features from a display
- * @param content content for this record to be displayed when this record is active
+ * @param content content for this entry to be displayed when this entry is active
*/
-public class NavRecord<T : Any>(
+public class NavEntry<T : Any>(
public val key: T,
public val featureMap: Map<String, Any> = emptyMap(),
public val content: @Composable (T) -> Unit,
diff --git a/navigation3/navigation3/src/commonMain/kotlin/androidx/navigation3/NavContentWrapper.kt b/navigation3/navigation3/src/commonMain/kotlin/androidx/navigation3/NavLocalProvider.kt
similarity index 73%
rename from navigation3/navigation3/src/commonMain/kotlin/androidx/navigation3/NavContentWrapper.kt
rename to navigation3/navigation3/src/commonMain/kotlin/androidx/navigation3/NavLocalProvider.kt
index 42d1652..b3fbeb2 100644
--- a/navigation3/navigation3/src/commonMain/kotlin/androidx/navigation3/NavContentWrapper.kt
+++ b/navigation3/navigation3/src/commonMain/kotlin/androidx/navigation3/NavLocalProvider.kt
@@ -22,23 +22,23 @@
* Interface that offers the ability to provide information to some Composable content that is
* integrated with a [NavDisplay](reference/androidx/navigation/NavDisplay).
*
- * Information can be provided to the entire back stack via [NavContentWrapper.WrapBackStack] or to
- * a single record via [NavContentWrapper.WrapContent].
+ * Information can be provided to the entire back stack via [NavLocalProvider.ProvideToBackStack] or
+ * to a single entry via [NavLocalProvider.ProvideToEntry].
*/
-public interface NavContentWrapper {
+public interface NavLocalProvider {
/**
- * Allows a [NavContentWrapper] to execute on the entire backstack.
+ * Allows a [NavLocalProvider] to provide to the entire backstack.
*
* This function is called by the [NavWrapperManager] and should not be called directly.
*/
- @Composable public fun WrapBackStack(backStack: List<Any>): Unit = Unit
+ @Composable public fun ProvideToBackStack(backStack: List<Any>): Unit = Unit
/**
- * Allows a [NavContentWrapper] to provide information to the content of a single record.
+ * Allows a [NavLocalProvider] to provide information to a single entry.
*
* This function is called by the [NavDisplay](reference/androidx/navigation/NavDisplay) and
* should not be called directly.
*/
- @Composable public fun <T : Any> WrapContent(record: NavRecord<T>)
+ @Composable public fun <T : Any> ProvideToEntry(entry: NavEntry<T>)
}
diff --git a/navigation3/navigation3/src/commonMain/kotlin/androidx/navigation3/NavWrapperManager.kt b/navigation3/navigation3/src/commonMain/kotlin/androidx/navigation3/NavWrapperManager.kt
index 4a137df..a994b16 100644
--- a/navigation3/navigation3/src/commonMain/kotlin/androidx/navigation3/NavWrapperManager.kt
+++ b/navigation3/navigation3/src/commonMain/kotlin/androidx/navigation3/NavWrapperManager.kt
@@ -19,56 +19,54 @@
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
-/** Creates a [NavContentWrapper]. */
+/** Creates a [NavLocalProvider]. */
@Composable
-public fun rememberNavWrapperManager(
- navContentWrappers: List<NavContentWrapper>
-): NavWrapperManager {
- return remember { NavWrapperManager(navContentWrappers) }
+public fun rememberNavWrapperManager(navLocalProviders: List<NavLocalProvider>): NavWrapperManager {
+ return remember { NavWrapperManager(navLocalProviders) }
}
/**
- * Class that manages all of the provided [NavContentWrapper]. It is responsible for executing the
- * functions provided by each [NavContentWrapper] appropriately.
+ * Class that manages all of the provided [NavLocalProvider]. It is responsible for executing the
+ * functions provided by each [NavLocalProvider] appropriately.
*
- * Note: the order in which the [NavContentWrapper]s are added to the list determines their scope,
- * i.e. a [NavContentWrapper] added earlier in a list has its data available to those added later.
+ * Note: the order in which the [NavLocalProvider]s are added to the list determines their scope,
+ * i.e. a [NavLocalProvider] added earlier in a list has its data available to those added later.
*
- * @param navContentWrappers the [NavContentWrapper]s that are providing data to the content
+ * @param navLocalProviders the [NavLocalProvider]s that are providing data to the content
*/
-public class NavWrapperManager(navContentWrappers: List<NavContentWrapper> = emptyList()) {
+public class NavWrapperManager(navLocalProviders: List<NavLocalProvider> = emptyList()) {
/**
- * Final list of wrappers. This always adds a [SaveableStateNavContentWrapper] by default, as it
+ * Final list of wrappers. This always adds a [SaveableStateNavLocalProvider] by default, as it
* is required. It then filters out any duplicates to ensure there is always one instance of any
* wrapper at a given time.
*/
private val finalWrappers =
- (navContentWrappers + listOf(SaveableStateNavContentWrapper())).distinct()
+ (navLocalProviders + listOf(SaveableStateNavLocalProvider())).distinct()
/**
- * Calls the [NavContentWrapper.WrapBackStack] functions on each wrapper
+ * Calls the [NavLocalProvider.ProvideToBackStack] functions on each wrapper
*
* This function is called by the [NavDisplay](reference/androidx/navigation/NavDisplay) and
* should not be called directly.
*/
@Composable
public fun PrepareBackStack(backStack: List<Any>) {
- finalWrappers.distinct().forEach { it.WrapBackStack(backStack = backStack) }
+ finalWrappers.distinct().forEach { it.ProvideToBackStack(backStack = backStack) }
}
/**
- * Calls the [NavContentWrapper.WrapContent] functions on each wrapper.
+ * Calls the [NavLocalProvider.ProvideToEntry] functions on each wrapper.
*
* This function is called by the [NavDisplay](reference/androidx/navigation/NavDisplay) and
* should not be called directly.
*/
@Composable
- public fun <T : Any> ContentForRecord(record: NavRecord<T>) {
- val key = record.key
+ public fun <T : Any> ContentForEntry(entry: NavEntry<T>) {
+ val key = entry.key
finalWrappers
.distinct()
- .foldRight(record.content) { wrapper, contentLambda ->
- { wrapper.WrapContent(NavRecord(key, record.featureMap, content = contentLambda)) }
+ .foldRight(entry.content) { wrapper, contentLambda ->
+ { wrapper.ProvideToEntry(NavEntry(key, entry.featureMap, content = contentLambda)) }
}
.invoke(key)
}
diff --git a/navigation3/navigation3/src/commonMain/kotlin/androidx/navigation3/RecordProvider.kt b/navigation3/navigation3/src/commonMain/kotlin/androidx/navigation3/RecordProvider.kt
deleted file mode 100644
index 7f6dd14..0000000
--- a/navigation3/navigation3/src/commonMain/kotlin/androidx/navigation3/RecordProvider.kt
+++ /dev/null
@@ -1,107 +0,0 @@
-/*
- * 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.navigation3
-
-import androidx.compose.runtime.Composable
-import kotlin.reflect.KClass
-
-@DslMarker public annotation class RecordDsl
-
-/** Creates an [RecordProviderBuilder] with the record providers provided in the builder. */
-public inline fun recordProvider(
- noinline fallback: (unknownScreen: Any) -> NavRecord<*> = {
- throw IllegalStateException("Unknown screen $it")
- },
- builder: RecordProviderBuilder.() -> Unit
-): (Any) -> NavRecord<*> = RecordProviderBuilder(fallback).apply(builder).build()
-
-/** DSL for constructing a new [NavRecord] */
-@Suppress("TopLevelBuilder")
-@RecordDsl
-public class RecordProviderBuilder(private val fallback: (unknownScreen: Any) -> NavRecord<*>) {
- private val clazzProviders = mutableMapOf<KClass<*>, RecordClassProvider<*>>()
- private val providers = mutableMapOf<Any, RecordProvider<*>>()
-
- /** Builds a [NavRecord] for the given [key] that displays [content]. */
- @Suppress("SetterReturnsThis", "MissingGetterMatchingBuilder")
- public fun <T : Any> addRecordProvider(
- key: T,
- featureMap: Map<String, Any> = emptyMap(),
- content: @Composable (T) -> Unit,
- ) {
- require(key !in providers) {
- "A `record` with the key `key` has already been added: ${key}."
- }
- providers[key] = RecordProvider(key, featureMap, content)
- }
-
- /** Builds a [NavRecord] for the given [clazz] that displays [content]. */
- @Suppress("SetterReturnsThis", "MissingGetterMatchingBuilder")
- public fun <T : Any> addRecordProvider(
- clazz: KClass<T>,
- featureMap: Map<String, Any> = emptyMap(),
- content: @Composable (T) -> Unit,
- ) {
- require(clazz !in clazzProviders) {
- "A `record` with the same `clazz` has already been added: ${clazz.simpleName}."
- }
- clazzProviders[clazz] = RecordClassProvider(clazz, featureMap, content)
- }
-
- /**
- * Returns an instance of recordProvider created from the record providers set on this builder.
- */
- @Suppress("UNCHECKED_CAST")
- public fun build(): (Any) -> NavRecord<*> = { key ->
- val recordClassProvider = clazzProviders[key::class] as? RecordClassProvider<Any>
- val recordProvider = providers[key] as? RecordProvider<Any>
- recordClassProvider?.run { NavRecord(key, featureMap, content) }
- ?: recordProvider?.run { NavRecord(key, featureMap, content) }
- ?: fallback.invoke(key)
- }
-}
-
-/** Add an record provider to the [RecordProviderBuilder] */
-public fun <T : Any> RecordProviderBuilder.record(
- key: T,
- featureMap: Map<String, Any> = emptyMap(),
- content: @Composable (T) -> Unit,
-) {
- addRecordProvider(key, featureMap, content)
-}
-
-/** Add an record provider to the [RecordProviderBuilder] */
-public inline fun <reified T : Any> RecordProviderBuilder.record(
- featureMap: Map<String, Any> = emptyMap(),
- noinline content: @Composable (T) -> Unit,
-) {
- addRecordProvider(T::class, featureMap, content)
-}
-
-/** Holds a Record class, featureMap, and content for that class */
-public data class RecordClassProvider<T : Any>(
- val clazz: KClass<T>,
- val featureMap: Map<String, Any>,
- val content: @Composable (T) -> Unit,
-)
-
-/** Holds a Record class, featureMap, and content for that key */
-public data class RecordProvider<T : Any>(
- val key: T,
- val featureMap: Map<String, Any>,
- val content: @Composable (T) -> Unit,
-)
diff --git a/navigation3/navigation3/src/commonMain/kotlin/androidx/navigation3/SaveableStateNavContentWrapper.kt b/navigation3/navigation3/src/commonMain/kotlin/androidx/navigation3/SaveableStateNavLocalProvider.kt
similarity index 85%
rename from navigation3/navigation3/src/commonMain/kotlin/androidx/navigation3/SaveableStateNavContentWrapper.kt
rename to navigation3/navigation3/src/commonMain/kotlin/androidx/navigation3/SaveableStateNavLocalProvider.kt
index 9c6443f..69451da 100644
--- a/navigation3/navigation3/src/commonMain/kotlin/androidx/navigation3/SaveableStateNavContentWrapper.kt
+++ b/navigation3/navigation3/src/commonMain/kotlin/androidx/navigation3/SaveableStateNavLocalProvider.kt
@@ -24,19 +24,19 @@
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
/**
- * Wraps the content of a [NavRecord] with a [SaveableStateHolder.SaveableStateProvider] to ensure
+ * Wraps the content of a [NavEntry] with a [SaveableStateHolder.SaveableStateProvider] to ensure
* that calls to [rememberSaveable] within the content work properly and that state can be saved.
*
- * This [NavContentWrapper] is the only one that is **required** as saving state is considered a
+ * This [NavLocalProvider] is the only one that is **required** as saving state is considered a
* non-optional feature.
*/
-public class SaveableStateNavContentWrapper : NavContentWrapper {
+public class SaveableStateNavLocalProvider : NavLocalProvider {
private var savedStateHolder: SaveableStateHolder? = null
private val refCount: MutableObjectIntMap<Any> = MutableObjectIntMap()
private var backstackSize = 0
@Composable
- override fun WrapBackStack(backStack: List<Any>) {
+ override fun ProvideToBackStack(backStack: List<Any>) {
DisposableEffect(key1 = backStack) {
refCount.clear()
onDispose {}
@@ -56,7 +56,7 @@
.getOrElse(key) {
error(
"Attempting to incorrectly dispose of backstack state in " +
- "SaveableStateNavContentWrapper"
+ "SaveableStateNavLocalProvider"
)
}
.minus(1)
@@ -67,8 +67,8 @@
}
@Composable
- public override fun <T : Any> WrapContent(record: NavRecord<T>) {
- val key = record.key
+ public override fun <T : Any> ProvideToEntry(entry: NavEntry<T>) {
+ val key = entry.key
DisposableEffect(key1 = key) {
refCount[key] = refCount.getOrDefault(key, 0).plus(1)
onDispose {
@@ -85,7 +85,7 @@
.getOrElse(key) {
error(
"Attempting to incorrectly dispose of state associated with " +
- "key $key in SaveableStateNavContentWrapper"
+ "key $key in SaveableStateNavLocalProvider"
)
}
.minus(1)
@@ -94,6 +94,6 @@
}
val id: Int = rememberSaveable(key) { key.hashCode() + backstackSize }
- savedStateHolder?.SaveableStateProvider(id) { record.content.invoke(key) }
+ savedStateHolder?.SaveableStateProvider(id) { entry.content.invoke(key) }
}
}
diff --git a/navigation3/navigation3/src/commonMain/kotlin/androidx/navigation3/SavedStateNavContentWrapper.kt b/navigation3/navigation3/src/commonMain/kotlin/androidx/navigation3/SavedStateNavLocalProvider.kt
similarity index 83%
rename from navigation3/navigation3/src/commonMain/kotlin/androidx/navigation3/SavedStateNavContentWrapper.kt
rename to navigation3/navigation3/src/commonMain/kotlin/androidx/navigation3/SavedStateNavLocalProvider.kt
index c651c3d..56febae 100644
--- a/navigation3/navigation3/src/commonMain/kotlin/androidx/navigation3/SavedStateNavContentWrapper.kt
+++ b/navigation3/navigation3/src/commonMain/kotlin/androidx/navigation3/SavedStateNavLocalProvider.kt
@@ -33,29 +33,29 @@
import androidx.savedstate.savedState
/**
- * Provides the content of a [NavRecord] with a [SavedStateRegistryOwner] and provides that
+ * Provides the content of a [NavEntry] with a [SavedStateRegistryOwner] and provides that
* [SavedStateRegistryOwner] as a [LocalSavedStateRegistryOwner] so that it is available within the
* content.
*/
-public object SavedStateNavContentWrapper : NavContentWrapper {
+public object SavedStateNavLocalProvider : NavLocalProvider {
@Composable
- override fun <T : Any> WrapContent(record: NavRecord<T>) {
- val key = record.key
+ override fun <T : Any> ProvideToEntry(entry: NavEntry<T>) {
+ val key = entry.key
val childRegistry by
rememberSaveable(
key,
stateSaver =
Saver(
save = { it.savedState },
- restore = { RecordSavedStateRegistry().apply { savedState = it } }
+ restore = { EntrySavedStateRegistry().apply { savedState = it } }
)
) {
- mutableStateOf(RecordSavedStateRegistry())
+ mutableStateOf(EntrySavedStateRegistry())
}
CompositionLocalProvider(LocalSavedStateRegistryOwner provides childRegistry) {
- record.content.invoke(key)
+ entry.content.invoke(key)
}
DisposableEffect(key1 = key) {
@@ -70,7 +70,7 @@
}
}
-private class RecordSavedStateRegistry : SavedStateRegistryOwner {
+private class EntrySavedStateRegistry : SavedStateRegistryOwner {
override val lifecycle: LifecycleRegistry = LifecycleRegistry(this)
val savedStateRegistryController = SavedStateRegistryController.create(this)
override val savedStateRegistry: SavedStateRegistry =
diff --git a/navigation3/navigation3/src/commonTest/kotlin/androidx/navigation3/RecordProviderTest.kt b/navigation3/navigation3/src/commonTest/kotlin/androidx/navigation3/EntryProviderTest.kt
similarity index 64%
rename from navigation3/navigation3/src/commonTest/kotlin/androidx/navigation3/RecordProviderTest.kt
rename to navigation3/navigation3/src/commonTest/kotlin/androidx/navigation3/EntryProviderTest.kt
index 67bb3e1..64e716e 100644
--- a/navigation3/navigation3/src/commonTest/kotlin/androidx/navigation3/RecordProviderTest.kt
+++ b/navigation3/navigation3/src/commonTest/kotlin/androidx/navigation3/EntryProviderTest.kt
@@ -20,40 +20,40 @@
import kotlin.test.Test
import kotlin.test.fail
-class RecordProviderTest {
+class EntryProviderTest {
@Test
- fun recordProvider_withUniqueInitializers_returnsRecords() {
- val provider = recordProvider {
- record("first") {}
- record("second") {}
+ fun entryProvider_withUniqueInitializers_returnsEntries() {
+ val provider = entryProvider {
+ entry("first") {}
+ entry("second") {}
}
- val record1 = provider.invoke("first")
- val record2 = provider.invoke("second")
+ val entry1 = provider.invoke("first")
+ val entry2 = provider.invoke("second")
- assertThat(record1.key).isEqualTo("first")
- assertThat(record2.key).isEqualTo("second")
+ assertThat(entry1.key).isEqualTo("first")
+ assertThat(entry2.key).isEqualTo("second")
}
@Test
- fun recordProvider_withDuplicatedInitializers_throwsException() {
+ fun entryProvider_withDuplicatedInitializers_throwsException() {
try {
- recordProvider {
- record("first") {}
- record("first") {}
+ entryProvider {
+ entry("first") {}
+ entry("first") {}
}
fail("Expected `IllegalArgumentException` but no exception has been throw.")
} catch (e: IllegalArgumentException) {
assertThat(e)
.hasMessageThat()
- .isEqualTo("A `record` with the key `key` has already been added: first.")
+ .isEqualTo("An `entry` with the key `key` has already been added: first.")
}
}
@Test
- fun recordProvider_noInitializers_getsInvalidRecord() {
- val provider = recordProvider {}
+ fun entryProvider_noInitializers_getsInvalidEntry() {
+ val provider = entryProvider {}
try {
provider.invoke("something")
fail("Expected `IllegalStateException` but no exception has been throw.")
diff --git a/navigation3/navigation3/src/commonTest/kotlin/androidx/navigation3/RecordTest.kt b/navigation3/navigation3/src/commonTest/kotlin/androidx/navigation3/RecordTest.kt
index b049d3f..863305e 100644
--- a/navigation3/navigation3/src/commonTest/kotlin/androidx/navigation3/RecordTest.kt
+++ b/navigation3/navigation3/src/commonTest/kotlin/androidx/navigation3/RecordTest.kt
@@ -19,24 +19,24 @@
import androidx.kruth.assertThat
import kotlin.test.Test
-class RecordTest {
+class EntryTest {
@Test
fun getKey() {
- val record = NavRecord(key = "myKey", content = {})
- assertThat(record.key).isEqualTo("myKey")
+ val entry = NavEntry(key = "myKey", content = {})
+ assertThat(entry.key).isEqualTo("myKey")
}
@Test
fun getFeatureMap() {
- val record =
- NavRecord(
+ val entry =
+ NavEntry(
key = "myKey",
featureMap = mapOf("feature1" to 1, "feature2" to MyObject),
content = {}
)
- assertThat(record.featureMap["feature1"]).isEqualTo(1)
- assertThat(record.featureMap["feature2"]).isEqualTo(MyObject)
+ assertThat(entry.featureMap["feature1"]).isEqualTo(1)
+ assertThat(entry.featureMap["feature2"]).isEqualTo(MyObject)
}
object MyObject
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 3d44721..1dc4afe 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
@@ -853,9 +853,9 @@
private fun resetViewsAndModels(fileUri: Uri) {
if (pdfLoader != null) {
pdfLoaderCallbacks?.uri = fileUri
- paginatedView?.resetModels()
destroyContentModel()
}
+ paginatedView?.resetModels()
fastScrollView?.resetContents()
findInFileView?.resetFindInFile()
}
diff --git a/pdf/pdf-viewer-fragment/src/main/kotlin/androidx/pdf/viewer/fragment/PdfViewerFragmentV2.kt b/pdf/pdf-viewer-fragment/src/main/kotlin/androidx/pdf/viewer/fragment/PdfViewerFragmentV2.kt
index ec8233d..19b75a2 100644
--- a/pdf/pdf-viewer-fragment/src/main/kotlin/androidx/pdf/viewer/fragment/PdfViewerFragmentV2.kt
+++ b/pdf/pdf-viewer-fragment/src/main/kotlin/androidx/pdf/viewer/fragment/PdfViewerFragmentV2.kt
@@ -52,7 +52,6 @@
import androidx.pdf.viewer.fragment.search.PdfSearchViewManager
import androidx.pdf.viewer.fragment.util.getCenter
import androidx.pdf.viewer.fragment.view.PdfViewManager
-import com.google.android.material.color.MaterialColors
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
@@ -202,19 +201,9 @@
pdfViewManager =
PdfViewManager(
pdfView = pdfView,
- // TODO(b/385684706): Update colors for highlights
selectedHighlightColor =
- MaterialColors.getColor(
- pdfView,
- com.google.android.material.R.attr.colorPrimaryFixed,
- requireContext().getColor(R.color.selected_highlight_color)
- ),
- highlightColor =
- MaterialColors.getColor(
- pdfView,
- com.google.android.material.R.attr.colorSecondaryFixedDim,
- requireContext().getColor(R.color.highlight_color)
- )
+ requireContext().getColor(R.color.selected_highlight_color),
+ highlightColor = requireContext().getColor(R.color.highlight_color)
)
pdfSearchViewManager = PdfSearchViewManager(pdfSearchView)
diff --git a/performance/performance-annotation/README.md b/performance/performance-annotation/README.md
index b37c607..2ab0c3d 100644
--- a/performance/performance-annotation/README.md
+++ b/performance/performance-annotation/README.md
@@ -1 +1 @@
-The annotation defined in this library only affects Android.
+This library is a **compile-time** only dependency.
diff --git a/performance/performance-annotation/src/androidMain/kotlin/dalvik/annotation/optimization/NeverInline.android.kt b/performance/performance-annotation/src/androidMain/kotlin/dalvik/annotation/optimization/NeverInline.android.kt
index a44f0ff..50e22dc 100644
--- a/performance/performance-annotation/src/androidMain/kotlin/dalvik/annotation/optimization/NeverInline.android.kt
+++ b/performance/performance-annotation/src/androidMain/kotlin/dalvik/annotation/optimization/NeverInline.android.kt
@@ -14,10 +14,8 @@
* limitations under the License.
*/
-@file:Suppress("RedundantVisibilityModifier")
-
package dalvik.annotation.optimization
@Retention(AnnotationRetention.BINARY)
@Target(AnnotationTarget.CONSTRUCTOR, AnnotationTarget.FUNCTION)
-public actual annotation class NeverInline
+public actual annotation class NeverInline()
diff --git a/performance/performance-annotation/src/commonMain/kotlin/dalvik/annotation/optimization/NeverInline.kt b/performance/performance-annotation/src/commonMain/kotlin/dalvik/annotation/optimization/NeverInline.kt
index 99f736b..89cbfe3 100644
--- a/performance/performance-annotation/src/commonMain/kotlin/dalvik/annotation/optimization/NeverInline.kt
+++ b/performance/performance-annotation/src/commonMain/kotlin/dalvik/annotation/optimization/NeverInline.kt
@@ -15,7 +15,6 @@
*/
@file:OptIn(ExperimentalMultiplatform::class)
-@file:Suppress("RedundantVisibilityModifier")
package dalvik.annotation.optimization
diff --git a/privacysandbox/ui/integration-tests/mediateesdkproviderwrapper/build.gradle b/privacysandbox/ui/integration-tests/mediateesdkproviderwrapper/build.gradle
index 5aed355..88365c64 100644
--- a/privacysandbox/ui/integration-tests/mediateesdkproviderwrapper/build.gradle
+++ b/privacysandbox/ui/integration-tests/mediateesdkproviderwrapper/build.gradle
@@ -28,6 +28,10 @@
minSdk = 21
buildToolsVersion = AndroidXConfig.getDefaultAndroidConfig(project).buildToolsVersion
+ //TODO(b/389890488): This is added to suppress missing stub classes warning from ui-compose.
+ //Can be removed once linked bug is fixed.
+ optimization.keepRules.files += project.file('proguard-rules.pro')
+
bundle {
packageName = "androidx.privacysandbox.ui.integration.mediateesdkproviderwrapper"
diff --git a/privacysandbox/ui/integration-tests/mediateesdkproviderwrapper/proguard-rules.pro b/privacysandbox/ui/integration-tests/mediateesdkproviderwrapper/proguard-rules.pro
new file mode 100644
index 0000000..13de6f6
--- /dev/null
+++ b/privacysandbox/ui/integration-tests/mediateesdkproviderwrapper/proguard-rules.pro
@@ -0,0 +1,13 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# We supply these as stubs and are able to link to them at runtime
+# because they are hidden public classes in Android. We don't want
+# R8 to complain about them not being there during optimization.
+-dontwarn android.view.RenderNode
+-dontwarn android.view.DisplayListCanvas
+-dontwarn android.view.HardwareCanvas
diff --git a/privacysandbox/ui/integration-tests/testapp/build.gradle b/privacysandbox/ui/integration-tests/testapp/build.gradle
index 7ec0127..a9a297b 100644
--- a/privacysandbox/ui/integration-tests/testapp/build.gradle
+++ b/privacysandbox/ui/integration-tests/testapp/build.gradle
@@ -16,9 +16,9 @@
plugins {
id("AndroidXPlugin")
+ id("AndroidXComposePlugin")
id("com.android.application")
id("org.jetbrains.kotlin.android")
- id("AndroidXComposePlugin")
}
android {
@@ -45,6 +45,7 @@
dependencies {
implementation(libs.kotlinStdlib)
api("androidx.annotation:annotation:1.8.1")
+
implementation("androidx.core:core-ktx:1.8.0")
implementation("androidx.appcompat:appcompat:1.6.0")
implementation("com.google.android.material:material:1.6.0")
@@ -52,9 +53,7 @@
implementation("androidx.drawerlayout:drawerlayout:1.2.0")
implementation("androidx.constraintlayout:constraintlayout:2.0.1")
implementation("androidx.recyclerview:recyclerview:1.3.2")
- implementation("androidx.compose.foundation:foundation-layout:1.7.5")
- implementation("androidx.compose.material3:material3-android:1.3.1")
- implementation("androidx.compose.ui:ui-util:1.7.5")
+ implementation("androidx.compose.material3:material3:1.3.1")
implementation(project(":privacysandbox:activity:activity-core"))
implementation(project(":privacysandbox:activity:activity-client"))
implementation(project(":privacysandbox:sdkruntime:sdkruntime-client"))
diff --git a/privacysandbox/ui/integration-tests/testapp/src/main/java/androidx/privacysandbox/ui/integration/testapp/BaseFragment.kt b/privacysandbox/ui/integration-tests/testapp/src/main/java/androidx/privacysandbox/ui/integration/testapp/BaseFragment.kt
index 56e84a8..3559d29 100644
--- a/privacysandbox/ui/integration-tests/testapp/src/main/java/androidx/privacysandbox/ui/integration/testapp/BaseFragment.kt
+++ b/privacysandbox/ui/integration-tests/testapp/src/main/java/androidx/privacysandbox/ui/integration/testapp/BaseFragment.kt
@@ -23,6 +23,9 @@
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
import androidx.fragment.app.Fragment
import androidx.privacysandbox.sdkruntime.client.SdkSandboxManagerCompat
import androidx.privacysandbox.ui.client.SandboxedUiAdapterFactory
@@ -46,6 +49,7 @@
private lateinit var sdkSandboxManager: SdkSandboxManagerCompat
private lateinit var activity: Activity
+ protected var providerUiOnTop by mutableStateOf(true)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -117,9 +121,8 @@
}
open fun handleDrawerStateChange(isDrawerOpen: Boolean) {
- getSandboxedSdkViews().forEach {
- it.orderProviderUiAboveClientUi(!isDrawerOpen && isZOrderOnTop)
- }
+ providerUiOnTop = !isDrawerOpen && !isZOrderBelowToggleChecked
+ getSandboxedSdkViews().forEach { it.orderProviderUiAboveClientUi(providerUiOnTop) }
}
private inner class TestEventListener(val view: SandboxedSdkView) :
@@ -147,7 +150,7 @@
private const val MEDIATEE_SDK_NAME =
"androidx.privacysandbox.ui.integration.mediateesdkproviderwrapper"
const val TAG = "TestSandboxClient"
- var isZOrderOnTop = true
+ var isZOrderBelowToggleChecked = false
@AdType var currentAdType = AdType.BASIC_NON_WEBVIEW
@MediationOption var currentMediationOption = MediationOption.NON_MEDIATED
var shouldDrawViewabilityLayer = false
diff --git a/privacysandbox/ui/integration-tests/testapp/src/main/java/androidx/privacysandbox/ui/integration/testapp/MainActivity.kt b/privacysandbox/ui/integration-tests/testapp/src/main/java/androidx/privacysandbox/ui/integration/testapp/MainActivity.kt
index 8ba632c..8467ece 100644
--- a/privacysandbox/ui/integration-tests/testapp/src/main/java/androidx/privacysandbox/ui/integration/testapp/MainActivity.kt
+++ b/privacysandbox/ui/integration-tests/testapp/src/main/java/androidx/privacysandbox/ui/integration/testapp/MainActivity.kt
@@ -237,7 +237,7 @@
private fun initializeZOrderToggleButton() {
zOrderToggleButton.setOnCheckedChangeListener { _, isChecked ->
- BaseFragment.isZOrderOnTop = !isChecked
+ BaseFragment.isZOrderBelowToggleChecked = isChecked
}
}
@@ -290,8 +290,24 @@
private fun selectCuj(menuItem: MenuItem) {
when (menuItem.itemId) {
- R.id.item_resize -> switchContentFragment(ResizeFragment(), menuItem.title)
- R.id.item_scroll -> switchContentFragment(ScrollFragment(), menuItem.title)
+ R.id.item_resize ->
+ if (useCompose) {
+ switchContentFragment(
+ ResizeComposeFragment(),
+ "${menuItem.title} ${getString(R.string.compose)}"
+ )
+ } else {
+ switchContentFragment(ResizeFragment(), menuItem.title)
+ }
+ R.id.item_scroll ->
+ if (useCompose) {
+ switchContentFragment(
+ ScrollComposeFragment(),
+ "${menuItem.title} ${getString(R.string.compose)}"
+ )
+ } else {
+ switchContentFragment(ScrollFragment(), menuItem.title)
+ }
R.id.item_pooling_container ->
switchContentFragment(PoolingContainerFragment(), menuItem.title)
R.id.item_fullscreen ->
diff --git a/privacysandbox/ui/integration-tests/testapp/src/main/java/androidx/privacysandbox/ui/integration/testapp/PoolingContainerFragment.kt b/privacysandbox/ui/integration-tests/testapp/src/main/java/androidx/privacysandbox/ui/integration/testapp/PoolingContainerFragment.kt
index abdf0d4..fd5d6e9 100644
--- a/privacysandbox/ui/integration-tests/testapp/src/main/java/androidx/privacysandbox/ui/integration/testapp/PoolingContainerFragment.kt
+++ b/privacysandbox/ui/integration-tests/testapp/src/main/java/androidx/privacysandbox/ui/integration/testapp/PoolingContainerFragment.kt
@@ -38,7 +38,7 @@
override fun handleDrawerStateChange(isDrawerOpen: Boolean) {
super.handleDrawerStateChange(isDrawerOpen)
- (recyclerView.adapter as CustomAdapter).zOrderOnTop = !isDrawerOpen && isZOrderOnTop
+ (recyclerView.adapter as CustomAdapter).zOrderOnTop = providerUiOnTop
}
override fun handleLoadAdFromDrawer(
diff --git a/privacysandbox/ui/integration-tests/testapp/src/main/java/androidx/privacysandbox/ui/integration/testapp/ResizeComposeFragment.kt b/privacysandbox/ui/integration-tests/testapp/src/main/java/androidx/privacysandbox/ui/integration/testapp/ResizeComposeFragment.kt
new file mode 100644
index 0000000..c537976
--- /dev/null
+++ b/privacysandbox/ui/integration-tests/testapp/src/main/java/androidx/privacysandbox/ui/integration/testapp/ResizeComposeFragment.kt
@@ -0,0 +1,152 @@
+/*
+ * 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.privacysandbox.ui.integration.testapp
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.Button
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.platform.ViewCompositionStrategy
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.privacysandbox.ui.client.SandboxedUiAdapterFactory
+import androidx.privacysandbox.ui.client.view.SandboxedSdkUi
+import androidx.privacysandbox.ui.client.view.SandboxedSdkViewEventListener
+import androidx.privacysandbox.ui.core.SandboxedUiAdapter
+import kotlinx.coroutines.MainScope
+import kotlinx.coroutines.launch
+
+class ResizeComposeFragment : BaseFragment() {
+
+ private var adapter: SandboxedUiAdapter? by mutableStateOf(null)
+ private var adEventText by mutableStateOf("")
+ private var bannerDimension by mutableStateOf(BannerDimension())
+ private val onBannerDimensionChanged: (BannerDimension) -> Unit = { currentDimension ->
+ val displayMetrics = resources.displayMetrics
+ val maxSizePixels = Math.min(displayMetrics.widthPixels, displayMetrics.heightPixels)
+ val newSize = { currentSize: Int, maxSize: Int ->
+ (currentSize + (100..200).random()) % maxSize
+ }
+ val newWidth = newSize(currentDimension.width.value.toInt(), maxSizePixels).dp
+ val newHeight = newSize(currentDimension.height.value.toInt(), maxSizePixels).dp
+ bannerDimension = BannerDimension(newWidth, newHeight)
+ }
+
+ override fun handleLoadAdFromDrawer(
+ adType: Int,
+ mediationOption: Int,
+ drawViewabilityLayer: Boolean
+ ) {
+ currentAdType = adType
+ currentMediationOption = mediationOption
+ shouldDrawViewabilityLayer = drawViewabilityLayer
+ setAdAdapter()
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ setAdAdapter()
+ return ComposeView(requireContext()).apply {
+ // Dispose of the Composition when the view's LifecycleOwner is destroyed
+ setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
+ setContent { ResizeableBannerAd(adapter, bannerDimension, onBannerDimensionChanged) }
+ }
+ }
+
+ @Composable
+ fun ResizeableBannerAd(
+ adapter: SandboxedUiAdapter?,
+ bannerDimension: BannerDimension,
+ onResizeClicked: (BannerDimension) -> Unit
+ ) {
+ Column(
+ modifier = Modifier.fillMaxSize().padding(16.dp),
+ verticalArrangement = Arrangement.Top,
+ horizontalAlignment = Alignment.Start
+ ) {
+ val sandboxedSdkUiModifier =
+ if (bannerDimension.height != 0.dp && bannerDimension.width != 0.dp) {
+ Modifier.width(bannerDimension.width)
+ .weight(
+ 1f,
+ )
+ } else {
+ Modifier.fillMaxWidth().weight(1f)
+ }
+
+ Text("Ad state: $adEventText")
+ if (adapter != null) {
+ SandboxedSdkUi(
+ adapter,
+ sandboxedSdkUiModifier,
+ providerUiOnTop = providerUiOnTop,
+ sandboxedSdkViewEventListener =
+ object : SandboxedSdkViewEventListener {
+ override fun onUiDisplayed() {
+ adEventText = "Ad is visible"
+ }
+
+ override fun onUiError(error: Throwable) {
+ adEventText = "Error loading ad : ${error.message}"
+ }
+
+ override fun onUiClosed() {
+ adEventText = "Ad session is closed"
+ }
+ },
+ )
+ }
+ Button(onClick = { onResizeClicked(bannerDimension) }) { Text("Resize") }
+ }
+ }
+
+ private fun setAdAdapter() {
+ val coroutineScope = MainScope()
+ coroutineScope.launch {
+ adapter =
+ SandboxedUiAdapterFactory.createFromCoreLibInfo(
+ getSdkApi()
+ .loadBannerAd(
+ currentAdType,
+ currentMediationOption,
+ false,
+ shouldDrawViewabilityLayer,
+ )
+ )
+ }
+ }
+
+ data class BannerDimension(val width: Dp = 0.dp, val height: Dp = 0.dp)
+}
diff --git a/privacysandbox/ui/integration-tests/testapp/src/main/java/androidx/privacysandbox/ui/integration/testapp/ScrollComposeFragment.kt b/privacysandbox/ui/integration-tests/testapp/src/main/java/androidx/privacysandbox/ui/integration/testapp/ScrollComposeFragment.kt
new file mode 100644
index 0000000..6acfb9c
--- /dev/null
+++ b/privacysandbox/ui/integration-tests/testapp/src/main/java/androidx/privacysandbox/ui/integration/testapp/ScrollComposeFragment.kt
@@ -0,0 +1,121 @@
+/*
+ * Copyright 2025 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.privacysandbox.ui.integration.testapp
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Text
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.platform.ViewCompositionStrategy
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.privacysandbox.ui.client.SandboxedUiAdapterFactory
+import androidx.privacysandbox.ui.client.view.SandboxedSdkUi
+import androidx.privacysandbox.ui.core.SandboxedUiAdapter
+import kotlinx.coroutines.MainScope
+import kotlinx.coroutines.launch
+
+class ScrollComposeFragment : BaseFragment() {
+
+ private var bottomBannerAdapter: SandboxedUiAdapter? by mutableStateOf(null)
+ private var scrollBannerAdapter: SandboxedUiAdapter? by mutableStateOf(null)
+
+ override fun handleLoadAdFromDrawer(
+ adType: Int,
+ mediationOption: Int,
+ drawViewabilityLayer: Boolean
+ ) {
+ currentAdType = adType
+ currentMediationOption = mediationOption
+ shouldDrawViewabilityLayer = drawViewabilityLayer
+ setAdapter()
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ setAdapter()
+ return ComposeView(requireContext()).apply {
+ // Dispose of the Composition when the view's LifecycleOwner is destroyed
+ setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
+ setContent {
+ Column(
+ modifier = Modifier.fillMaxSize().padding(16.dp),
+ verticalArrangement = Arrangement.Top,
+ horizontalAlignment = Alignment.Start
+ ) {
+ Column(modifier = Modifier.weight(0.8f).verticalScroll(rememberScrollState())) {
+ scrollBannerAdapter?.let {
+ SandboxedSdkUi(
+ it,
+ Modifier.fillMaxWidth().height(200.dp),
+ providerUiOnTop = providerUiOnTop
+ )
+ }
+ Text(stringResource(R.string.long_text), Modifier.padding(vertical = 16.dp))
+ }
+ bottomBannerAdapter?.let {
+ SandboxedSdkUi(it, Modifier.weight(0.2f), providerUiOnTop = providerUiOnTop)
+ }
+ }
+ }
+ }
+ }
+
+ private fun setAdapter() {
+ val coroutineScope = MainScope()
+ coroutineScope.launch {
+ bottomBannerAdapter =
+ SandboxedUiAdapterFactory.createFromCoreLibInfo(
+ getSdkApi()
+ .loadBannerAd(
+ currentAdType,
+ currentMediationOption,
+ false,
+ shouldDrawViewabilityLayer,
+ )
+ )
+ scrollBannerAdapter =
+ SandboxedUiAdapterFactory.createFromCoreLibInfo(
+ getSdkApi()
+ .loadBannerAd(
+ currentAdType,
+ currentMediationOption,
+ false,
+ shouldDrawViewabilityLayer,
+ )
+ )
+ }
+ }
+}
diff --git a/privacysandbox/ui/integration-tests/testapp/src/main/res/values/strings.xml b/privacysandbox/ui/integration-tests/testapp/src/main/res/values/strings.xml
index cfdbb065..143c337 100644
--- a/privacysandbox/ui/integration-tests/testapp/src/main/res/values/strings.xml
+++ b/privacysandbox/ui/integration-tests/testapp/src/main/res/values/strings.xml
@@ -16,6 +16,7 @@
<resources>
<string name="resize_cuj">Resize CUJ</string>
+ <string name="compose">Compose</string>
<string name="scroll_cuj">Scroll CUJ</string>
<string name="poolingcontainer_cuj">PoolingContainer CUJ</string>
<string name="fullscreen_cuj">Fullscreen CUJ</string>
diff --git a/privacysandbox/ui/integration-tests/testsdkproviderwrapper/build.gradle b/privacysandbox/ui/integration-tests/testsdkproviderwrapper/build.gradle
index 96d56fa..0532e9c 100644
--- a/privacysandbox/ui/integration-tests/testsdkproviderwrapper/build.gradle
+++ b/privacysandbox/ui/integration-tests/testsdkproviderwrapper/build.gradle
@@ -28,6 +28,10 @@
minSdk = 21
buildToolsVersion = AndroidXConfig.getDefaultAndroidConfig(project).buildToolsVersion
+ //TODO(b/389890488): This is added to suppress missing stub classes warning from ui-compose.
+ //Can be removed once linked bug is fixed.
+ optimization.keepRules.files += project.file('proguard-rules.pro')
+
bundle {
packageName = "androidx.privacysandbox.ui.integration.testsdkproviderwrapper"
diff --git a/privacysandbox/ui/integration-tests/testsdkproviderwrapper/proguard-rules.pro b/privacysandbox/ui/integration-tests/testsdkproviderwrapper/proguard-rules.pro
new file mode 100644
index 0000000..13de6f6
--- /dev/null
+++ b/privacysandbox/ui/integration-tests/testsdkproviderwrapper/proguard-rules.pro
@@ -0,0 +1,13 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# We supply these as stubs and are able to link to them at runtime
+# because they are hidden public classes in Android. We don't want
+# R8 to complain about them not being there during optimization.
+-dontwarn android.view.RenderNode
+-dontwarn android.view.DisplayListCanvas
+-dontwarn android.view.HardwareCanvas
diff --git a/privacysandbox/ui/ui-client/api/current.txt b/privacysandbox/ui/ui-client/api/current.txt
index a3d0e4d..94a15f0 100644
--- a/privacysandbox/ui/ui-client/api/current.txt
+++ b/privacysandbox/ui/ui-client/api/current.txt
@@ -10,6 +10,10 @@
package androidx.privacysandbox.ui.client.view {
+ public final class SandboxedSdkUiKt {
+ method @androidx.compose.runtime.Composable public static void SandboxedSdkUi(androidx.privacysandbox.ui.core.SandboxedUiAdapter sandboxedUiAdapter, optional androidx.compose.ui.Modifier modifier, optional boolean providerUiOnTop, optional androidx.privacysandbox.ui.client.view.SandboxedSdkViewEventListener? sandboxedSdkViewEventListener);
+ }
+
public final class SandboxedSdkView extends android.view.ViewGroup {
ctor public SandboxedSdkView(android.content.Context context);
ctor public SandboxedSdkView(android.content.Context context, optional android.util.AttributeSet? attrs);
diff --git a/privacysandbox/ui/ui-client/api/restricted_current.txt b/privacysandbox/ui/ui-client/api/restricted_current.txt
index a3d0e4d..94a15f0 100644
--- a/privacysandbox/ui/ui-client/api/restricted_current.txt
+++ b/privacysandbox/ui/ui-client/api/restricted_current.txt
@@ -10,6 +10,10 @@
package androidx.privacysandbox.ui.client.view {
+ public final class SandboxedSdkUiKt {
+ method @androidx.compose.runtime.Composable public static void SandboxedSdkUi(androidx.privacysandbox.ui.core.SandboxedUiAdapter sandboxedUiAdapter, optional androidx.compose.ui.Modifier modifier, optional boolean providerUiOnTop, optional androidx.privacysandbox.ui.client.view.SandboxedSdkViewEventListener? sandboxedSdkViewEventListener);
+ }
+
public final class SandboxedSdkView extends android.view.ViewGroup {
ctor public SandboxedSdkView(android.content.Context context);
ctor public SandboxedSdkView(android.content.Context context, optional android.util.AttributeSet? attrs);
diff --git a/privacysandbox/ui/ui-client/build.gradle b/privacysandbox/ui/ui-client/build.gradle
index bec5091..7546329 100644
--- a/privacysandbox/ui/ui-client/build.gradle
+++ b/privacysandbox/ui/ui-client/build.gradle
@@ -27,12 +27,14 @@
id("AndroidXPlugin")
id("com.android.library")
id("org.jetbrains.kotlin.android")
+ id("AndroidXComposePlugin")
}
dependencies {
api(libs.kotlinStdlib)
api("androidx.annotation:annotation:1.8.1")
api("androidx.core:core:1.12.0")
+ api("androidx.compose.ui:ui:1.7.6")
api("androidx.lifecycle:lifecycle-common:2.6.2")
api("androidx.customview:customview-poolingcontainer:1.0.0")
api(project(":privacysandbox:ui:ui-core"))
@@ -52,6 +54,9 @@
androidTestImplementation(project(":appcompat:appcompat"))
androidTestImplementation(project(":privacysandbox:ui:ui-provider"))
androidTestImplementation(project(":privacysandbox:ui:integration-tests:testingutils"))
+ androidTestImplementation("androidx.compose.ui:ui-test-junit4:1.7.6")
+ androidTestImplementation("androidx.compose.foundation:foundation-layout:1.7.6")
+ debugImplementation("androidx.compose.ui:ui-test-manifest:1.7.6")
}
android {
diff --git a/privacysandbox/ui/ui-client/src/androidTest/AndroidManifest.xml b/privacysandbox/ui/ui-client/src/androidTest/AndroidManifest.xml
index 1f5dbd3..c245d56 100644
--- a/privacysandbox/ui/ui-client/src/androidTest/AndroidManifest.xml
+++ b/privacysandbox/ui/ui-client/src/androidTest/AndroidManifest.xml
@@ -1,5 +1,4 @@
-<?xml version="1.0" encoding="utf-8"?>
-<!--
+<?xml version="1.0" encoding="utf-8"?><!--
Copyright 2022 The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
@@ -17,18 +16,24 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- This override is okay because the associated tests only run on T+ -->
- <uses-sdk tools:overrideLibrary="android_libs.ub_uiautomator, androidx.test.uiautomator" />
- <application android:supportsRtl="true"
+ <application
+ android:supportsRtl="true"
android:theme="@style/Theme.AppCompat">
- <activity
- android:name="androidx.privacysandbox.ui.client.test.UiLibActivity"
- android:configChanges="orientation|screenSize"
- android:exported="true"/>
- <activity android:name=".SecondActivity"
- android:exported="true"/>
+ <activity
+ android:name="androidx.privacysandbox.ui.client.test.UiLibActivity"
+ android:configChanges="orientation|screenSize"
+ android:exported="true" />
+ <activity
+ android:name="androidx.privacysandbox.ui.client.test.SecondActivity"
+ android:exported="true" />
+ <activity
+ android:name="androidx.privacysandbox.ui.client.test.UiLibComposeActivity"
+ android:configChanges="orientation|screenSize"
+ android:exported="true" />
</application>
-
<queries>
<package android:name="androidx.privacysandbox.ui.client.test" />
</queries>
+
+ <uses-sdk tools:overrideLibrary="android_libs.ub_uiautomator, androidx.test.uiautomator" />
</manifest>
\ No newline at end of file
diff --git a/privacysandbox/ui/ui-client/src/androidTest/java/androidx/privacysandbox/ui/client/test/FailingTestSandboxedUiAdapter.kt b/privacysandbox/ui/ui-client/src/androidTest/java/androidx/privacysandbox/ui/client/test/FailingTestSandboxedUiAdapter.kt
new file mode 100644
index 0000000..79dd0c8
--- /dev/null
+++ b/privacysandbox/ui/ui-client/src/androidTest/java/androidx/privacysandbox/ui/client/test/FailingTestSandboxedUiAdapter.kt
@@ -0,0 +1,37 @@
+/*
+ * 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.privacysandbox.ui.client.test
+
+import android.content.Context
+import android.os.IBinder
+import androidx.privacysandbox.ui.core.SandboxedUiAdapter
+import androidx.privacysandbox.ui.provider.AbstractSandboxedUiAdapter
+import java.util.concurrent.Executor
+
+class FailingTestSandboxedUiAdapter : AbstractSandboxedUiAdapter() {
+ override fun openSession(
+ context: Context,
+ windowInputToken: IBinder,
+ initialWidth: Int,
+ initialHeight: Int,
+ isZOrderOnTop: Boolean,
+ clientExecutor: Executor,
+ client: SandboxedUiAdapter.SessionClient
+ ) {
+ clientExecutor.execute { client.onSessionError(Exception("Error in openSession()")) }
+ }
+}
diff --git a/privacysandbox/ui/ui-client/src/androidTest/java/androidx/privacysandbox/ui/client/test/SandboxedSdkUiTest.kt b/privacysandbox/ui/ui-client/src/androidTest/java/androidx/privacysandbox/ui/client/test/SandboxedSdkUiTest.kt
new file mode 100644
index 0000000..e057517
--- /dev/null
+++ b/privacysandbox/ui/ui-client/src/androidTest/java/androidx/privacysandbox/ui/client/test/SandboxedSdkUiTest.kt
@@ -0,0 +1,558 @@
+/*
+ * 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.privacysandbox.ui.client.test
+
+import android.annotation.SuppressLint
+import android.content.Intent
+import android.content.pm.ActivityInfo
+import android.util.DisplayMetrics
+import android.util.TypedValue
+import android.view.ViewGroup
+import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+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.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.Lifecycle
+import androidx.privacysandbox.ui.client.test.SandboxedSdkViewTest.Companion.SHORTEST_TIME_BETWEEN_SIGNALS_MS
+import androidx.privacysandbox.ui.client.test.SandboxedSdkViewTest.Companion.TIMEOUT
+import androidx.privacysandbox.ui.client.test.SandboxedSdkViewTest.Companion.UI_INTENSIVE_TIMEOUT
+import androidx.privacysandbox.ui.client.view.SandboxedSdkUi
+import androidx.privacysandbox.ui.client.view.SandboxedSdkView
+import androidx.privacysandbox.ui.integration.testingutils.TestEventListener
+import androidx.test.espresso.Espresso
+import androidx.test.espresso.assertion.ViewAssertions.matches
+import androidx.test.espresso.matcher.ViewMatchers.hasChildCount
+import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.LargeTest
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.uiautomator.UiDevice
+import androidx.test.uiautomator.Until
+import androidx.testutils.withActivity
+import com.google.common.truth.Truth.assertThat
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+import kotlin.math.roundToInt
+import org.hamcrest.Matchers.instanceOf
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+// TODO(b/374919355): Create a common test framework for testing Compose and View UI lib constructs
+@RunWith(AndroidJUnit4::class)
+@LargeTest
+class SandboxedSdkUiTest {
+ @get:Rule val composeTestRule = createAndroidComposeRule<UiLibComposeActivity>()
+ private var testSandboxedUiAdapter by mutableStateOf(TestSandboxedUiAdapter())
+ private var eventListener by mutableStateOf(TestEventListener())
+ private var providerUiOnTop by mutableStateOf(true)
+ private var size by mutableStateOf(20.dp)
+ private lateinit var uiDevice: UiDevice
+
+ @Before
+ fun setup() {
+ testSandboxedUiAdapter = TestSandboxedUiAdapter()
+ eventListener = TestEventListener()
+ providerUiOnTop = true
+ uiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
+ }
+
+ @Test
+ fun eventListenerErrorTest() {
+ composeTestRule.setContent {
+ SandboxedSdkUi(
+ sandboxedUiAdapter = FailingTestSandboxedUiAdapter(),
+ modifier = Modifier,
+ providerUiOnTop = providerUiOnTop,
+ sandboxedSdkViewEventListener = eventListener
+ )
+ }
+ assertThat(eventListener.errorLatch.await(TIMEOUT, TimeUnit.MILLISECONDS)).isTrue()
+ assertThat(eventListener.error?.message).isEqualTo("Error in openSession()")
+ }
+
+ @Test
+ fun addEventListenerTest() {
+ // Initially no events are received when the session is not open.
+ assertThat(eventListener.uiDisplayedLatch.await(TIMEOUT, TimeUnit.MILLISECONDS)).isFalse()
+ // When session is open, the events are received
+ addNodeToLayout()
+ assertThat(eventListener.uiDisplayedLatch.await(TIMEOUT, TimeUnit.MILLISECONDS)).isTrue()
+ }
+
+ @Test
+ fun sessionNotOpenedWhenWindowIsNotVisibleTest() {
+ // the window is not visible when the activity is in the CREATED state.
+ composeTestRule.activityRule.scenario.moveToState(Lifecycle.State.CREATED)
+ addNodeToLayout()
+ testSandboxedUiAdapter.assertSessionNotOpened()
+ // the window becomes visible when the activity is in the STARTED state.
+ composeTestRule.activityRule.scenario.moveToState(Lifecycle.State.STARTED)
+ testSandboxedUiAdapter.assertSessionOpened()
+ }
+
+ @Test
+ fun onAttachedToWindowTest() {
+ addNodeToLayout()
+ testSandboxedUiAdapter.assertSessionOpened()
+ Espresso.onView(instanceOf(SandboxedSdkView::class.java))
+ .check(matches(isDisplayed()))
+ .check(matches(hasChildCount(1)))
+ .check { view, exception ->
+ if (
+ view.layoutParams.width != WRAP_CONTENT ||
+ view.layoutParams.height != WRAP_CONTENT
+ ) {
+ throw exception
+ }
+ }
+ }
+
+ @Test
+ fun childViewRemovedOnErrorTest() {
+ addNodeToLayout()
+ testSandboxedUiAdapter.assertSessionOpened()
+ Espresso.onView(instanceOf(SandboxedSdkView::class.java)).check(matches(hasChildCount(1)))
+ composeTestRule.activityRule.withActivity {
+ testSandboxedUiAdapter.internalClient!!.onSessionError(Exception())
+ }
+ Espresso.onView(instanceOf(SandboxedSdkView::class.java)).check(matches(hasChildCount(0)))
+ }
+
+ @Test
+ fun onZOrderChangedTest() {
+ addNodeToLayout()
+ // When session is opened, the provider should not receive a Z-order notification.
+ testSandboxedUiAdapter.assertSessionOpened()
+ val session = testSandboxedUiAdapter.testSession
+ assertThat(session?.zOrderChangedLatch?.await(TIMEOUT, TimeUnit.MILLISECONDS)).isFalse()
+ assertThat(testSandboxedUiAdapter.isZOrderOnTop).isTrue()
+ // When state changes to false, the provider should be notified.
+ providerUiOnTop = false
+ composeTestRule.waitForIdle()
+ assertThat(session?.zOrderChangedLatch?.await(TIMEOUT, TimeUnit.MILLISECONDS)).isTrue()
+ assertThat(testSandboxedUiAdapter.isZOrderOnTop).isFalse()
+ // When state changes back to true, the provider should be notified.
+ session?.zOrderChangedLatch = CountDownLatch(1)
+ providerUiOnTop = true
+ composeTestRule.waitForIdle()
+ assertThat(session?.zOrderChangedLatch?.await(TIMEOUT, TimeUnit.MILLISECONDS)).isTrue()
+ assertThat(testSandboxedUiAdapter.isZOrderOnTop).isTrue()
+ }
+
+ @Test
+ fun setZOrderNotOnTopBeforeOpeningSessionTest() {
+ providerUiOnTop = false
+ addNodeToLayout()
+ testSandboxedUiAdapter.assertSessionOpened()
+ val session = testSandboxedUiAdapter.testSession
+ // The initial Z-order state is passed to the session, but notifyZOrderChanged is not called
+ assertThat(session?.zOrderChangedLatch?.await(TIMEOUT, TimeUnit.MILLISECONDS)).isFalse()
+ assertThat(testSandboxedUiAdapter.isZOrderOnTop).isFalse()
+ }
+
+ @Test
+ fun setZOrderNotOnTopWhileSessionLoadingTest() {
+ testSandboxedUiAdapter.delayOpenSessionCallback = true
+ addNodeToLayout()
+ testSandboxedUiAdapter.assertSessionOpened()
+ providerUiOnTop = false
+ composeTestRule.waitForIdle()
+ val session = testSandboxedUiAdapter.testSession!!
+ assertThat(session.zOrderChangedLatch.await(TIMEOUT, TimeUnit.MILLISECONDS)).isFalse()
+ composeTestRule.activityRule.withActivity { testSandboxedUiAdapter.sendOnSessionOpened() }
+ // After session has opened, the pending Z order changed made while loading is notified
+ // to the session.
+ assertThat(session.zOrderChangedLatch.await(TIMEOUT, TimeUnit.MILLISECONDS)).isTrue()
+ assertThat(testSandboxedUiAdapter.isZOrderOnTop).isFalse()
+ }
+
+ @Test
+ fun onConfigurationChangedSessionRemainsOpened() {
+ addNodeToLayout()
+ testSandboxedUiAdapter.assertSessionOpened()
+ // newWindow() will be triggered by a window state change, even if the activity handles
+ // orientation changes without recreating the activity.
+ uiDevice.performActionAndWait(
+ { uiDevice.setOrientationLeft() },
+ Until.newWindow(),
+ UI_INTENSIVE_TIMEOUT
+ )
+ testSandboxedUiAdapter.assertSessionNotClosed()
+ uiDevice.performActionAndWait(
+ { uiDevice.setOrientationNatural() },
+ Until.newWindow(),
+ UI_INTENSIVE_TIMEOUT
+ )
+ testSandboxedUiAdapter.assertSessionNotClosed()
+ }
+
+ @Test
+ fun onConfigurationChangedTestSameConfigurationTest() {
+ addNodeToLayout()
+ testSandboxedUiAdapter.assertSessionOpened()
+ composeTestRule.activityRule.withActivity {
+ requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
+ }
+ assertThat(testSandboxedUiAdapter.wasOnConfigChangedCalled()).isFalse()
+ }
+
+ @Test
+ fun onPaddingSetTest() {
+ var padding by mutableStateOf(0.dp)
+ composeTestRule.setContent {
+ SandboxedSdkUi(
+ sandboxedUiAdapter = testSandboxedUiAdapter,
+ modifier = Modifier.padding(all = padding),
+ providerUiOnTop = providerUiOnTop,
+ sandboxedSdkViewEventListener = eventListener
+ )
+ }
+ testSandboxedUiAdapter.assertSessionOpened()
+ padding = 10.dp
+ composeTestRule.waitForIdle()
+ assertThat(testSandboxedUiAdapter.wasNotifyResizedCalled()).isTrue()
+ }
+
+ @Test
+ fun signalsSentWhenPaddingApplied() {
+ var padding by mutableStateOf(0.dp)
+ composeTestRule.setContent {
+ SandboxedSdkUi(
+ sandboxedUiAdapter = testSandboxedUiAdapter,
+ modifier = Modifier.padding(all = padding),
+ providerUiOnTop = providerUiOnTop,
+ sandboxedSdkViewEventListener = eventListener
+ )
+ }
+ testSandboxedUiAdapter.assertSessionOpened()
+ val session = testSandboxedUiAdapter.testSession
+ session?.runAndRetrieveNextUiChange {
+ padding = 10.dp
+ composeTestRule.waitForIdle()
+ }
+ assertThat(session?.shortestGapBetweenUiChangeEvents)
+ .isAtLeast(SHORTEST_TIME_BETWEEN_SIGNALS_MS)
+ }
+
+ @Test
+ fun onLayoutTestWithSizeChangeTest() {
+ addNodeToLayout()
+ testSandboxedUiAdapter.assertSessionOpened()
+ Espresso.onView(instanceOf(SandboxedSdkView::class.java))
+ .check(matches(isDisplayed()))
+ .check(matches(hasChildCount(1)))
+ .check { view, exception ->
+ val expectedSize = size.toPx(view.context.resources.displayMetrics)
+ if (view.width != expectedSize || view.height != expectedSize) {
+ throw exception
+ }
+ }
+ size = 30.dp
+ composeTestRule.waitForIdle()
+ Espresso.onView(instanceOf(SandboxedSdkView::class.java))
+ .check(matches(isDisplayed()))
+ .check(matches(hasChildCount(1)))
+ .check { view, exception ->
+ val expectedSize = size.toPx(view.context.resources.displayMetrics)
+ if (view.width != expectedSize || view.height != expectedSize) {
+ throw exception
+ }
+ }
+ assertThat(testSandboxedUiAdapter.wasNotifyResizedCalled()).isTrue()
+ }
+
+ @Test
+ fun onLayoutTestNoSizeChangeTest() {
+ size = 20.dp
+ addNodeToLayout()
+ testSandboxedUiAdapter.assertSessionOpened()
+ size = 20.dp
+ composeTestRule.waitForIdle()
+ assertThat(testSandboxedUiAdapter.wasNotifyResizedCalled()).isFalse()
+ }
+
+ @Test
+ fun onLayoutTestViewShiftWithoutSizeChangeTest() {
+ var offset by mutableStateOf(0.dp)
+ composeTestRule.setContent {
+ SandboxedSdkUi(
+ sandboxedUiAdapter = testSandboxedUiAdapter,
+ modifier = Modifier.offset(offset),
+ providerUiOnTop = providerUiOnTop,
+ sandboxedSdkViewEventListener = eventListener
+ )
+ }
+ testSandboxedUiAdapter.assertSessionOpened()
+ offset = 10.dp
+ composeTestRule.waitForIdle()
+ assertThat(testSandboxedUiAdapter.wasNotifyResizedCalled()).isFalse()
+ }
+
+ @Test
+ fun onSdkRequestsResizeTest() {
+ val boxSize = 300.dp
+ composeTestRule.setContent {
+ Box(modifier = Modifier.requiredSize(boxSize)) {
+ SandboxedSdkUi(
+ sandboxedUiAdapter = testSandboxedUiAdapter,
+ modifier = Modifier,
+ providerUiOnTop = providerUiOnTop,
+ sandboxedSdkViewEventListener = eventListener
+ )
+ }
+ }
+ testSandboxedUiAdapter.assertSessionOpened()
+ Espresso.onView(instanceOf(SandboxedSdkView::class.java)).check { view, _ ->
+ assertThat(view.width).isEqualTo(boxSize.toPx(view.context.resources.displayMetrics))
+ assertThat(view.height).isEqualTo(boxSize.toPx(view.context.resources.displayMetrics))
+ }
+ composeTestRule.activityRule.withActivity {
+ testSandboxedUiAdapter.testSession?.requestResize(200, 200) as Any
+ }
+ composeTestRule.waitForIdle()
+ Espresso.onView(instanceOf(SandboxedSdkView::class.java)).check { view, _ ->
+ assertThat(view.width).isEqualTo(200)
+ assertThat(view.height).isEqualTo(200)
+ }
+ }
+
+ @Test
+ fun requestResizeWithMeasureSpecAtMostExceedsParentBoundsTest() {
+ val boxSize = 300.dp
+ composeTestRule.setContent {
+ Box(modifier = Modifier.requiredSize(boxSize)) {
+ SandboxedSdkUi(
+ sandboxedUiAdapter = testSandboxedUiAdapter,
+ modifier = Modifier,
+ providerUiOnTop = providerUiOnTop,
+ sandboxedSdkViewEventListener = eventListener
+ )
+ }
+ }
+ testSandboxedUiAdapter.assertSessionOpened()
+ Espresso.onView(instanceOf(SandboxedSdkView::class.java)).check { view, _ ->
+ assertThat(view.width).isEqualTo(boxSize.toPx(view.context.resources.displayMetrics))
+ assertThat(view.height).isEqualTo(boxSize.toPx(view.context.resources.displayMetrics))
+ }
+ composeTestRule.activityRule.withActivity {
+ val newSize = (boxSize + 10.dp).toPx(resources.displayMetrics)
+ testSandboxedUiAdapter.testSession?.requestResize(newSize, newSize) as Any
+ }
+ composeTestRule.waitForIdle()
+ // the resize is constrained by the parent's size
+ Espresso.onView(instanceOf(SandboxedSdkView::class.java)).check { view, _ ->
+ assertThat(view.width).isEqualTo(boxSize.toPx(view.context.resources.displayMetrics))
+ assertThat(view.height).isEqualTo(boxSize.toPx(view.context.resources.displayMetrics))
+ }
+ }
+
+ @Test
+ fun requestResizeWithMeasureSpecExactlyTest() {
+ var boxWidth = 0.dp
+ var boxHeight = 0.dp
+ composeTestRule.setContent {
+ // Get local density from composable
+ val localDensity = LocalDensity.current
+ Box(
+ modifier =
+ Modifier.fillMaxSize().onGloballyPositioned { coordinates ->
+ boxWidth = with(localDensity) { coordinates.size.width.toDp() }
+ boxHeight = with(localDensity) { coordinates.size.height.toDp() }
+ }
+ ) {
+ SandboxedSdkUi(
+ sandboxedUiAdapter = testSandboxedUiAdapter,
+ modifier = Modifier.fillMaxSize(),
+ providerUiOnTop = providerUiOnTop,
+ sandboxedSdkViewEventListener = eventListener
+ )
+ }
+ }
+ testSandboxedUiAdapter.assertSessionOpened()
+ Espresso.onView(instanceOf(SandboxedSdkView::class.java)).check { view, _ ->
+ assertThat(view.width).isEqualTo(boxWidth.toPx(view.context.resources.displayMetrics))
+ assertThat(view.height).isEqualTo(boxHeight.toPx(view.context.resources.displayMetrics))
+ }
+ composeTestRule.activityRule.withActivity {
+ val newBoxWidth = (boxWidth - 10.dp).toPx(resources.displayMetrics)
+ val newBoxHeight = (boxHeight - 10.dp).toPx(resources.displayMetrics)
+ testSandboxedUiAdapter.testSession?.requestResize(newBoxWidth, newBoxHeight) as Any
+ }
+ composeTestRule.waitForIdle()
+ // the request is a no-op when the MeasureSpec is EXACTLY
+ Espresso.onView(instanceOf(SandboxedSdkView::class.java)).check { view, _ ->
+ assertThat(view.width).isEqualTo((boxWidth).toPx(view.context.resources.displayMetrics))
+ assertThat(view.height)
+ .isEqualTo((boxHeight).toPx(view.context.resources.displayMetrics))
+ }
+ }
+
+ @Test
+ fun sandboxedSdkViewIsTransitionGroupTest() {
+ addNodeToLayout()
+ Espresso.onView(instanceOf(SandboxedSdkView::class.java)).check { view, exception ->
+ if (!(view as ViewGroup).isTransitionGroup) {
+ throw exception
+ }
+ }
+ }
+
+ @Test
+ fun signalsNotSentWhenViewUnchangedTest() {
+ addNodeToLayout()
+ val session = testSandboxedUiAdapter.testSession
+ session?.runAndRetrieveNextUiChange {}
+ session?.assertNoSubsequentUiChanges()
+ }
+
+ /**
+ * Shifts the view partially off screen and verifies that the reported onScreenGeometry is
+ * cropped accordingly.
+ */
+ @Test
+ fun correctSignalsSentForOnScreenGeometryWhenViewOffScreenTest() {
+ var xOffset by mutableStateOf(0.dp)
+ var yOffset by mutableStateOf(0.dp)
+ val clippedWidth = 300.dp
+ val clippedHeight = 400.dp
+ composeTestRule.setContent {
+ Box(modifier = Modifier.size(width = clippedWidth, height = clippedHeight)) {
+ SandboxedSdkUi(
+ sandboxedUiAdapter = testSandboxedUiAdapter,
+ modifier = Modifier.offset(x = xOffset, y = yOffset),
+ providerUiOnTop = providerUiOnTop,
+ sandboxedSdkViewEventListener = eventListener
+ )
+ }
+ }
+ var initialHeight = 0
+ var initialWidth = 0
+ Espresso.onView(instanceOf(SandboxedSdkView::class.java)).check { view, _ ->
+ initialHeight = view.height
+ initialWidth = view.width
+ }
+ val session = testSandboxedUiAdapter.testSession
+ val sandboxedSdkViewUiInfo =
+ session?.runAndRetrieveNextUiChange {
+ xOffset = 100.dp
+ yOffset = 200.dp
+ composeTestRule.waitForIdle()
+ }
+ Espresso.onView(instanceOf(SandboxedSdkView::class.java)).check { view, _ ->
+ assertThat(sandboxedSdkViewUiInfo?.uiContainerWidth)
+ .isEqualTo(clippedWidth.toPx(view.context.resources.displayMetrics))
+ assertThat(sandboxedSdkViewUiInfo?.uiContainerHeight)
+ .isEqualTo(clippedHeight.toPx(view.context.resources.displayMetrics))
+ assertThat(sandboxedSdkViewUiInfo?.onScreenGeometry?.height()?.toFloat())
+ .isEqualTo(initialHeight - yOffset.toPx(view.context.resources.displayMetrics))
+ assertThat(sandboxedSdkViewUiInfo?.onScreenGeometry?.width()?.toFloat())
+ .isEqualTo(initialWidth - xOffset.toPx(view.context.resources.displayMetrics))
+ }
+ }
+
+ @Test
+ fun signalsSentWhenPositionChangesTest() {
+ var offset by mutableStateOf(0.dp)
+ composeTestRule.setContent {
+ SandboxedSdkUi(
+ sandboxedUiAdapter = testSandboxedUiAdapter,
+ modifier = Modifier.offset(x = offset),
+ providerUiOnTop = providerUiOnTop,
+ sandboxedSdkViewEventListener = eventListener
+ )
+ }
+ val session = testSandboxedUiAdapter.testSession
+ val sandboxedSdkViewUiInfo =
+ session?.runAndRetrieveNextUiChange {
+ offset = 10.dp
+ composeTestRule.waitForIdle()
+ }
+ val containerWidth = sandboxedSdkViewUiInfo?.uiContainerWidth ?: 0
+ val onScreenWidth = sandboxedSdkViewUiInfo?.onScreenGeometry?.width()?.toFloat()
+ Espresso.onView(instanceOf(SandboxedSdkView::class.java)).check { view, exception ->
+ val expectedWidth =
+ (containerWidth - offset.toPx(view.context.resources.displayMetrics)).toFloat()
+ if (expectedWidth != onScreenWidth) {
+ throw exception
+ }
+ }
+ }
+
+ /**
+ * Creates many UI changes and ensures that these changes are not sent more frequently than
+ * expected.
+ */
+ @Test
+ @SuppressLint("BanThreadSleep") // Deliberate delay for testing
+ fun signalsNotSentMoreFrequentlyThanLimitTest() {
+ addNodeToLayout()
+ val session = testSandboxedUiAdapter.testSession!!
+ for (i in 1..10) {
+ size += (i * 10).dp
+ composeTestRule.waitForIdle()
+ Thread.sleep(100)
+ }
+ assertThat(session.shortestGapBetweenUiChangeEvents)
+ .isAtLeast(SHORTEST_TIME_BETWEEN_SIGNALS_MS)
+ }
+
+ @Test
+ fun signalsSentWhenHostActivityStateChangesTest() {
+ addNodeToLayout()
+ val session = testSandboxedUiAdapter.testSession
+ session?.runAndRetrieveNextUiChange {}
+ // Replace the first activity with a new activity. The onScreenGeometry should now be empty.
+ var sandboxedSdkViewUiInfo =
+ session?.runAndRetrieveNextUiChange {
+ composeTestRule.activityRule.scenario.onActivity {
+ val intent = Intent(it, SecondActivity::class.java)
+ it.startActivity(intent)
+ }
+ }
+ assertThat(sandboxedSdkViewUiInfo?.onScreenGeometry?.isEmpty).isTrue()
+ // Return to the first activity. The onScreenGeometry should now be non-empty.
+ sandboxedSdkViewUiInfo = session?.runAndRetrieveNextUiChange { uiDevice.pressBack() }
+ assertThat(sandboxedSdkViewUiInfo?.onScreenGeometry?.isEmpty).isFalse()
+ }
+
+ private fun addNodeToLayout() {
+ composeTestRule.setContent {
+ SandboxedSdkUi(
+ sandboxedUiAdapter = testSandboxedUiAdapter,
+ modifier = Modifier.requiredSize(size),
+ providerUiOnTop = providerUiOnTop,
+ sandboxedSdkViewEventListener = eventListener
+ )
+ }
+ }
+
+ private fun Dp.toPx(displayMetrics: DisplayMetrics) =
+ TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, value, displayMetrics).roundToInt()
+}
diff --git a/privacysandbox/ui/ui-client/src/androidTest/java/androidx/privacysandbox/ui/client/test/SandboxedSdkViewTest.kt b/privacysandbox/ui/ui-client/src/androidTest/java/androidx/privacysandbox/ui/client/test/SandboxedSdkViewTest.kt
index 84015cb..9002a19 100644
--- a/privacysandbox/ui/ui-client/src/androidTest/java/androidx/privacysandbox/ui/client/test/SandboxedSdkViewTest.kt
+++ b/privacysandbox/ui/ui-client/src/androidTest/java/androidx/privacysandbox/ui/client/test/SandboxedSdkViewTest.kt
@@ -13,7 +13,6 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-
package androidx.privacysandbox.ui.client.test
import android.annotation.SuppressLint
@@ -55,19 +54,10 @@
import org.junit.Test
import org.junit.runner.RunWith
+// TODO(b/374919355): Create a common test for View and Compose
@RunWith(AndroidJUnit4::class)
@LargeTest
class SandboxedSdkViewTest {
-
- companion object {
- const val TIMEOUT = 1000.toLong()
-
- // Longer timeout used for expensive operations like device rotation.
- const val UI_INTENSIVE_TIMEOUT = 2000.toLong()
-
- const val SHORTEST_TIME_BETWEEN_SIGNALS_MS = 200
- }
-
private lateinit var uiDevice: UiDevice
private lateinit var context: Context
private lateinit var view: SandboxedSdkView
@@ -77,23 +67,8 @@
private lateinit var linearLayout: LinearLayout
private var mainLayoutWidth = -1
private var mainLayoutHeight = -1
-
@get:Rule var activityScenarioRule = ActivityScenarioRule(UiLibActivity::class.java)
- class FailingTestSandboxedUiAdapter : AbstractSandboxedUiAdapter() {
- override fun openSession(
- context: Context,
- windowInputToken: IBinder,
- initialWidth: Int,
- initialHeight: Int,
- isZOrderOnTop: Boolean,
- clientExecutor: Executor,
- client: SandboxedUiAdapter.SessionClient
- ) {
- clientExecutor.execute { client.onSessionError(Exception("Error in openSession()")) }
- }
- }
-
@Before
fun setup() {
context = InstrumentationRegistry.getInstrumentation().targetContext
@@ -125,20 +100,15 @@
fun addAndRemoveEventListenerTest() {
// Initially no events are received when the session is not open.
assertThat(eventListener.uiDisplayedLatch.await(TIMEOUT, TimeUnit.MILLISECONDS)).isFalse()
-
// When session is open, the events are received
addViewToLayout()
assertThat(eventListener.uiDisplayedLatch.await(TIMEOUT, TimeUnit.MILLISECONDS)).isTrue()
-
// Remove the view from layout to close the session.
removeAllViewsFromLayout()
assertThat(eventListener.sessionClosedLatch.await(TIMEOUT, TimeUnit.MILLISECONDS)).isTrue()
-
eventListener.uiDisplayedLatch = CountDownLatch(1)
-
// Remove the listener from the view.
view.setEventListener(null)
-
// Add view to layout again to start the session. The latches will not count down this time.
addViewToLayout()
assertThat(eventListener.uiDisplayedLatch.await(TIMEOUT, TimeUnit.MILLISECONDS)).isFalse()
@@ -150,16 +120,13 @@
val eventListener2 = TestEventListener()
view.setEventListener(eventListener1)
view.setEventListener(eventListener2)
-
activityScenarioRule.withActivity { view.setAdapter(FailingTestSandboxedUiAdapter()) }
addViewToLayout()
assertThat(eventListener1.errorLatch.await(TIMEOUT, TimeUnit.MILLISECONDS)).isFalse()
assertThat(eventListener2.errorLatch.await(TIMEOUT, TimeUnit.MILLISECONDS)).isTrue()
-
activityScenarioRule.withActivity { view.setAdapter(testSandboxedUiAdapter) }
assertThat(eventListener1.uiDisplayedLatch.await(TIMEOUT, TimeUnit.MILLISECONDS)).isFalse()
assertThat(eventListener2.uiDisplayedLatch.await(TIMEOUT, TimeUnit.MILLISECONDS)).isTrue()
-
removeAllViewsFromLayout()
assertThat(eventListener1.sessionClosedLatch.await(TIMEOUT, TimeUnit.MILLISECONDS))
.isFalse()
@@ -189,11 +156,9 @@
fun childViewRemovedOnErrorTest() {
assertTrue(view.childCount == 0)
addViewToLayout()
-
testSandboxedUiAdapter.assertSessionOpened()
assertTrue(view.childCount == 1)
assertTrue(view.layoutParams == layoutParams)
-
activityScenarioRule.withActivity {
testSandboxedUiAdapter.internalClient!!.onSessionError(Exception())
assertTrue(view.childCount == 0)
@@ -203,19 +168,16 @@
@Test
fun onZOrderChangedTest() {
addViewToLayout()
-
// When session is opened, the provider should not receive a Z-order notification.
testSandboxedUiAdapter.assertSessionOpened()
val session = testSandboxedUiAdapter.testSession!!
val adapter = testSandboxedUiAdapter
assertThat(session.zOrderChangedLatch.await(TIMEOUT, TimeUnit.MILLISECONDS)).isFalse()
assertThat(adapter.isZOrderOnTop).isTrue()
-
// When state changes to false, the provider should be notified.
view.orderProviderUiAboveClientUi(false)
assertThat(session.zOrderChangedLatch.await(TIMEOUT, TimeUnit.MILLISECONDS)).isTrue()
assertThat(adapter.isZOrderOnTop).isFalse()
-
// When state changes back to true, the provider should be notified.
session.zOrderChangedLatch = CountDownLatch(1)
view.orderProviderUiAboveClientUi(true)
@@ -226,13 +188,11 @@
@Test
fun onZOrderUnchangedTest() {
addViewToLayout()
-
// When session is opened, the provider should not receive a Z-order notification.
testSandboxedUiAdapter.assertSessionOpened()
val session = testSandboxedUiAdapter.testSession!!
val adapter = testSandboxedUiAdapter
assertThat(adapter.isZOrderOnTop).isTrue()
-
// When Z-order state is unchanged, the provider should not be notified.
session.zOrderChangedLatch = CountDownLatch(1)
view.orderProviderUiAboveClientUi(true)
@@ -246,7 +206,6 @@
addViewToLayout()
testSandboxedUiAdapter.assertSessionOpened()
val session = testSandboxedUiAdapter.testSession!!
-
// The initial Z-order state is passed to the session, but notifyZOrderChanged is not called
assertThat(session.zOrderChangedLatch.await(TIMEOUT, TimeUnit.MILLISECONDS)).isFalse()
assertThat(testSandboxedUiAdapter.isZOrderOnTop).isFalse()
@@ -261,7 +220,6 @@
val session = testSandboxedUiAdapter.testSession!!
assertThat(session.zOrderChangedLatch.await(TIMEOUT, TimeUnit.MILLISECONDS)).isFalse()
activityScenarioRule.withActivity { testSandboxedUiAdapter.sendOnSessionOpened() }
-
// After session has opened, the pending Z order changed made while loading is notified
// th the session.
assertThat(session.zOrderChangedLatch.await(TIMEOUT, TimeUnit.MILLISECONDS)).isTrue()
@@ -279,12 +237,13 @@
Until.newWindow(),
UI_INTENSIVE_TIMEOUT
)
- testSandboxedUiAdapter.assertSessionOpened()
+ testSandboxedUiAdapter.assertSessionNotClosed()
uiDevice.performActionAndWait(
{ uiDevice.setOrientationNatural() },
Until.newWindow(),
UI_INTENSIVE_TIMEOUT
)
+ testSandboxedUiAdapter.assertSessionNotClosed()
}
@Test
@@ -301,7 +260,6 @@
fun overrideProviderViewLayoutParams() {
val providerViewWidth = (0..1000).random()
val providerViewHeight = (0..1000).random()
-
class CustomSession : AbstractSandboxedUiAdapter.AbstractSession() {
override val view = View(context)
@@ -309,7 +267,6 @@
view.layoutParams = LinearLayout.LayoutParams(providerViewWidth, providerViewHeight)
}
}
-
class CustomUiAdapter : AbstractSandboxedUiAdapter() {
override fun openSession(
context: Context,
@@ -323,11 +280,9 @@
clientExecutor.execute { client.onSessionOpened(CustomSession()) }
}
}
-
view.setAdapter(CustomUiAdapter())
addViewToLayout(waitToBeActive = true)
val contentView = view.getChildAt(0)
-
assertThat(contentView.layoutParams.width).isNotEqualTo(providerViewWidth)
assertThat(contentView.layoutParams.height).isNotEqualTo(providerViewHeight)
assertThat(contentView.layoutParams.width).isEqualTo(LinearLayout.LayoutParams.WRAP_CONTENT)
@@ -341,7 +296,6 @@
val initialWidth = 100
val initialHeight = 100
view.layoutParams = LinearLayout.LayoutParams(initialWidth, initialHeight)
-
class CustomSession : AbstractSandboxedUiAdapter.AbstractSession() {
override val view = TextView(context)
@@ -349,9 +303,7 @@
view.text = "Test View"
}
}
-
val customSession = CustomSession()
-
class CustomUiAdapter : AbstractSandboxedUiAdapter() {
override fun openSession(
context: Context,
@@ -365,12 +317,9 @@
clientExecutor.execute { client.onSessionOpened(customSession) }
}
}
-
view.setAdapter(CustomUiAdapter())
addViewToLayout(waitToBeActive = true)
-
customSession.view.layout(0, 0, initialWidth * 2, initialHeight * 2)
-
assertThat(customSession.view.width).isEqualTo(initialWidth * 2)
assertThat(customSession.view.height).isEqualTo(initialHeight * 2)
assertThat(view.width).isEqualTo(initialWidth)
@@ -495,11 +444,9 @@
fun inputTokenIsCorrect() {
// Input token is only needed when provider can be located on a separate process.
assumeTrue(BackwardCompatUtil.canProviderBeRemote())
-
lateinit var layout: LinearLayout
val surfaceView = SurfaceView(context)
val surfaceViewLatch = CountDownLatch(1)
-
var token: IBinder? = null
surfaceView.addOnAttachStateChangeListener(
object : View.OnAttachStateChangeListener {
@@ -512,18 +459,15 @@
override fun onViewDetachedFromWindow(p0: View) {}
}
)
-
// Attach SurfaceView
activityScenarioRule.withActivity {
layout = findViewById(R.id.mainlayout)
layout.addView(surfaceView)
layout.removeView(surfaceView)
}
-
// Verify SurfaceView has a non-null token when attached.
assertThat(surfaceViewLatch.await(TIMEOUT, TimeUnit.MILLISECONDS)).isTrue()
assertThat(token).isNotNull()
-
// Verify that the UI adapter receives the same host token object when opening a session.
addViewToLayout()
testSandboxedUiAdapter.assertSessionOpened()
@@ -752,7 +696,6 @@
Runnable { view.removeAllViewsInLayout() },
Runnable { view.removeViewsInLayout(0, 0) }
)
-
removeChildRunnableArray.forEach { removeChildRunnable ->
val exception =
assertThrows(UnsupportedOperationException::class.java) {
@@ -807,4 +750,11 @@
assertThat(width).isEqualTo(expectedWidth)
assertThat(height).isEqualTo(expectedHeight)
}
+
+ companion object {
+ const val TIMEOUT = 1000.toLong()
+ // Longer timeout used for expensive operations like device rotation.
+ const val UI_INTENSIVE_TIMEOUT = 2000.toLong()
+ const val SHORTEST_TIME_BETWEEN_SIGNALS_MS = 200
+ }
}
diff --git a/privacysandbox/ui/ui-client/src/androidTest/java/androidx/privacysandbox/ui/client/test/TestSandboxedUiAdapter.kt b/privacysandbox/ui/ui-client/src/androidTest/java/androidx/privacysandbox/ui/client/test/TestSandboxedUiAdapter.kt
index 5bfe8df..f9ebe61 100644
--- a/privacysandbox/ui/ui-client/src/androidTest/java/androidx/privacysandbox/ui/client/test/TestSandboxedUiAdapter.kt
+++ b/privacysandbox/ui/ui-client/src/androidTest/java/androidx/privacysandbox/ui/client/test/TestSandboxedUiAdapter.kt
@@ -13,7 +13,6 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-
package androidx.privacysandbox.ui.client.test
import android.content.Context
@@ -32,20 +31,18 @@
class TestSandboxedUiAdapter(private val signalOptions: Set<String> = setOf("option")) :
AbstractSandboxedUiAdapter() {
-
var isSessionOpened = false
var internalClient: SandboxedUiAdapter.SessionClient? = null
var testSession: TestSession? = null
var isZOrderOnTop = true
var inputToken: IBinder? = null
-
// When set to true, the onSessionOpened callback will only be invoked when specified
// by the test. This is to test race conditions when the session is being loaded.
var delayOpenSessionCallback = false
-
private val openSessionLatch = CountDownLatch(1)
private val resizeLatch = CountDownLatch(1)
private val configChangedLatch = CountDownLatch(1)
+ private val sessionClosedLatch = CountDownLatch(1)
override fun openSession(
context: Context,
@@ -98,16 +95,21 @@
)
}
+ internal fun assertSessionNotClosed() {
+ Truth.assertThat(
+ sessionClosedLatch.await(SandboxedSdkViewTest.TIMEOUT, TimeUnit.MILLISECONDS)
+ )
+ .isFalse()
+ }
+
inner class TestSession(context: Context, override val signalOptions: Set<String>) :
SandboxedUiAdapter.Session {
-
var zOrderChangedLatch: CountDownLatch = CountDownLatch(1)
var shortestGapBetweenUiChangeEvents = Long.MAX_VALUE
private var notifyUiChangedLatch: CountDownLatch = CountDownLatch(1)
private var latestUiChange: Bundle = Bundle()
private var hasReceivedFirstUiChange = false
private var timeReceivedLastUiChange = SystemClock.elapsedRealtime()
-
override val view: View = View(context)
fun requestResize(width: Int, height: Int) {
@@ -127,7 +129,9 @@
configChangedLatch.countDown()
}
- override fun close() {}
+ override fun close() {
+ sessionClosedLatch.countDown()
+ }
override fun notifyUiChanged(uiContainerInfo: Bundle) {
if (hasReceivedFirstUiChange) {
diff --git a/compose/runtime/runtime/src/nonAndroidMain/kotlin/androidx/compose/runtime/collection/ArrayUtils.nonAndroid.kt b/privacysandbox/ui/ui-client/src/androidTest/java/androidx/privacysandbox/ui/client/test/UiLibComposeActivity.kt
similarity index 63%
rename from compose/runtime/runtime/src/nonAndroidMain/kotlin/androidx/compose/runtime/collection/ArrayUtils.nonAndroid.kt
rename to privacysandbox/ui/ui-client/src/androidTest/java/androidx/privacysandbox/ui/client/test/UiLibComposeActivity.kt
index 2e2c834..27b1299 100644
--- a/compose/runtime/runtime/src/nonAndroidMain/kotlin/androidx/compose/runtime/collection/ArrayUtils.nonAndroid.kt
+++ b/privacysandbox/ui/ui-client/src/androidTest/java/androidx/privacysandbox/ui/client/test/UiLibComposeActivity.kt
@@ -13,14 +13,8 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
+package androidx.privacysandbox.ui.client.test
-@file:Suppress("NOTHING_TO_INLINE", "KotlinRedundantDiagnosticSuppress")
+import androidx.activity.ComponentActivity
-package androidx.compose.runtime.collection
-
-internal actual inline fun <T> Array<out T>.fastCopyInto(
- destination: Array<T>,
- destinationOffset: Int,
- startIndex: Int,
- endIndex: Int
-): Array<T> = this.copyInto(destination, destinationOffset, startIndex, endIndex)
+class UiLibComposeActivity : ComponentActivity() {}
diff --git a/privacysandbox/ui/ui-client/src/main/java/androidx/privacysandbox/ui/client/ClientDelegatingAdapter.kt b/privacysandbox/ui/ui-client/src/main/java/androidx/privacysandbox/ui/client/ClientDelegatingAdapter.kt
index 2901fe9..404139d 100644
--- a/privacysandbox/ui/ui-client/src/main/java/androidx/privacysandbox/ui/client/ClientDelegatingAdapter.kt
+++ b/privacysandbox/ui/ui-client/src/main/java/androidx/privacysandbox/ui/client/ClientDelegatingAdapter.kt
@@ -21,13 +21,13 @@
import android.os.IBinder
import androidx.annotation.GuardedBy
import androidx.core.util.Consumer
-import androidx.privacysandbox.ui.client.RemoteCallManager.tryToCallRemoteObject
import androidx.privacysandbox.ui.client.SandboxedUiAdapterFactory.createFromCoreLibInfo
import androidx.privacysandbox.ui.client.view.RefreshableSessionClient
import androidx.privacysandbox.ui.core.IDelegateChangeListener
import androidx.privacysandbox.ui.core.IDelegatingSandboxedUiAdapter
import androidx.privacysandbox.ui.core.IDelegatorCallback
import androidx.privacysandbox.ui.core.ISessionRefreshCallback
+import androidx.privacysandbox.ui.core.RemoteCallManager.tryToCallRemoteObject
import androidx.privacysandbox.ui.core.SandboxedUiAdapter
import java.util.concurrent.Executor
import kotlin.coroutines.resume
diff --git a/privacysandbox/ui/ui-client/src/main/java/androidx/privacysandbox/ui/client/SandboxedUiAdapterFactory.kt b/privacysandbox/ui/ui-client/src/main/java/androidx/privacysandbox/ui/client/SandboxedUiAdapterFactory.kt
index e4a4f5f..e80268a 100644
--- a/privacysandbox/ui/ui-client/src/main/java/androidx/privacysandbox/ui/client/SandboxedUiAdapterFactory.kt
+++ b/privacysandbox/ui/ui-client/src/main/java/androidx/privacysandbox/ui/client/SandboxedUiAdapterFactory.kt
@@ -30,14 +30,14 @@
import android.view.View
import android.window.SurfaceSyncGroup
import androidx.annotation.RequiresApi
-import androidx.privacysandbox.ui.client.RemoteCallManager.addBinderDeathListener
-import androidx.privacysandbox.ui.client.RemoteCallManager.closeRemoteSession
-import androidx.privacysandbox.ui.client.RemoteCallManager.tryToCallRemoteObject
import androidx.privacysandbox.ui.core.IDelegatingSandboxedUiAdapter
import androidx.privacysandbox.ui.core.IRemoteSessionClient
import androidx.privacysandbox.ui.core.IRemoteSessionController
import androidx.privacysandbox.ui.core.ISandboxedUiAdapter
import androidx.privacysandbox.ui.core.ProtocolConstants
+import androidx.privacysandbox.ui.core.RemoteCallManager.addBinderDeathListener
+import androidx.privacysandbox.ui.core.RemoteCallManager.closeRemoteSession
+import androidx.privacysandbox.ui.core.RemoteCallManager.tryToCallRemoteObject
import androidx.privacysandbox.ui.core.SandboxedUiAdapter
import java.lang.reflect.InvocationHandler
import java.lang.reflect.Method
diff --git a/privacysandbox/ui/ui-client/src/main/java/androidx/privacysandbox/ui/client/view/SandboxedSdkUi.kt b/privacysandbox/ui/ui-client/src/main/java/androidx/privacysandbox/ui/client/view/SandboxedSdkUi.kt
new file mode 100644
index 0000000..29f873a
--- /dev/null
+++ b/privacysandbox/ui/ui-client/src/main/java/androidx/privacysandbox/ui/client/view/SandboxedSdkUi.kt
@@ -0,0 +1,73 @@
+/*
+ * 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.privacysandbox.ui.client.view
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.privacysandbox.ui.core.SandboxedUiAdapter
+
+/**
+ * Composable that can be used to remotely render UI from a SandboxedSdk to host app window.
+ *
+ * @param sandboxedUiAdapter an adapter that provides content from a SandboxedSdk to be displayed as
+ * part of a host app's window.
+ * @param modifier the [Modifier] to be applied to this SandboxedSdkUi.
+ * @param providerUiOnTop sets the Z-ordering of the SandboxedSdkUi surface, relative to its window.
+ * @param sandboxedSdkViewEventListener an event listener to the UI presentation.
+ */
+@Composable
+@Suppress("MissingJvmstatic")
+fun SandboxedSdkUi(
+ sandboxedUiAdapter: SandboxedUiAdapter,
+ modifier: Modifier = Modifier,
+ providerUiOnTop: Boolean = true,
+ sandboxedSdkViewEventListener: SandboxedSdkViewEventListener? = null
+) {
+ val delegatedListener =
+ remember {
+ object : SandboxedSdkViewEventListener {
+ var delegate by mutableStateOf(sandboxedSdkViewEventListener)
+
+ override fun onUiDisplayed() {
+ delegate?.onUiDisplayed()
+ }
+
+ override fun onUiError(error: Throwable) {
+ delegate?.onUiError(error)
+ }
+
+ override fun onUiClosed() {
+ delegate?.onUiClosed()
+ }
+ }
+ }
+ .apply { delegate = sandboxedSdkViewEventListener }
+ AndroidView(
+ modifier = modifier,
+ factory = { context ->
+ SandboxedSdkView(context).apply { setEventListener(delegatedListener) }
+ },
+ update = { view ->
+ view.setAdapter(sandboxedUiAdapter)
+ view.orderProviderUiAboveClientUi(providerUiOnTop)
+ }
+ )
+}
diff --git a/privacysandbox/ui/ui-client/src/main/java/androidx/privacysandbox/ui/client/RemoteCallManager.kt b/privacysandbox/ui/ui-core/src/main/java/androidx/privacysandbox/ui/core/RemoteCallManager.kt
similarity index 93%
rename from privacysandbox/ui/ui-client/src/main/java/androidx/privacysandbox/ui/client/RemoteCallManager.kt
rename to privacysandbox/ui/ui-core/src/main/java/androidx/privacysandbox/ui/core/RemoteCallManager.kt
index 6570440..3d75a93 100644
--- a/privacysandbox/ui/ui-client/src/main/java/androidx/privacysandbox/ui/client/RemoteCallManager.kt
+++ b/privacysandbox/ui/ui-core/src/main/java/androidx/privacysandbox/ui/core/RemoteCallManager.kt
@@ -14,13 +14,12 @@
* limitations under the License.
*/
-package androidx.privacysandbox.ui.client
+package androidx.privacysandbox.ui.core
import android.os.IBinder
import android.os.RemoteException
import android.util.Log
import androidx.annotation.RestrictTo
-import androidx.privacysandbox.ui.core.IRemoteSessionController
/** Utility class for remote objects called by the UI library adapter factories. */
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
diff --git a/privacysandbox/ui/ui-provider/src/main/java/androidx/privacysandbox/ui/provider/BinderAdapterDelegate.kt b/privacysandbox/ui/ui-provider/src/main/java/androidx/privacysandbox/ui/provider/BinderAdapterDelegate.kt
index 3dd9727..697e3c8 100644
--- a/privacysandbox/ui/ui-provider/src/main/java/androidx/privacysandbox/ui/provider/BinderAdapterDelegate.kt
+++ b/privacysandbox/ui/ui-provider/src/main/java/androidx/privacysandbox/ui/provider/BinderAdapterDelegate.kt
@@ -40,6 +40,7 @@
import androidx.privacysandbox.ui.core.IRemoteSessionController
import androidx.privacysandbox.ui.core.ISandboxedUiAdapter
import androidx.privacysandbox.ui.core.ProtocolConstants
+import androidx.privacysandbox.ui.core.RemoteCallManager.tryToCallRemoteObject
import androidx.privacysandbox.ui.core.SandboxedUiAdapter
import androidx.privacysandbox.ui.core.SessionObserver
import androidx.privacysandbox.ui.core.SessionObserverContext
@@ -78,20 +79,22 @@
override suspend fun onDelegateChanged(delegate: Bundle) {
suspendCancellableCoroutine { continuation ->
- binder.onDelegateChanged(
- delegate,
- object : IDelegatorCallback.Stub() {
- override fun onDelegateChangeResult(success: Boolean) {
- if (success) {
- continuation.resume(Unit)
- } else {
- continuation.resumeWithException(
- IllegalStateException("Client failed to switch")
- )
+ tryToCallRemoteObject(binder) {
+ onDelegateChanged(
+ delegate,
+ object : IDelegatorCallback.Stub() {
+ override fun onDelegateChangeResult(success: Boolean) {
+ if (success) {
+ continuation.resume(Unit)
+ } else {
+ continuation.resumeWithException(
+ IllegalStateException("Client failed to switch")
+ )
+ }
}
}
- }
- )
+ )
+ }
}
}
}
@@ -154,7 +157,9 @@
remoteSessionClient: IRemoteSessionClient
) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
- remoteSessionClient.onRemoteSessionError("openRemoteSession() requires API34+")
+ tryToCallRemoteObject(remoteSessionClient) {
+ onRemoteSessionError("openRemoteSession() requires API34+")
+ }
return
}
@@ -177,7 +182,11 @@
)
},
clientInit = { it.initialize(initialWidth, initialHeight) },
- errorHandler = { remoteSessionClient.onRemoteSessionError(it.message) }
+ errorHandler = {
+ tryToCallRemoteObject(remoteSessionClient) {
+ onRemoteSessionError(it.message)
+ }
+ }
)
openSessionInternal(
@@ -192,7 +201,9 @@
deferredClient.preloadClient()
} catch (exception: Throwable) {
- remoteSessionClient.onRemoteSessionError(exception.message)
+ tryToCallRemoteObject(remoteSessionClient) {
+ onRemoteSessionError(exception.message)
+ }
}
}
}
@@ -280,27 +291,31 @@
}
override fun onSessionError(throwable: Throwable) {
- remoteSessionClient.onRemoteSessionError(throwable.message)
+ tryToCallRemoteObject(remoteSessionClient) { onRemoteSessionError(throwable.message) }
}
override fun onResizeRequested(width: Int, height: Int) {
- remoteSessionClient.onResizeRequested(width, height)
+ tryToCallRemoteObject(remoteSessionClient) { onResizeRequested(width, height) }
}
private fun sendRemoteSessionOpened(session: SandboxedUiAdapter.Session) {
val surfacePackage = surfaceControlViewHost.surfacePackage
val remoteSessionController = RemoteSessionController(surfaceControlViewHost, session)
- remoteSessionClient.onRemoteSessionOpened(
- surfacePackage,
- remoteSessionController,
- isZOrderOnTop,
- session.signalOptions.isNotEmpty()
- )
+ tryToCallRemoteObject(remoteSessionClient) {
+ onRemoteSessionOpened(
+ surfacePackage,
+ remoteSessionController,
+ isZOrderOnTop,
+ session.signalOptions.isNotEmpty()
+ )
+ }
}
private fun sendSurfacePackage() {
if (surfaceControlViewHost.surfacePackage != null) {
- remoteSessionClient.onSessionUiFetched(surfaceControlViewHost.surfacePackage)
+ tryToCallRemoteObject(remoteSessionClient) {
+ onSessionUiFetched(surfaceControlViewHost.surfacePackage)
+ }
}
}
diff --git a/room/room-paging/build.gradle b/room/room-paging/build.gradle
index e0ab2c1..3805343 100644
--- a/room/room-paging/build.gradle
+++ b/room/room-paging/build.gradle
@@ -41,7 +41,6 @@
api(libs.kotlinStdlib)
api("androidx.paging:paging-common:3.3.2")
api(project(":room:room-runtime"))
- implementation(libs.atomicFu)
}
}
@@ -66,6 +65,9 @@
nativeMain {
dependsOn(jvmNativeMain)
+ dependencies {
+ implementation(libs.atomicFu)
+ }
}
androidInstrumentedTest {
diff --git a/room/room-paging/src/androidMain/kotlin/androidx/room/paging/LimitOffsetPagingSource.android.kt b/room/room-paging/src/androidMain/kotlin/androidx/room/paging/LimitOffsetPagingSource.android.kt
index 4128526..66cbfa3 100644
--- a/room/room-paging/src/androidMain/kotlin/androidx/room/paging/LimitOffsetPagingSource.android.kt
+++ b/room/room-paging/src/androidMain/kotlin/androidx/room/paging/LimitOffsetPagingSource.android.kt
@@ -61,7 +61,7 @@
private val implementation = CommonLimitOffsetImpl(tables, this, ::convertRows)
public actual val itemCount: Int
- get() = implementation.itemCount.value
+ get() = implementation.itemCount.get()
override val jumpingSupported: Boolean
get() = true
diff --git a/room/room-paging/src/commonMain/kotlin/androidx/room/paging/LimitOffsetPagingSource.kt b/room/room-paging/src/commonMain/kotlin/androidx/room/paging/LimitOffsetPagingSource.kt
index edd5950..9c5783d4 100644
--- a/room/room-paging/src/commonMain/kotlin/androidx/room/paging/LimitOffsetPagingSource.kt
+++ b/room/room-paging/src/commonMain/kotlin/androidx/room/paging/LimitOffsetPagingSource.kt
@@ -24,11 +24,12 @@
import androidx.room.RoomDatabase
import androidx.room.RoomRawQuery
import androidx.room.Transactor.SQLiteTransactionType
+import androidx.room.concurrent.AtomicBoolean
+import androidx.room.concurrent.AtomicInt
import androidx.room.paging.util.INITIAL_ITEM_COUNT
import androidx.room.paging.util.queryDatabase
import androidx.room.paging.util.queryItemCount
import androidx.room.useReaderConnection
-import kotlinx.atomicfu.atomic
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
@@ -70,9 +71,9 @@
private val db = pagingSource.db
private val sourceQuery = pagingSource.sourceQuery
- internal val itemCount = atomic(INITIAL_ITEM_COUNT)
+ internal val itemCount = AtomicInt(INITIAL_ITEM_COUNT)
- private val invalidationFlowStarted = atomic(false)
+ private val invalidationFlowStarted = AtomicBoolean(false)
private var invalidationFlowJob: Job? = null
init {
@@ -80,7 +81,7 @@
}
suspend fun load(params: LoadParams<Int>): LoadResult<Int, Value> {
- if (invalidationFlowStarted.compareAndSet(expect = false, update = true)) {
+ if (invalidationFlowStarted.compareAndSet(false, true)) {
invalidationFlowJob =
db.getCoroutineScope().launch {
db.invalidationTracker.createFlow(*tables, emitInitialState = false).collect {
@@ -92,7 +93,7 @@
}
}
- val tempCount = itemCount.value
+ val tempCount = itemCount.get()
// if itemCount is < 0, then it is initial load
return try {
if (tempCount == INITIAL_ITEM_COUNT) {
@@ -119,7 +120,7 @@
return db.useReaderConnection { connection ->
connection.withTransaction(SQLiteTransactionType.DEFERRED) {
val tempCount = queryItemCount(sourceQuery, db)
- itemCount.value = tempCount
+ itemCount.set(tempCount)
queryDatabase(
params = params,
sourceQuery = sourceQuery,
diff --git a/room/room-paging/src/jvmNativeMain/kotlin/androidx/room/paging/LimitOffsetPagingSource.jvmNative.kt b/room/room-paging/src/jvmNativeMain/kotlin/androidx/room/paging/LimitOffsetPagingSource.jvmNative.kt
index 1f4235e..77f4bd2 100644
--- a/room/room-paging/src/jvmNativeMain/kotlin/androidx/room/paging/LimitOffsetPagingSource.jvmNative.kt
+++ b/room/room-paging/src/jvmNativeMain/kotlin/androidx/room/paging/LimitOffsetPagingSource.jvmNative.kt
@@ -41,7 +41,7 @@
private val implementation = CommonLimitOffsetImpl(tables, this, ::convertRows)
public actual val itemCount: Int
- get() = implementation.itemCount.value
+ get() = implementation.itemCount.get()
override val jumpingSupported: Boolean
get() = true
diff --git a/room/room-runtime/bcv/native/current.txt b/room/room-runtime/bcv/native/current.txt
index 984bd87..89d7a96 100644
--- a/room/room-runtime/bcv/native/current.txt
+++ b/room/room-runtime/bcv/native/current.txt
@@ -222,6 +222,24 @@
final object Companion // androidx.room/EntityUpsertAdapter.Companion|null[0]
}
+final class androidx.room.concurrent/AtomicBoolean { // androidx.room.concurrent/AtomicBoolean|null[0]
+ constructor <init>(kotlin/Boolean) // androidx.room.concurrent/AtomicBoolean.<init>|<init>(kotlin.Boolean){}[0]
+
+ final fun compareAndSet(kotlin/Boolean, kotlin/Boolean): kotlin/Boolean // androidx.room.concurrent/AtomicBoolean.compareAndSet|compareAndSet(kotlin.Boolean;kotlin.Boolean){}[0]
+ final fun get(): kotlin/Boolean // androidx.room.concurrent/AtomicBoolean.get|get(){}[0]
+}
+
+final class androidx.room.concurrent/AtomicInt { // androidx.room.concurrent/AtomicInt|null[0]
+ constructor <init>(kotlin/Int) // androidx.room.concurrent/AtomicInt.<init>|<init>(kotlin.Int){}[0]
+
+ final fun compareAndSet(kotlin/Int, kotlin/Int): kotlin/Boolean // androidx.room.concurrent/AtomicInt.compareAndSet|compareAndSet(kotlin.Int;kotlin.Int){}[0]
+ final fun decrementAndGet(): kotlin/Int // androidx.room.concurrent/AtomicInt.decrementAndGet|decrementAndGet(){}[0]
+ final fun get(): kotlin/Int // androidx.room.concurrent/AtomicInt.get|get(){}[0]
+ final fun getAndIncrement(): kotlin/Int // androidx.room.concurrent/AtomicInt.getAndIncrement|getAndIncrement(){}[0]
+ final fun incrementAndGet(): kotlin/Int // androidx.room.concurrent/AtomicInt.incrementAndGet|incrementAndGet(){}[0]
+ final fun set(kotlin/Int) // androidx.room.concurrent/AtomicInt.set|set(kotlin.Int){}[0]
+}
+
final class androidx.room.util/ByteArrayWrapper { // androidx.room.util/ByteArrayWrapper|null[0]
constructor <init>(kotlin/ByteArray) // androidx.room.util/ByteArrayWrapper.<init>|<init>(kotlin.ByteArray){}[0]
diff --git a/room/room-runtime/build.gradle b/room/room-runtime/build.gradle
index dc736fd..4fd3768 100644
--- a/room/room-runtime/build.gradle
+++ b/room/room-runtime/build.gradle
@@ -110,7 +110,6 @@
api("androidx.collection:collection:1.4.2")
api("androidx.annotation:annotation:1.8.1")
api(libs.kotlinCoroutinesCore)
- implementation(libs.atomicFu)
}
}
commonTest {
@@ -180,6 +179,7 @@
dependsOn(jvmNativeMain)
dependencies {
api(project(":sqlite:sqlite-framework"))
+ implementation(libs.atomicFu)
}
}
nativeTest {
diff --git a/room/room-runtime/src/androidInstrumentedTest/kotlin/androidx/room/coroutines/BundledSQLiteConnectionPoolTest.kt b/room/room-runtime/src/androidInstrumentedTest/kotlin/androidx/room/coroutines/BundledSQLiteConnectionPoolTest.kt
index ab9a93b..f876d37 100644
--- a/room/room-runtime/src/androidInstrumentedTest/kotlin/androidx/room/coroutines/BundledSQLiteConnectionPoolTest.kt
+++ b/room/room-runtime/src/androidInstrumentedTest/kotlin/androidx/room/coroutines/BundledSQLiteConnectionPoolTest.kt
@@ -18,6 +18,7 @@
import androidx.kruth.assertThat
import androidx.room.Transactor
+import androidx.room.concurrent.AtomicInt
import androidx.sqlite.SQLiteDriver
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.filters.LargeTest
@@ -27,7 +28,6 @@
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
-import kotlinx.atomicfu.atomic
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.InternalCoroutinesApi
@@ -130,7 +130,7 @@
/** A CoroutineDispatcher that dispatches every block into a new thread */
private class NewThreadDispatcher : CoroutineDispatcher() {
- private val idCounter = atomic(0)
+ private val idCounter = AtomicInt(0)
@OptIn(InternalCoroutinesApi::class)
override fun dispatchYield(context: CoroutineContext, block: Runnable) {
diff --git a/room/room-runtime/src/androidMain/kotlin/androidx/room/InvalidationTracker.android.kt b/room/room-runtime/src/androidMain/kotlin/androidx/room/InvalidationTracker.android.kt
index 04caeb7..9b5e5d8 100644
--- a/room/room-runtime/src/androidMain/kotlin/androidx/room/InvalidationTracker.android.kt
+++ b/room/room-runtime/src/androidMain/kotlin/androidx/room/InvalidationTracker.android.kt
@@ -21,12 +21,12 @@
import androidx.annotation.WorkerThread
import androidx.lifecycle.LiveData
import androidx.room.InvalidationTracker.Observer
+import androidx.room.concurrent.ReentrantLock
+import androidx.room.concurrent.withLock
import androidx.room.support.AutoCloser
import androidx.sqlite.SQLiteConnection
import java.lang.ref.WeakReference
import java.util.concurrent.Callable
-import kotlinx.atomicfu.locks.reentrantLock
-import kotlinx.atomicfu.locks.withLock
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.runBlocking
@@ -65,7 +65,7 @@
)
private val observerMap = mutableMapOf<Observer, ObserverWrapper>()
- private val observerMapLock = reentrantLock()
+ private val observerMapLock = ReentrantLock()
private var autoCloser: AutoCloser? = null
diff --git a/room/room-runtime/src/androidUnitTest/kotlin/androidx/room/InvalidationTrackerTest.kt b/room/room-runtime/src/androidUnitTest/kotlin/androidx/room/InvalidationTrackerTest.kt
index bbe94dc..c5e76f9 100644
--- a/room/room-runtime/src/androidUnitTest/kotlin/androidx/room/InvalidationTrackerTest.kt
+++ b/room/room-runtime/src/androidUnitTest/kotlin/androidx/room/InvalidationTrackerTest.kt
@@ -19,6 +19,8 @@
import androidx.annotation.RequiresApi
import androidx.kruth.assertThat
import androidx.kruth.assertThrows
+import androidx.room.concurrent.AtomicBoolean
+import androidx.room.concurrent.AtomicInt
import androidx.sqlite.SQLiteConnection
import androidx.sqlite.SQLiteDriver
import androidx.sqlite.SQLiteStatement
@@ -27,7 +29,6 @@
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import kotlin.collections.removeFirst as removeFirstKt
-import kotlinx.atomicfu.atomic
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.cancelAndJoin
@@ -346,12 +347,12 @@
fun refreshAndCloseDbWithSlowObserver() = runTest {
// Validates that a slow observer will finish notification after database closing
val invalidatedLatch = CountDownLatch(1)
- val invalidated = atomic(false)
+ val invalidated = AtomicBoolean(false)
tracker.addObserver(
object : InvalidationTracker.Observer("a") {
override fun onInvalidated(tables: Set<String>) {
invalidatedLatch.countDown()
- assertThat(invalidated.compareAndSet(expect = false, update = true)).isTrue()
+ assertThat(invalidated.compareAndSet(false, true)).isTrue()
runBlocking { delay(100) }
}
}
@@ -361,7 +362,7 @@
testScheduler.advanceUntilIdle()
invalidatedLatch.await()
roomDatabase.close()
- assertThat(invalidated.value).isTrue()
+ assertThat(invalidated.get()).isTrue()
}
@Test
@@ -473,7 +474,7 @@
@Test
fun weakObserver() = runTest {
- val invalidated = atomic(0)
+ val invalidated = AtomicInt(0)
var observer: InvalidationTracker.Observer? =
object : InvalidationTracker.Observer("a") {
override fun onInvalidated(tables: Set<String>) {
@@ -484,7 +485,7 @@
sqliteDriver.setInvalidatedTables(0)
tracker.awaitRefreshAsync()
- assertThat(invalidated.value).isEqualTo(1)
+ assertThat(invalidated.get()).isEqualTo(1)
// Attempt to perform garbage collection in a loop so that weak observer is discarded
// and it stops receiving invalidation notifications. If GC fails to collect the observer
@@ -511,7 +512,7 @@
sqliteDriver.setInvalidatedTables(0)
tracker.awaitRefreshAsync()
- assertThat(invalidated.value).isEqualTo(1)
+ assertThat(invalidated.get()).isEqualTo(1)
}
@Test
diff --git a/room/room-runtime/src/commonMain/kotlin/androidx/room/InvalidationTracker.kt b/room/room-runtime/src/commonMain/kotlin/androidx/room/InvalidationTracker.kt
index 1997321..5730b60 100644
--- a/room/room-runtime/src/commonMain/kotlin/androidx/room/InvalidationTracker.kt
+++ b/room/room-runtime/src/commonMain/kotlin/androidx/room/InvalidationTracker.kt
@@ -18,16 +18,16 @@
import androidx.annotation.RestrictTo
import androidx.room.Transactor.SQLiteTransactionType
+import androidx.room.concurrent.AtomicBoolean
+import androidx.room.concurrent.ReentrantLock
import androidx.room.concurrent.ifNotClosed
+import androidx.room.concurrent.withLock
import androidx.room.util.getCoroutineContext
import androidx.sqlite.SQLiteConnection
import androidx.sqlite.SQLiteException
import androidx.sqlite.execSQL
import kotlin.jvm.JvmOverloads
import kotlin.jvm.JvmSuppressWildcards
-import kotlinx.atomicfu.atomic
-import kotlinx.atomicfu.locks.reentrantLock
-import kotlinx.atomicfu.locks.withLock
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.FlowCollector
@@ -162,7 +162,7 @@
* queue to be done asynchronously, this flag is used to control excessive scheduling of
* refreshes.
*/
- private val pendingRefresh = atomic(false)
+ private val pendingRefresh = AtomicBoolean(false)
/** Callback to allow or disallow [refreshInvalidation] from proceeding. */
internal var onAllowRefresh: () -> Boolean = { true }
@@ -484,7 +484,7 @@
*/
internal class ObservedTableStates(size: Int) {
- private val lock = reentrantLock()
+ private val lock = ReentrantLock()
// The number of observers per table
private val tableObserversCount = LongArray(size)
diff --git a/room/room-runtime/src/commonMain/kotlin/androidx/room/concurrent/Atomics.kt b/room/room-runtime/src/commonMain/kotlin/androidx/room/concurrent/Atomics.kt
new file mode 100644
index 0000000..04abb13
--- /dev/null
+++ b/room/room-runtime/src/commonMain/kotlin/androidx/room/concurrent/Atomics.kt
@@ -0,0 +1,51 @@
+/*
+ * Copyright 2025 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:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+
+package androidx.room.concurrent
+
+import androidx.annotation.RestrictTo
+
+expect class AtomicInt {
+ constructor(initialValue: Int)
+
+ fun get(): Int
+
+ fun set(value: Int)
+
+ fun compareAndSet(expect: Int, update: Int): Boolean
+
+ fun incrementAndGet(): Int
+
+ fun getAndIncrement(): Int
+
+ fun decrementAndGet(): Int
+}
+
+internal inline fun AtomicInt.loop(action: (Int) -> Unit): Nothing {
+ while (true) {
+ action(get())
+ }
+}
+
+expect class AtomicBoolean {
+ constructor(initialValue: Boolean)
+
+ fun get(): Boolean
+
+ fun compareAndSet(expect: Boolean, update: Boolean): Boolean
+}
diff --git a/room/room-runtime/src/commonMain/kotlin/androidx/room/concurrent/CloseBarrier.kt b/room/room-runtime/src/commonMain/kotlin/androidx/room/concurrent/CloseBarrier.kt
index 8bf4a24..e499315 100644
--- a/room/room-runtime/src/commonMain/kotlin/androidx/room/concurrent/CloseBarrier.kt
+++ b/room/room-runtime/src/commonMain/kotlin/androidx/room/concurrent/CloseBarrier.kt
@@ -16,11 +16,6 @@
package androidx.room.concurrent
-import kotlinx.atomicfu.atomic
-import kotlinx.atomicfu.locks.SynchronizedObject
-import kotlinx.atomicfu.locks.synchronized
-import kotlinx.atomicfu.loop
-
/**
* A barrier that can be used to perform a cleanup action once, waiting for registered parties
* (blockers) to finish using the protected resource.
@@ -40,9 +35,10 @@
* blockers.
*/
internal class CloseBarrier(private val closeAction: () -> Unit) : SynchronizedObject() {
- private val blockers = atomic(0)
- private val closeInitiated = atomic(false)
- private val isClosed by closeInitiated
+ private val blockers = AtomicInt(0)
+ private val closeInitiated = AtomicBoolean(false)
+ private val isClosed: Boolean
+ get() = closeInitiated.get()
/**
* Blocks the [closeAction] from occurring.
@@ -72,7 +68,7 @@
internal fun unblock(): Unit =
synchronized(this) {
blockers.decrementAndGet()
- check(blockers.value >= 0) { "Unbalanced call to unblock() detected." }
+ check(blockers.get() >= 0) { "Unbalanced call to unblock() detected." }
}
/**
diff --git a/room/room-runtime/src/commonMain/kotlin/androidx/room/concurrent/ExclusiveLock.kt b/room/room-runtime/src/commonMain/kotlin/androidx/room/concurrent/ExclusiveLock.kt
index de1c2e5..2d34dd7 100644
--- a/room/room-runtime/src/commonMain/kotlin/androidx/room/concurrent/ExclusiveLock.kt
+++ b/room/room-runtime/src/commonMain/kotlin/androidx/room/concurrent/ExclusiveLock.kt
@@ -16,11 +16,6 @@
package androidx.room.concurrent
-import kotlinx.atomicfu.locks.ReentrantLock
-import kotlinx.atomicfu.locks.SynchronizedObject
-import kotlinx.atomicfu.locks.reentrantLock
-import kotlinx.atomicfu.locks.synchronized
-
/**
* An exclusive lock for in-process and multi-process synchronization.
*
@@ -59,7 +54,7 @@
private fun getThreadLock(key: String): ReentrantLock =
synchronized(this) {
- return threadLocksMap.getOrPut(key) { reentrantLock() }
+ return threadLocksMap.getOrPut(key) { ReentrantLock() }
}
private fun getFileLock(key: String) = FileLock(key)
diff --git a/room/room-runtime/src/commonMain/kotlin/androidx/room/concurrent/ReentrantLock.kt b/room/room-runtime/src/commonMain/kotlin/androidx/room/concurrent/ReentrantLock.kt
new file mode 100644
index 0000000..444ef7c
--- /dev/null
+++ b/room/room-runtime/src/commonMain/kotlin/androidx/room/concurrent/ReentrantLock.kt
@@ -0,0 +1,34 @@
+/*
+ * Copyright 2025 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.room.concurrent
+
+internal expect class ReentrantLock() {
+ fun lock(): Unit
+
+ fun tryLock(): Boolean
+
+ fun unlock(): Unit
+}
+
+internal inline fun <T> ReentrantLock.withLock(block: () -> T): T {
+ lock()
+ try {
+ return block()
+ } finally {
+ unlock()
+ }
+}
diff --git a/room/room-runtime/src/commonMain/kotlin/androidx/room/concurrent/Synchronized.kt b/room/room-runtime/src/commonMain/kotlin/androidx/room/concurrent/Synchronized.kt
new file mode 100644
index 0000000..1515bdf
--- /dev/null
+++ b/room/room-runtime/src/commonMain/kotlin/androidx/room/concurrent/Synchronized.kt
@@ -0,0 +1,21 @@
+/*
+ * Copyright 2025 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.room.concurrent
+
+internal expect open class SynchronizedObject()
+
+internal expect inline fun <T> synchronized(lock: SynchronizedObject, block: () -> T): T
diff --git a/room/room-runtime/src/commonMain/kotlin/androidx/room/coroutines/ConnectionPoolImpl.kt b/room/room-runtime/src/commonMain/kotlin/androidx/room/coroutines/ConnectionPoolImpl.kt
index 185e412..ae49a2e 100644
--- a/room/room-runtime/src/commonMain/kotlin/androidx/room/coroutines/ConnectionPoolImpl.kt
+++ b/room/room-runtime/src/commonMain/kotlin/androidx/room/coroutines/ConnectionPoolImpl.kt
@@ -19,6 +19,8 @@
import androidx.room.TransactionScope
import androidx.room.Transactor
import androidx.room.Transactor.SQLiteTransactionType
+import androidx.room.concurrent.AtomicBoolean
+import androidx.room.concurrent.AtomicInt
import androidx.room.concurrent.ThreadLocal
import androidx.room.concurrent.asContextElement
import androidx.room.concurrent.currentThreadId
@@ -35,7 +37,6 @@
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.coroutineContext
import kotlin.time.Duration.Companion.seconds
-import kotlinx.atomicfu.atomic
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.sync.Mutex
@@ -50,8 +51,9 @@
private val threadLocal = ThreadLocal<PooledConnectionImpl>()
- private val _isClosed = atomic(false)
- private val isClosed by _isClosed
+ private val _isClosed = AtomicBoolean(false)
+ private val isClosed: Boolean
+ get() = _isClosed.get()
// Amount of time to wait to acquire a connection before throwing, Android uses 30 seconds in
// its pool, so we do too here, but IDK if that is a good number. This timeout is unrelated to
@@ -195,7 +197,7 @@
}
private class Pool(val capacity: Int, val connectionFactory: () -> SQLiteConnection) {
- private val size = atomic(0)
+ private val size = AtomicInt(0)
private val connections = arrayOfNulls<ConnectionWithLock>(capacity)
private val channel =
Channel<ConnectionWithLock>(capacity = capacity, onUndeliveredElement = { recycle(it) })
@@ -211,7 +213,7 @@
}
private fun tryOpenNewConnection() {
- val currentSize = size.value
+ val currentSize = size.get()
if (currentSize >= capacity) {
// Capacity reached
return
@@ -322,8 +324,9 @@
) : Transactor, RawConnectionAccessor {
private val transactionStack = ArrayDeque<TransactionItem>()
- private val _isRecycled = atomic(false)
- private val isRecycled by _isRecycled
+ private val _isRecycled = AtomicBoolean(false)
+ private val isRecycled: Boolean
+ get() = _isRecycled.get()
override val rawConnection: SQLiteConnection
get() = delegate
diff --git a/room/room-runtime/src/commonTest/kotlin/androidx/room/concurrent/CloseBarrierTest.kt b/room/room-runtime/src/commonTest/kotlin/androidx/room/concurrent/CloseBarrierTest.kt
index 75f64d1..3133b3a 100644
--- a/room/room-runtime/src/commonTest/kotlin/androidx/room/concurrent/CloseBarrierTest.kt
+++ b/room/room-runtime/src/commonTest/kotlin/androidx/room/concurrent/CloseBarrierTest.kt
@@ -19,7 +19,6 @@
import androidx.kruth.assertThat
import androidx.kruth.assertThrows
import kotlin.test.Test
-import kotlinx.atomicfu.atomic
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
@@ -34,9 +33,9 @@
@Test
@OptIn(ExperimentalCoroutinesApi::class, DelicateCoroutinesApi::class)
fun oneBlocker() = runTest {
- val actionPerformed = atomic(false)
+ val actionPerformed = AtomicBoolean(false)
val closeBarrier = CloseBarrier {
- assertThat(actionPerformed.compareAndSet(expect = false, update = true)).isTrue()
+ assertThat(actionPerformed.compareAndSet(false, true)).isTrue()
}
val jobLaunched = Mutex(locked = true)
@@ -52,14 +51,14 @@
// yield for launch and verify the close action has not been performed
yield()
- jobLaunched.withLock { assertThat(actionPerformed.value).isFalse() }
+ jobLaunched.withLock { assertThat(actionPerformed.get()).isFalse() }
// unblock the barrier, close job should complete
closeBarrier.unblock()
closeJob.join()
// verify action was performed
- assertThat(actionPerformed.value).isTrue()
+ assertThat(actionPerformed.get()).isTrue()
// verify a new block is not granted since the barrier is already close
assertThat(closeBarrier.block()).isFalse()
@@ -67,15 +66,15 @@
@Test
fun noBlockers() = runTest {
- val actionPerformed = atomic(false)
+ val actionPerformed = AtomicBoolean(false)
val closeBarrier = CloseBarrier {
- assertThat(actionPerformed.compareAndSet(expect = false, update = true)).isTrue()
+ assertThat(actionPerformed.compareAndSet(false, true)).isTrue()
}
// Validate close action is performed immediately if there are no blockers
closeBarrier.close()
- assertThat(actionPerformed.value).isTrue()
+ assertThat(actionPerformed.get()).isTrue()
}
@Test
@@ -89,9 +88,9 @@
@Test
@OptIn(ExperimentalCoroutinesApi::class, DelicateCoroutinesApi::class)
fun noStarvation() = runTest {
- val actionPerformed = atomic(false)
+ val actionPerformed = AtomicBoolean(false)
val closeBarrier = CloseBarrier {
- assertThat(actionPerformed.compareAndSet(expect = false, update = true)).isTrue()
+ assertThat(actionPerformed.compareAndSet(false, true)).isTrue()
}
val jobLaunched = Mutex(locked = true)
@@ -111,12 +110,12 @@
// yield for launch and verify the close action has not been performed in an attempt to
// get the block / unblock loop going
yield()
- jobLaunched.withLock { assertThat(actionPerformed.value).isFalse() }
+ jobLaunched.withLock { assertThat(actionPerformed.get()).isFalse() }
// initiate the close action, test should not deadlock (or timeout) meaning the barrier
// will not cause the caller to starve
closeBarrier.close()
blockerJob.join()
- assertThat(actionPerformed.value).isTrue()
+ assertThat(actionPerformed.get()).isTrue()
}
}
diff --git a/room/room-runtime/src/commonTest/kotlin/androidx/room/coroutines/BaseConnectionPoolTest.kt b/room/room-runtime/src/commonTest/kotlin/androidx/room/coroutines/BaseConnectionPoolTest.kt
index cff6dbd..f86b7b6 100644
--- a/room/room-runtime/src/commonTest/kotlin/androidx/room/coroutines/BaseConnectionPoolTest.kt
+++ b/room/room-runtime/src/commonTest/kotlin/androidx/room/coroutines/BaseConnectionPoolTest.kt
@@ -19,6 +19,7 @@
import androidx.kruth.assertThat
import androidx.room.PooledConnection
import androidx.room.Transactor
+import androidx.room.concurrent.AtomicInt
import androidx.room.deferredTransaction
import androidx.room.exclusiveTransaction
import androidx.room.execSQL
@@ -34,7 +35,6 @@
import kotlin.test.Test
import kotlin.test.assertFailsWith
import kotlin.test.fail
-import kotlinx.atomicfu.atomic
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -463,7 +463,7 @@
@Test
fun singleConnectionPool() = runTest {
val multiThreadContext = newFixedThreadPoolContext(2, "Test-Threads")
- val connectionsOpened = atomic(0)
+ val connectionsOpened = AtomicInt(0)
val actualDriver = setupDriver()
val driver =
object : SQLiteDriver by actualDriver {
@@ -487,12 +487,12 @@
jobs.joinAll()
pool.close()
multiThreadContext.close()
- assertThat(connectionsOpened.value).isEqualTo(1)
+ assertThat(connectionsOpened.get()).isEqualTo(1)
}
@Test
fun openOneConnectionWhenUsedSerially() = runTest {
- val connectionsOpened = atomic(0)
+ val connectionsOpened = AtomicInt(0)
val actualDriver = setupDriver()
val driver =
object : SQLiteDriver by actualDriver {
@@ -518,7 +518,7 @@
}
}
pool.close()
- assertThat(connectionsOpened.value).isEqualTo(1)
+ assertThat(connectionsOpened.get()).isEqualTo(1)
}
@Test
@@ -689,7 +689,7 @@
actual.close()
}
}
- val connectionArrCount = atomic(0)
+ val connectionArrCount = AtomicInt(0)
val connectionsArr = arrayOfNulls<CloseAwareConnection>(4)
val actualDriver = setupDriver()
val driver =
@@ -714,7 +714,7 @@
launch(multiThreadContext) { pool.useReaderConnection { barrier.withLock {} } }
jobs.add(job)
}
- while (connectionArrCount.value < 4) {
+ while (connectionArrCount.get() < 4) {
delay(100)
}
barrier.unlock()
diff --git a/room/room-runtime/src/jvmAndroidMain/kotlin/androidx/room/concurrent/Atomics.jvmAndroid.kt b/room/room-runtime/src/jvmAndroidMain/kotlin/androidx/room/concurrent/Atomics.jvmAndroid.kt
new file mode 100644
index 0000000..00255dc
--- /dev/null
+++ b/room/room-runtime/src/jvmAndroidMain/kotlin/androidx/room/concurrent/Atomics.jvmAndroid.kt
@@ -0,0 +1,25 @@
+/*
+ * Copyright 2025 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:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+
+package androidx.room.concurrent
+
+import androidx.annotation.RestrictTo
+
+actual typealias AtomicInt = java.util.concurrent.atomic.AtomicInteger
+
+actual typealias AtomicBoolean = java.util.concurrent.atomic.AtomicBoolean
diff --git a/room/room-runtime/src/jvmAndroidMain/kotlin/androidx/room/concurrent/ReentrantLock.jvmAndroid.kt b/room/room-runtime/src/jvmAndroidMain/kotlin/androidx/room/concurrent/ReentrantLock.jvmAndroid.kt
new file mode 100644
index 0000000..dc8fdab
--- /dev/null
+++ b/room/room-runtime/src/jvmAndroidMain/kotlin/androidx/room/concurrent/ReentrantLock.jvmAndroid.kt
@@ -0,0 +1,19 @@
+/*
+ * Copyright 2025 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.room.concurrent
+
+internal actual typealias ReentrantLock = java.util.concurrent.locks.ReentrantLock
diff --git a/room/room-runtime/src/jvmAndroidMain/kotlin/androidx/room/concurrent/Synchronized.jvmAndroid.kt b/room/room-runtime/src/jvmAndroidMain/kotlin/androidx/room/concurrent/Synchronized.jvmAndroid.kt
new file mode 100644
index 0000000..fae30d5
--- /dev/null
+++ b/room/room-runtime/src/jvmAndroidMain/kotlin/androidx/room/concurrent/Synchronized.jvmAndroid.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright 2025 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.room.concurrent
+
+internal actual typealias SynchronizedObject = Any
+
+internal actual inline fun <T> synchronized(lock: SynchronizedObject, block: () -> T): T =
+ kotlin.synchronized(lock, block)
diff --git a/room/room-runtime/src/nativeMain/kotlin/androidx/room/concurrent/Atomics.native.kt b/room/room-runtime/src/nativeMain/kotlin/androidx/room/concurrent/Atomics.native.kt
new file mode 100644
index 0000000..46b4012
--- /dev/null
+++ b/room/room-runtime/src/nativeMain/kotlin/androidx/room/concurrent/Atomics.native.kt
@@ -0,0 +1,52 @@
+/*
+ * Copyright 2025 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:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+
+package androidx.room.concurrent
+
+import androidx.annotation.RestrictTo
+import kotlin.concurrent.AtomicInt as KotlinAtomicInt
+
+actual class AtomicInt actual constructor(initialValue: Int) {
+ private val delegate: KotlinAtomicInt = KotlinAtomicInt(initialValue)
+
+ actual fun get(): Int = delegate.value
+
+ actual fun set(value: Int) {
+ delegate.value = value
+ }
+
+ actual fun compareAndSet(expect: Int, update: Int): Boolean =
+ delegate.compareAndSet(expect, update)
+
+ actual fun incrementAndGet(): Int = delegate.incrementAndGet()
+
+ actual fun getAndIncrement(): Int = delegate.getAndIncrement()
+
+ actual fun decrementAndGet(): Int = delegate.decrementAndGet()
+}
+
+actual class AtomicBoolean actual constructor(initialValue: Boolean) {
+ private val delegate: KotlinAtomicInt = KotlinAtomicInt(toInt(initialValue))
+
+ actual fun get(): Boolean = delegate.value == 1
+
+ actual fun compareAndSet(expect: Boolean, update: Boolean): Boolean =
+ delegate.compareAndSet(toInt(expect), toInt(update))
+
+ private fun toInt(value: Boolean) = if (value) 1 else 0
+}
diff --git a/room/room-runtime/src/nativeMain/kotlin/androidx/room/concurrent/ReentrantLock.native.kt b/room/room-runtime/src/nativeMain/kotlin/androidx/room/concurrent/ReentrantLock.native.kt
new file mode 100644
index 0000000..544fa01
--- /dev/null
+++ b/room/room-runtime/src/nativeMain/kotlin/androidx/room/concurrent/ReentrantLock.native.kt
@@ -0,0 +1,19 @@
+/*
+ * Copyright 2025 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.room.concurrent
+
+internal actual typealias ReentrantLock = kotlinx.atomicfu.locks.SynchronizedObject
diff --git a/room/room-runtime/src/nativeMain/kotlin/androidx/room/concurrent/Synchronized.native.kt b/room/room-runtime/src/nativeMain/kotlin/androidx/room/concurrent/Synchronized.native.kt
new file mode 100644
index 0000000..7691045
--- /dev/null
+++ b/room/room-runtime/src/nativeMain/kotlin/androidx/room/concurrent/Synchronized.native.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright 2025 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.room.concurrent
+
+internal actual typealias SynchronizedObject = kotlinx.atomicfu.locks.SynchronizedObject
+
+internal actual inline fun <T> synchronized(lock: SynchronizedObject, block: () -> T): T =
+ kotlinx.atomicfu.locks.synchronized(lock, block)
diff --git a/savedstate/savedstate-samples/src/main/java/androidx/savedstate/SavedStateCodecSamples.kt b/savedstate/savedstate-samples/src/main/java/androidx/savedstate/SavedStateCodecSamples.kt
index 8fd4b86..616baa6 100644
--- a/savedstate/savedstate-samples/src/main/java/androidx/savedstate/SavedStateCodecSamples.kt
+++ b/savedstate/savedstate-samples/src/main/java/androidx/savedstate/SavedStateCodecSamples.kt
@@ -18,6 +18,8 @@
package androidx.savedstate
+import android.os.Parcel
+import android.os.Parcelable
import androidx.annotation.Sampled
import androidx.savedstate.serialization.decodeFromSavedState
import androidx.savedstate.serialization.encodeToSavedState
@@ -96,7 +98,6 @@
val uuid = decodeFromSavedState(UUIDSerializer(), uuidSavedState)
}
-@Suppress("SERIALIZER_TYPE_INCOMPATIBLE") // The lint warning does not show up for external users.
@Sampled
fun savedStateSerializer() {
@Serializable
@@ -125,20 +126,36 @@
)
}
+private class MyJavaSerializable : java.io.Serializable
+
+private class MyJavaSerializableSerializer : JavaSerializableSerializer<MyJavaSerializable>()
+
@Sampled
fun serializableSerializer() {
@Serializable
data class MyModel(
- @Serializable(with = JavaSerializableSerializer::class)
- val serializable: java.io.Serializable
+ @Serializable(with = MyJavaSerializableSerializer::class)
+ val serializable: MyJavaSerializable
)
}
+private class MyParcelable : Parcelable {
+ override fun describeContents(): Int {
+ TODO("Not yet implemented")
+ }
+
+ override fun writeToParcel(dest: Parcel, flags: Int) {
+ TODO("Not yet implemented")
+ }
+}
+
+private class MyParcelableSerializer : ParcelableSerializer<MyParcelable>()
+
@Sampled
fun parcelableSerializer() {
@Serializable
data class MyModel(
- @Serializable(with = ParcelableSerializer::class) val parcelable: android.os.Parcelable
+ @Serializable(with = MyParcelableSerializer::class) val parcelable: MyParcelable
)
}
@@ -172,13 +189,11 @@
fun charSequenceListSerializer() {
@Serializable
class MyModel(
- @Suppress("SERIALIZER_TYPE_INCOMPATIBLE")
@Serializable(with = CharSequenceListSerializer::class)
val charSequenceList: List<CharSequence>
)
}
-@Suppress("SERIALIZER_TYPE_INCOMPATIBLE")
@Sampled
fun parcelableListSerializer() {
@Serializable
diff --git a/savedstate/savedstate/api/current.txt b/savedstate/savedstate/api/current.txt
index a3e7083..8a63bfe 100644
--- a/savedstate/savedstate/api/current.txt
+++ b/savedstate/savedstate/api/current.txt
@@ -195,12 +195,12 @@
property public kotlinx.serialization.descriptors.SerialDescriptor descriptor;
}
- public class CharSequenceSerializer<T extends java.lang.CharSequence> implements kotlinx.serialization.KSerializer<T> {
+ public final class CharSequenceSerializer implements kotlinx.serialization.KSerializer<java.lang.CharSequence> {
ctor public CharSequenceSerializer();
- method public final T deserialize(kotlinx.serialization.encoding.Decoder decoder);
- method public final kotlinx.serialization.descriptors.SerialDescriptor getDescriptor();
- method public final void serialize(kotlinx.serialization.encoding.Encoder encoder, T value);
- property public final kotlinx.serialization.descriptors.SerialDescriptor descriptor;
+ method public CharSequence deserialize(kotlinx.serialization.encoding.Decoder decoder);
+ method public kotlinx.serialization.descriptors.SerialDescriptor getDescriptor();
+ method public void serialize(kotlinx.serialization.encoding.Encoder encoder, CharSequence value);
+ property public kotlinx.serialization.descriptors.SerialDescriptor descriptor;
}
public final class IBinderSerializer implements kotlinx.serialization.KSerializer<android.os.IBinder> {
@@ -211,7 +211,7 @@
property public kotlinx.serialization.descriptors.SerialDescriptor descriptor;
}
- public class JavaSerializableSerializer<T extends java.io.Serializable> implements kotlinx.serialization.KSerializer<T> {
+ public abstract class JavaSerializableSerializer<T extends java.io.Serializable> implements kotlinx.serialization.KSerializer<T> {
ctor public JavaSerializableSerializer();
method public final T deserialize(kotlinx.serialization.encoding.Decoder decoder);
method public final kotlinx.serialization.descriptors.SerialDescriptor getDescriptor();
@@ -235,7 +235,7 @@
property public kotlinx.serialization.descriptors.SerialDescriptor descriptor;
}
- public class ParcelableSerializer<T extends android.os.Parcelable> implements kotlinx.serialization.KSerializer<T> {
+ public abstract class ParcelableSerializer<T extends android.os.Parcelable> implements kotlinx.serialization.KSerializer<T> {
ctor public ParcelableSerializer();
method public final T deserialize(kotlinx.serialization.encoding.Decoder decoder);
method public final kotlinx.serialization.descriptors.SerialDescriptor getDescriptor();
diff --git a/savedstate/savedstate/api/restricted_current.txt b/savedstate/savedstate/api/restricted_current.txt
index eb7a636..0f65840 100644
--- a/savedstate/savedstate/api/restricted_current.txt
+++ b/savedstate/savedstate/api/restricted_current.txt
@@ -220,12 +220,12 @@
property public kotlinx.serialization.descriptors.SerialDescriptor descriptor;
}
- public class CharSequenceSerializer<T extends java.lang.CharSequence> implements kotlinx.serialization.KSerializer<T> {
+ public final class CharSequenceSerializer implements kotlinx.serialization.KSerializer<java.lang.CharSequence> {
ctor public CharSequenceSerializer();
- method public final T deserialize(kotlinx.serialization.encoding.Decoder decoder);
- method public final kotlinx.serialization.descriptors.SerialDescriptor getDescriptor();
- method public final void serialize(kotlinx.serialization.encoding.Encoder encoder, T value);
- property public final kotlinx.serialization.descriptors.SerialDescriptor descriptor;
+ method public CharSequence deserialize(kotlinx.serialization.encoding.Decoder decoder);
+ method public kotlinx.serialization.descriptors.SerialDescriptor getDescriptor();
+ method public void serialize(kotlinx.serialization.encoding.Encoder encoder, CharSequence value);
+ property public kotlinx.serialization.descriptors.SerialDescriptor descriptor;
}
public final class IBinderSerializer implements kotlinx.serialization.KSerializer<android.os.IBinder> {
@@ -236,7 +236,7 @@
property public kotlinx.serialization.descriptors.SerialDescriptor descriptor;
}
- public class JavaSerializableSerializer<T extends java.io.Serializable> implements kotlinx.serialization.KSerializer<T> {
+ public abstract class JavaSerializableSerializer<T extends java.io.Serializable> implements kotlinx.serialization.KSerializer<T> {
ctor public JavaSerializableSerializer();
method public final T deserialize(kotlinx.serialization.encoding.Decoder decoder);
method public final kotlinx.serialization.descriptors.SerialDescriptor getDescriptor();
@@ -260,7 +260,7 @@
property public kotlinx.serialization.descriptors.SerialDescriptor descriptor;
}
- public class ParcelableSerializer<T extends android.os.Parcelable> implements kotlinx.serialization.KSerializer<T> {
+ public abstract class ParcelableSerializer<T extends android.os.Parcelable> implements kotlinx.serialization.KSerializer<T> {
ctor public ParcelableSerializer();
method public final T deserialize(kotlinx.serialization.encoding.Decoder decoder);
method public final kotlinx.serialization.descriptors.SerialDescriptor getDescriptor();
diff --git a/savedstate/savedstate/src/androidInstrumentedTest/kotlin/androidx/savedstate/SavedStateCodecTestUtils.android.kt b/savedstate/savedstate/src/androidInstrumentedTest/kotlin/androidx/savedstate/SavedStateCodecTestUtils.android.kt
new file mode 100644
index 0000000..edf79cc
--- /dev/null
+++ b/savedstate/savedstate/src/androidInstrumentedTest/kotlin/androidx/savedstate/SavedStateCodecTestUtils.android.kt
@@ -0,0 +1,28 @@
+/*
+ * Copyright 2025 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.savedstate
+
+import android.os.Parcel
+
+actual fun platformEncodeDecode(savedState: SavedState): SavedState {
+ val parcel =
+ Parcel.obtain().apply {
+ savedState.writeToParcel(this, 0)
+ setDataPosition(0)
+ }
+ return SavedState.CREATOR.createFromParcel(parcel)
+}
diff --git a/savedstate/savedstate/src/androidMain/kotlin/androidx/savedstate/serialization/serializers/BuiltInSerializer.android.kt b/savedstate/savedstate/src/androidMain/kotlin/androidx/savedstate/serialization/serializers/BuiltInSerializer.android.kt
index f0dacc5..071f4c1 100644
--- a/savedstate/savedstate/src/androidMain/kotlin/androidx/savedstate/serialization/serializers/BuiltInSerializer.android.kt
+++ b/savedstate/savedstate/src/androidMain/kotlin/androidx/savedstate/serialization/serializers/BuiltInSerializer.android.kt
@@ -107,10 +107,10 @@
* @see androidx.savedstate.serialization.decodeFromSavedState
*/
@OptIn(ExperimentalSerializationApi::class)
-public open class CharSequenceSerializer<T : CharSequence> : KSerializer<T> {
- final override val descriptor: SerialDescriptor = buildClassSerialDescriptor("CharSequence")
+public class CharSequenceSerializer : KSerializer<CharSequence> {
+ override val descriptor: SerialDescriptor = buildClassSerialDescriptor("CharSequence")
- final override fun serialize(encoder: Encoder, value: T) {
+ override fun serialize(encoder: Encoder, value: CharSequence) {
require(encoder is SavedStateEncoder) {
encoderErrorMessage(descriptor.serialName, encoder)
}
@@ -118,17 +118,18 @@
}
@Suppress("UNCHECKED_CAST")
- final override fun deserialize(decoder: Decoder): T {
+ override fun deserialize(decoder: Decoder): CharSequence {
require(decoder is SavedStateDecoder) {
decoderErrorMessage(descriptor.serialName, decoder)
}
- return decoder.run { savedState.read { getCharSequence(key) as T } }
+ return decoder.run { savedState.read { getCharSequence(key) } }
}
}
/**
* A serializer for [java.io.Serializable]. This serializer uses [SavedState]'s API directly to
- * save/load a [java.io.Serializable].
+ * save/load a [java.io.Serializable]. You must extend this serializer for each of your
+ * [java.io.Serializable] subclasses.
*
* Note that this serializer should be used with [SavedStateEncoder] or [SavedStateDecoder] only.
* Using it with other Encoders/Decoders may throw [IllegalArgumentException].
@@ -138,7 +139,7 @@
* @see androidx.savedstate.serialization.decodeFromSavedState
*/
@OptIn(ExperimentalSerializationApi::class)
-public open class JavaSerializableSerializer<T : JavaSerializable> : KSerializer<T> {
+public abstract class JavaSerializableSerializer<T : JavaSerializable> : KSerializer<T> {
final override val descriptor: SerialDescriptor = buildClassSerialDescriptor("JavaSerializable")
final override fun serialize(encoder: Encoder, value: T) {
@@ -159,7 +160,7 @@
/**
* A serializer for [Parcelable]. This serializer uses [SavedState]'s API directly to save/load a
- * [Parcelable].
+ * [Parcelable]. You must extend this serializer for each of your [Parcelable] subclasses.
*
* Note that this serializer should be used with [SavedStateEncoder] or [SavedStateDecoder] only.
* Using it with other Encoders/Decoders may throw [IllegalArgumentException].
@@ -169,7 +170,7 @@
* @see androidx.savedstate.serialization.decodeFromSavedState
*/
@OptIn(ExperimentalSerializationApi::class)
-public open class ParcelableSerializer<T : Parcelable> : KSerializer<T> {
+public abstract class ParcelableSerializer<T : Parcelable> : KSerializer<T> {
final override val descriptor: SerialDescriptor = buildClassSerialDescriptor("Parcelable")
final override fun serialize(encoder: Encoder, value: T) {
diff --git a/savedstate/savedstate/src/androidUnitTest/kotlin/androidx/savedstate/SavedStateCodecAndroidTest.android.kt b/savedstate/savedstate/src/androidUnitTest/kotlin/androidx/savedstate/SavedStateCodecAndroidTest.android.kt
index 2c34ddb..743c3e4 100644
--- a/savedstate/savedstate/src/androidUnitTest/kotlin/androidx/savedstate/SavedStateCodecAndroidTest.android.kt
+++ b/savedstate/savedstate/src/androidUnitTest/kotlin/androidx/savedstate/SavedStateCodecAndroidTest.android.kt
@@ -26,7 +26,6 @@
import android.util.SizeF
import android.util.SparseArray
import androidx.core.os.bundleOf
-import androidx.core.util.forEach
import androidx.kruth.assertThat
import androidx.kruth.assertThrows
import androidx.savedstate.SavedStateCodecTestUtils.encodeDecode
@@ -59,7 +58,6 @@
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.encoding.decodeStructure
import kotlinx.serialization.encoding.encodeStructure
-import kotlinx.serialization.serializer
@ExperimentalSerializationApi
internal class SavedStateCodecAndroidTest : RobolectricTest() {
@@ -100,18 +98,7 @@
"SERIALIZER_TYPE_INCOMPATIBLE"
) // The lint warning does not show up for external users.
@Serializable
- class MyClass(@Serializable(with = SavedStateSerializer::class) val s: Bundle) {
- override fun equals(other: Any?): Boolean {
- if (this === other) return true
- if (javaClass != other?.javaClass) return false
- other as MyClass
- return s.read { contentDeepEquals(other.s) }
- }
-
- override fun hashCode(): Int {
- return s.read { contentDeepHashCode() }
- }
- }
+ class MyClass(@Serializable(with = SavedStateSerializer::class) val s: Bundle)
MyClass(
bundleOf(
"i" to 1,
@@ -120,19 +107,24 @@
"ss" to bundleOf("s" to "bar")
)
)
- .encodeDecode {
- assertThat(size()).isEqualTo(1)
- getSavedState("s").read {
- assertThat(size()).isEqualTo(4)
- assertThat(getInt("i")).isEqualTo(1)
- assertThat(getString("s")).isEqualTo("foo")
- assertThat(getIntArray("a")).isEqualTo(intArrayOf(1, 3, 5))
- getSavedState("ss").read {
- assertThat(size()).isEqualTo(1)
- assertThat(getString("s")).isEqualTo("bar")
+ .encodeDecode(
+ checkDecoded = { decoded, original ->
+ assertThat(decoded.s.read { contentDeepEquals(original.s) }).isTrue()
+ },
+ checkEncoded = {
+ assertThat(size()).isEqualTo(1)
+ getSavedState("s").read {
+ assertThat(size()).isEqualTo(4)
+ assertThat(getInt("i")).isEqualTo(1)
+ assertThat(getString("s")).isEqualTo("foo")
+ assertThat(getIntArray("a")).isEqualTo(intArrayOf(1, 3, 5))
+ getSavedState("ss").read {
+ assertThat(size()).isEqualTo(1)
+ assertThat(getString("s")).isEqualTo("bar")
+ }
}
}
- }
+ )
// Bundle at root.
val origin = bundleOf("i" to 3, "s" to "foo", "d" to 3.14)
@@ -180,7 +172,8 @@
@Serializable
data class SerializableContainer(
- @Serializable(with = JavaSerializableSerializer::class) val value: java.io.Serializable
+ @Serializable(with = CustomJavaSerializableSerializer::class)
+ val value: java.io.Serializable
)
val myJavaSerializable = MyJavaSerializable(3, "foo", 3.14)
SerializableContainer(myJavaSerializable).encodeDecode {
@@ -191,7 +184,7 @@
@Serializable
data class ParcelableContainer(
- @Serializable(with = ParcelableSerializer::class) val value: Parcelable
+ @Serializable(with = CustomParcelableSerializer::class) val value: Parcelable
)
val myParcelable = MyParcelable(3, "foo", 3.14)
ParcelableContainer(myParcelable).encodeDecode {
@@ -257,19 +250,9 @@
error("VERSION.SDK_INT < Q")
}
+ @Suppress("ArrayInDataClass")
@Serializable
- data class CharSequenceArrayContainer(val value: Array<out CharSequence>) {
- override fun equals(other: Any?): Boolean {
- if (this === other) return true
- if (javaClass != other?.javaClass) return false
- other as CharSequenceArrayContainer
- return value.contentEquals(other.value)
- }
-
- override fun hashCode(): Int {
- return value.contentHashCode()
- }
- }
+ data class CharSequenceArrayContainer(val value: Array<out CharSequence>)
assertThrows<SerializationException> {
CharSequenceArrayContainer(arrayOf("foo", "bar")).encodeDecode {}
}
@@ -281,20 +264,10 @@
@Test
fun concreteTypesInsteadOfInterfaceTypes() {
- @Suppress("SERIALIZER_TYPE_INCOMPATIBLE")
- @Serializable
- data class CharSequenceContainer(
- @Serializable(with = CharSequenceSerializer::class) val value: String
- )
- CharSequenceContainer("foo").encodeDecode {
- assertThat(size()).isEqualTo(1)
- assertThat(getCharSequence("value")).isEqualTo("foo")
- }
-
- @Suppress("SERIALIZER_TYPE_INCOMPATIBLE")
@Serializable
data class SerializableContainer(
- @Serializable(with = JavaSerializableSerializer::class) val value: MyJavaSerializable
+ @Serializable(with = MyJavaSerializableAsJavaSerializableSerializer::class)
+ val value: MyJavaSerializable
)
val myJavaSerializable = MyJavaSerializable(3, "foo", 3.14)
SerializableContainer(myJavaSerializable).encodeDecode {
@@ -303,10 +276,9 @@
.isEqualTo(myJavaSerializable)
}
- @Suppress("SERIALIZER_TYPE_INCOMPATIBLE")
@Serializable
data class ParcelableContainer(
- @Serializable(with = ParcelableSerializer::class) val value: MyParcelable
+ @Serializable(with = MyParcelableAsParcelableSerializer::class) val value: MyParcelable
)
val myParcelable = MyParcelable(3, "foo", 3.14)
ParcelableContainer(myParcelable).encodeDecode {
@@ -315,10 +287,11 @@
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
- @Suppress("SERIALIZER_TYPE_INCOMPATIBLE")
@Serializable
data class IBinderContainer(
- @Serializable(with = IBinderSerializer::class) val value: Binder
+ @Suppress("SERIALIZER_TYPE_INCOMPATIBLE")
+ @Serializable(with = IBinderSerializer::class)
+ val value: Binder
)
val binder = Binder("foo")
IBinderContainer(binder).encodeDecode {
@@ -333,47 +306,36 @@
@Test
fun collectionTypes() {
@Serializable
+ @Suppress("ArrayInDataClass")
data class CharSequenceArrayContainer(
@Serializable(with = CharSequenceArraySerializer::class)
val value: Array<out CharSequence>
- ) {
- override fun equals(other: Any?): Boolean {
- if (this === other) return true
- if (javaClass != other?.javaClass) return false
- other as CharSequenceArrayContainer
- return value.contentEquals(other.value)
- }
-
- override fun hashCode(): Int {
- return value.contentHashCode()
- }
- }
- val myCharSequenceArray = arrayOf("foo", "bar")
- CharSequenceArrayContainer(myCharSequenceArray).encodeDecode {
- assertThat(size()).isEqualTo(1)
- assertThat(getCharSequenceArray("value")).isEqualTo(myCharSequenceArray)
- }
+ )
+ val myCharSequenceArray = arrayOf(StringBuilder("foo"), StringBuilder("bar"))
+ CharSequenceArrayContainer(myCharSequenceArray)
+ .encodeDecode(
+ checkDecoded = { decoded, original -> decoded.value.contentEquals(original.value) },
+ checkEncoded = {
+ assertThat(size()).isEqualTo(1)
+ assertThat(getCharSequenceArray("value")).isEqualTo(myCharSequenceArray)
+ }
+ )
@Serializable
+ @Suppress("ArrayInDataClass")
data class ParcelableArrayContainer(
@Serializable(with = ParcelableArraySerializer::class) val value: Array<out Parcelable>
- ) {
- override fun equals(other: Any?): Boolean {
- if (this === other) return true
- if (javaClass != other?.javaClass) return false
- other as ParcelableArrayContainer
- return value.contentEquals(other.value)
- }
-
- override fun hashCode(): Int {
- return value.contentHashCode()
- }
- }
+ )
val myParcelableArray = arrayOf(MyParcelable(3, "foo", 3.14), MyParcelable(4, "bar", 1.73))
- ParcelableArrayContainer(myParcelableArray).encodeDecode {
- assertThat(size()).isEqualTo(1)
- assertThat(getParcelableArray<MyParcelable>("value")).isEqualTo(myParcelableArray)
- }
+ ParcelableArrayContainer(myParcelableArray)
+ .encodeDecode(
+ checkDecoded = { decoded, original -> decoded.value.contentEquals(original.value) },
+ checkEncoded = {
+ assertThat(size()).isEqualTo(1)
+ assertThat(getParcelableArray<MyParcelable>("value"))
+ .isEqualTo(myParcelableArray)
+ }
+ )
@Serializable
data class CharSequenceListContainer(
@@ -406,76 +368,96 @@
append(1, MyParcelable(3, "foo", 3.14))
append(3, MyParcelable(4, "bar", 1.73))
}
- SparseParcelableArrayContainer(mySparseParcelableArray).encodeDecode {
- assertThat(size()).isEqualTo(1)
- assertThat(getSparseParcelableArray<Parcelable>("value"))
- .isEqualTo(mySparseParcelableArray)
- }
+ SparseParcelableArrayContainer(mySparseParcelableArray)
+ .encodeDecode(
+ checkDecoded = { decoded, original ->
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ decoded.value.contentEquals(original.value)
+ } else {
+ error("VERSION.SDK_INT < S")
+ }
+ },
+ checkEncoded = {
+ assertThat(size()).isEqualTo(1)
+ assertThat(getSparseParcelableArray<Parcelable>("value"))
+ .isEqualTo(mySparseParcelableArray)
+ }
+ )
}
@Test
fun collectionTypesWithConcreteElement() {
+ @Suppress("ArrayInDataClass")
@Serializable
data class CharSequenceArrayContainer(
- @Serializable(with = CharSequenceArraySerializer::class) val value: Array<String>
- ) {
- override fun equals(other: Any?): Boolean {
- if (this === other) return true
- if (javaClass != other?.javaClass) return false
- other as CharSequenceArrayContainer
- return value.contentEquals(other.value)
- }
-
- override fun hashCode(): Int {
- return value.contentHashCode()
+ @Suppress("SERIALIZER_TYPE_INCOMPATIBLE")
+ @Serializable(with = CharSequenceArraySerializer::class)
+ val value: Array<@Serializable(with = CharSequenceSerializer::class) StringBuilder>
+ )
+ val myCharSequenceArray = arrayOf<StringBuilder>(StringBuilder("foo"), StringBuilder("bar"))
+ // `Bundle.getCharSequenceArray()` returns a `CharSequence[]` and the actual element type
+ // is not being retained after parcel/unparcel so the plugin-generated serializer will
+ // get `ClassCastException` when trying to cast it back to `Array<StringBuilder>`.
+ assertThrows(ClassCastException::class) {
+ CharSequenceArrayContainer(myCharSequenceArray).encodeDecode {
+ assertThat(size()).isEqualTo(1)
+ assertThat(getCharSequenceArray("value")).isEqualTo(myCharSequenceArray)
}
}
- val myCharSequenceArray = arrayOf("foo", "bar")
- CharSequenceArrayContainer(myCharSequenceArray).encodeDecode {
- assertThat(size()).isEqualTo(1)
- assertThat(getCharSequenceArray("value")).isEqualTo(myCharSequenceArray)
- }
- @Suppress("SERIALIZER_TYPE_INCOMPATIBLE")
+ @Suppress("ArrayInDataClass")
@Serializable
data class ParcelableArrayContainer(
@Serializable(with = ParcelableArraySerializer::class)
// Here the serializer for the element is actually not used, but leaving it out leads
// to SERIALIZER_NOT_FOUND compile error.
- val value: Array<@Serializable(with = ParcelableSerializer::class) MyParcelable>
- ) {
- override fun equals(other: Any?): Boolean {
- if (this === other) return true
- if (javaClass != other?.javaClass) return false
- other as ParcelableArrayContainer
- return value.contentEquals(other.value)
- }
-
- override fun hashCode(): Int {
- return value.contentHashCode()
- }
- }
+ val value:
+ Array<@Serializable(with = MyParcelableAsParcelableSerializer::class) MyParcelable>
+ )
val myParcelableArray = arrayOf(MyParcelable(3, "foo", 3.14), MyParcelable(4, "bar", 1.73))
- ParcelableArrayContainer(myParcelableArray).encodeDecode {
- assertThat(size()).isEqualTo(1)
- assertThat(getParcelableArray<MyParcelable>("value")).isEqualTo(myParcelableArray)
+ // Even though `Bundle` does retain the actual `Parcelable` type there's no way for us to
+ // specify this `Parcelable` element type for the array, so the restored array is still of
+ // type `Array<Parcelable>` and the plugin-generated serializer will get
+ // `ClassCastException` when trying to cast it back to `Array<MyParcelable>`.
+ assertThrows(ClassCastException::class) {
+ ParcelableArrayContainer(myParcelableArray).encodeDecode {
+ assertThat(size()).isEqualTo(1)
+ assertThat(getParcelableArray<MyParcelable>("value")).isEqualTo(myParcelableArray)
+ }
}
@Serializable
data class CharSequenceListContainer(
- @Serializable(with = CharSequenceListSerializer::class) val value: List<String>
+ @Serializable(with = CharSequenceListSerializer::class)
+ @Suppress("SERIALIZER_TYPE_INCOMPATIBLE")
+ val value: List<@Serializable(with = CharSequenceSerializer::class) StringBuilder>
)
- val myCharSequenceList = arrayListOf("foo", "bar")
- CharSequenceListContainer(myCharSequenceList).encodeDecode {
- assertThat(size()).isEqualTo(1)
- assertThat(getCharSequenceList("value")).isEqualTo(myCharSequenceList)
- }
+ val myCharSequenceList = arrayListOf(StringBuilder("foo"), StringBuilder("bar"))
- @Suppress("SERIALIZER_TYPE_INCOMPATIBLE")
+ CharSequenceListContainer(myCharSequenceList)
+ .encodeDecode(
+ checkDecoded = { decoded, original ->
+ assertThat(original.value[0]::class).isEqualTo(StringBuilder::class)
+ // This is similar to the `CharSequenceArray` case where the element type of the
+ // restored List after parcel/unparcel is of `String` instead of
+ // `StringBuilder`. However, since the element type of Lists is erased no
+ // `CastCastException` is thrown when the plugin-generated serializer tried to
+ // assign the restored list back to `List<StringBuilder>`.
+ assertThat(decoded.value[0]::class).isEqualTo(String::class)
+ },
+ checkEncoded = {
+ assertThat(size()).isEqualTo(1)
+ assertThat(getCharSequenceList("value")).isEqualTo(myCharSequenceList)
+ }
+ )
+
@Serializable
data class ParcelableListContainer(
+ // Unlike arrays this works as `List`s can be down-casted, e.g.
+ // a `List<Parcelable>` can be casted to `List<MyParcelable>`.
@Serializable(with = ParcelableListSerializer::class)
- val value: List<@Serializable(with = ParcelableSerializer::class) MyParcelable>
+ val value:
+ List<@Serializable(with = MyParcelableAsParcelableSerializer::class) MyParcelable>
)
val myParcelableList =
arrayListOf(MyParcelable(3, "foo", 3.14), MyParcelable(4, "bar", 1.73))
@@ -484,57 +466,37 @@
assertThat(getParcelableList<MyParcelable>("value")).isEqualTo(myParcelableList)
}
- @Suppress("SERIALIZER_TYPE_INCOMPATIBLE")
@Serializable
data class SparseParcelableArrayContainer(
+ // Unlike arrays this works as `SparseArray`s can be down-casted, e.g.
+ // a `SparseArray<Parcelable>` can be casted to `SparseArray<MyParcelable>`.
@Serializable(with = SparseParcelableArraySerializer::class)
- val value: SparseArray<@Serializable(with = ParcelableSerializer::class) MyParcelable>
+ val value:
+ SparseArray<
+ @Serializable(with = MyParcelableAsParcelableSerializer::class)
+ MyParcelable
+ >
)
val mySparseParcelableArray =
SparseArray<MyParcelable>().apply {
append(1, MyParcelable(3, "foo", 3.14))
append(3, MyParcelable(4, "bar", 1.73))
}
- SparseParcelableArrayContainer(mySparseParcelableArray).encodeDecode {
- assertThat(size()).isEqualTo(1)
- assertThat(getSparseParcelableArray<Parcelable>("value"))
- .isEqualTo(mySparseParcelableArray)
- }
- }
-
- @Test
- fun concreteTypeSerializers() {
- // No need to suppress SERIALIZER_TYPE_INCOMPATIBLE with these serializers.
- @Serializable
- data class CharSequenceContainer(
- @Serializable(with = StringAsCharSequenceSerializer::class) val value: String
- )
- CharSequenceContainer("foo").encodeDecode {
- assertThat(size()).isEqualTo(1)
- assertThat(getCharSequence("value")).isEqualTo("foo")
- }
-
- @Serializable
- data class SerializableContainer(
- @Serializable(with = MyJavaSerializableAsJavaSerializableSerializer::class)
- val value: MyJavaSerializable
- )
- val myJavaSerializable = MyJavaSerializable(3, "foo", 3.14)
- SerializableContainer(myJavaSerializable).encodeDecode {
- assertThat(size()).isEqualTo(1)
- assertThat(getJavaSerializable<MyJavaSerializable>("value"))
- .isEqualTo(myJavaSerializable)
- }
-
- @Serializable
- data class ParcelableContainer(
- @Serializable(with = MyParcelableAsParcelableSerializer::class) val value: MyParcelable
- )
- val myParcelable = MyParcelable(3, "foo", 3.14)
- ParcelableContainer(myParcelable).encodeDecode {
- assertThat(size()).isEqualTo(1)
- assertThat(getParcelable<MyParcelable>("value")).isEqualTo(myParcelable)
- }
+ SparseParcelableArrayContainer(mySparseParcelableArray)
+ .encodeDecode(
+ checkDecoded = { decoded, original ->
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ assertThat(decoded.value.contentEquals(original.value))
+ } else {
+ error("VERSION.SDK_INT < S")
+ }
+ },
+ checkEncoded = {
+ assertThat(size()).isEqualTo(1)
+ assertThat(getSparseParcelableArray<Parcelable>("value"))
+ .isEqualTo(mySparseParcelableArray)
+ }
+ )
}
}
@@ -610,45 +572,11 @@
}
}
-private object CharArrayAsStringSerializer : KSerializer<Array<Char>> {
- private val delegateSerializer = serializer<String>()
- override val descriptor: SerialDescriptor =
- PrimitiveSerialDescriptor("Array<Char>", PrimitiveKind.STRING)
-
- override fun deserialize(decoder: Decoder): Array<Char> {
- val s = decoder.decodeSerializableValue(delegateSerializer)
- val result = Array(s.length) { s[it] }
- return result
- }
-
- override fun serialize(encoder: Encoder, value: Array<Char>) {
- val charArray = CharArray(value.size)
- value.forEachIndexed { index, c -> charArray[index] = c }
- encoder.encodeSerializableValue(delegateSerializer, String(charArray))
- }
-}
-
-@OptIn(ExperimentalSerializationApi::class)
-private object SparseStringArrayAsMapSerializer : KSerializer<SparseArray<String>> {
- private val delegateSerializer = serializer<Map<Int, String>>()
- override val descriptor = SerialDescriptor("SparseArray<String>", delegateSerializer.descriptor)
-
- override fun deserialize(decoder: Decoder): SparseArray<String> {
- val m = decoder.decodeSerializableValue(delegateSerializer)
- val result = SparseArray<String>()
- m.forEach { (k, v) -> result.append(k, v) }
- return result
- }
-
- override fun serialize(encoder: Encoder, value: SparseArray<String>) {
- val map = buildMap { value.forEach { k, v -> put(k, v) } }
- encoder.encodeSerializableValue(delegateSerializer, map)
- }
-}
-
-private class StringAsCharSequenceSerializer : CharSequenceSerializer<String>()
-
private class MyJavaSerializableAsJavaSerializableSerializer :
JavaSerializableSerializer<MyJavaSerializable>()
private class MyParcelableAsParcelableSerializer : ParcelableSerializer<MyParcelable>()
+
+private class CustomJavaSerializableSerializer : JavaSerializableSerializer<java.io.Serializable>()
+
+private class CustomParcelableSerializer : ParcelableSerializer<Parcelable>()
diff --git a/savedstate/savedstate/src/androidUnitTest/kotlin/androidx/savedstate/SavedStateCodecTestUtils.android.kt b/savedstate/savedstate/src/androidUnitTest/kotlin/androidx/savedstate/SavedStateCodecTestUtils.android.kt
new file mode 100644
index 0000000..edf79cc
--- /dev/null
+++ b/savedstate/savedstate/src/androidUnitTest/kotlin/androidx/savedstate/SavedStateCodecTestUtils.android.kt
@@ -0,0 +1,28 @@
+/*
+ * Copyright 2025 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.savedstate
+
+import android.os.Parcel
+
+actual fun platformEncodeDecode(savedState: SavedState): SavedState {
+ val parcel =
+ Parcel.obtain().apply {
+ savedState.writeToParcel(this, 0)
+ setDataPosition(0)
+ }
+ return SavedState.CREATOR.createFromParcel(parcel)
+}
diff --git a/savedstate/savedstate/src/commonMain/kotlin/androidx/savedstate/serialization/SavedStateDecoder.kt b/savedstate/savedstate/src/commonMain/kotlin/androidx/savedstate/serialization/SavedStateDecoder.kt
index 65ccd1f..3f06dff 100644
--- a/savedstate/savedstate/src/commonMain/kotlin/androidx/savedstate/serialization/SavedStateDecoder.kt
+++ b/savedstate/savedstate/src/commonMain/kotlin/androidx/savedstate/serialization/SavedStateDecoder.kt
@@ -22,6 +22,7 @@
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerializationException
import kotlinx.serialization.descriptors.SerialDescriptor
+import kotlinx.serialization.descriptors.StructureKind
import kotlinx.serialization.encoding.AbstractDecoder
import kotlinx.serialization.encoding.CompositeDecoder
import kotlinx.serialization.modules.EmptySerializersModule
@@ -73,9 +74,31 @@
private var index = 0
override fun decodeElementIndex(descriptor: SerialDescriptor): Int {
- if (index == savedState.read { size() }) return CompositeDecoder.DECODE_DONE
- key = descriptor.getElementName(index)
- return index++
+ val size =
+ if (descriptor.kind == StructureKind.LIST || descriptor.kind == StructureKind.MAP) {
+ // Use the number of elements encoded for collections.
+ savedState.read { size() }
+ } else {
+ // We may skip elements when encoding so if we used `size()`
+ // here we may miss some fields.
+ descriptor.elementsCount
+ }
+ fun hasDefaultValueDefined(index: Int) = descriptor.isElementOptional(index)
+ fun presentInEncoding(index: Int) =
+ savedState.read {
+ val key = descriptor.getElementName(index)
+ contains(key)
+ }
+ // Skip elements omitted from encoding (those assigned with its default values).
+ while (index < size && hasDefaultValueDefined(index) && !presentInEncoding(index)) {
+ index++
+ }
+ if (index < size) {
+ key = descriptor.getElementName(index)
+ return index++
+ } else {
+ return CompositeDecoder.DECODE_DONE
+ }
}
override fun decodeBoolean(): Boolean = savedState.read { getBoolean(key) }
diff --git a/savedstate/savedstate/src/commonTest/kotlin/androidx/savedstate/SavedStateCodecTest.kt b/savedstate/savedstate/src/commonTest/kotlin/androidx/savedstate/SavedStateCodecTest.kt
index 86adf0b..88c33e4 100644
--- a/savedstate/savedstate/src/commonTest/kotlin/androidx/savedstate/SavedStateCodecTest.kt
+++ b/savedstate/savedstate/src/commonTest/kotlin/androidx/savedstate/SavedStateCodecTest.kt
@@ -401,15 +401,19 @@
@Test
fun sealedClasses() {
- Node.Add(Node.Operand(3), Node.Operand(5)).encodeDecode {
+ // Should use base type for encoding/decoding.
+ Node.Add(Node.Operand(3), Node.Operand(5)).encodeDecode<Node> {
assertThat(size()).isEqualTo(2)
- getSavedState("lhs").read {
- assertThat(size()).isEqualTo(1)
- assertThat(getInt("value")).isEqualTo(3)
- }
- getSavedState("rhs").read {
- assertThat(size()).isEqualTo(1)
- assertThat(getInt("value")).isEqualTo(5)
+ assertThat(getString("type")).isEqualTo("androidx.savedstate.Node.Add")
+ getSavedState("value").read {
+ getSavedState("lhs").read {
+ assertThat(size()).isEqualTo(1)
+ assertThat(getInt("value")).isEqualTo(3)
+ }
+ getSavedState("rhs").read {
+ assertThat(size()).isEqualTo(1)
+ assertThat(getInt("value")).isEqualTo(5)
+ }
}
}
}
@@ -425,12 +429,24 @@
}
// Nullable with default value.
- @Serializable data class B(val s: String? = "foo")
- B().encodeDecode()
- B(s = "bar").encodeDecode {
+ @Serializable data class B(val s: String? = "foo", val i: Int)
+ B(i = 3).encodeDecode {
assertThat(size()).isEqualTo(1)
+ assertThat(getInt("i")).isEqualTo(3)
+ }
+ B(s = null, i = 3).encodeDecode {
+ assertThat(size()).isEqualTo(2)
+ assertThat(isNull("s")).isTrue()
+ }
+ B(s = "bar", i = 3).encodeDecode {
+ assertThat(size()).isEqualTo(2)
assertThat(getString("s")).isEqualTo("bar")
}
+ // The value of `s` is the same as its default value so it's omitted from encoding.
+ B(s = "foo", i = 3).encodeDecode {
+ assertThat(size()).isEqualTo(1)
+ assertThat(getInt("i")).isEqualTo(3)
+ }
// Nullable without default value
@Serializable data class C(val s: String?)
@@ -450,6 +466,22 @@
assertThat(getInt("i")).isEqualTo(5)
assertThat(getString("s")).isEqualTo("foo")
}
+
+ // Nullable with null as default value.
+ @Serializable data class E(val s: String? = null)
+ // Even though we encode `null`s in general as we don't encode default values
+ // nothing is encoded.
+ E().encodeDecode()
+
+ // Nullable in parent
+ G(i = 3).encodeDecode<F> {
+ assertThat(size()).isEqualTo(2)
+ assertThat(getString("type")).isEqualTo("androidx.savedstate.G")
+ getSavedState("value").read {
+ assertThat(size()).isEqualTo(1)
+ assertThat(getInt("i")).isEqualTo(3)
+ }
+ }
}
@Test
@@ -464,19 +496,24 @@
putSavedState("ss", savedState { putString("s", "bar") })
}
)
- .encodeDecode {
- assertThat(size()).isEqualTo(1)
- getSavedState("s").read {
- assertThat(size()).isEqualTo(4)
- assertThat(getInt("i")).isEqualTo(1)
- assertThat(getString("s")).isEqualTo("foo")
- assertThat(getIntArray("a")).isEqualTo(intArrayOf(1, 3, 5))
- getSavedState("ss").read {
- assertThat(size()).isEqualTo(1)
- assertThat(getString("s")).isEqualTo("bar")
+ .encodeDecode(
+ checkDecoded = { decoded, original ->
+ assertThat(decoded.s.read { contentDeepEquals(original.s) })
+ },
+ checkEncoded = {
+ assertThat(size()).isEqualTo(1)
+ getSavedState("s").read {
+ assertThat(size()).isEqualTo(4)
+ assertThat(getInt("i")).isEqualTo(1)
+ assertThat(getString("s")).isEqualTo("foo")
+ assertThat(getIntArray("a")).isEqualTo(intArrayOf(1, 3, 5))
+ getSavedState("ss").read {
+ assertThat(size()).isEqualTo(1)
+ assertThat(getString("s")).isEqualTo("bar")
+ }
}
}
- }
+ )
val origin = savedState {
putInt("i", 1)
@@ -563,6 +600,7 @@
private typealias MyNestedTypeAlias = MyTypeAliasToInt
+@Serializable
private sealed class Node {
@Serializable data class Add(val lhs: Operand, val rhs: Operand) : Node()
@@ -605,3 +643,10 @@
return MyColor(array[0], array[1], array[2])
}
}
+
+@Serializable
+private sealed class F {
+ val s: String? = null
+}
+
+@Serializable private data class G(val i: Int) : F()
diff --git a/savedstate/savedstate/src/commonTest/kotlin/androidx/savedstate/SavedStateCodecTestUtils.kt b/savedstate/savedstate/src/commonTest/kotlin/androidx/savedstate/SavedStateCodecTestUtils.kt
index e9ac161..67930cd 100644
--- a/savedstate/savedstate/src/commonTest/kotlin/androidx/savedstate/SavedStateCodecTestUtils.kt
+++ b/savedstate/savedstate/src/commonTest/kotlin/androidx/savedstate/SavedStateCodecTestUtils.kt
@@ -23,16 +23,30 @@
import kotlinx.serialization.serializer
internal object SavedStateCodecTestUtils {
+ /* Test the following steps: 1. encode `T` to a `SavedState`, 2. parcelize it to a `Parcel`,
+ * 3. un-parcelize it back to a `SavedState`, and 4. decode it back to a `T`. Step 2 and 3
+ * are only performed on Android. Here's the whole process:
+ *
+ * (A)Serializable -1-> (B)SavedState -2-> (C)Parcel -3-> (D)SavedState -4-> (E)Serializable
+ *
+ * `checkEncoded` can be used to check the content of "B", and `checkDecoded` can be
+ * used to compare the instances of "E" and "A".
+ */
inline fun <reified T : Any> T.encodeDecode(
serializer: KSerializer<T> = serializer<T>(),
- checkContent: SavedStateReader.() -> Unit = { assertThat(size()).isEqualTo(0) }
+ checkDecoded: (T, T) -> Unit = { decoded, original ->
+ assertThat(decoded).isEqualTo(original)
+ },
+ checkEncoded: SavedStateReader.() -> Unit = { assertThat(size()).isEqualTo(0) }
) {
- assertThat(
- decodeFromSavedState(
- serializer,
- encodeToSavedState(serializer, this).apply { read { checkContent() } }
- )
- )
- .isEqualTo(this)
+ val encoded = encodeToSavedState(serializer, this)
+ encoded.read { checkEncoded() }
+
+ val restored = platformEncodeDecode(encoded)
+
+ val decoded = decodeFromSavedState(serializer, restored)
+ checkDecoded(decoded, this)
}
}
+
+expect fun platformEncodeDecode(savedState: SavedState): SavedState
diff --git a/savedstate/savedstate/src/nonAndroidTest/kotlin/androidx/savedstate/SavedStateCodecTestUtils.nonAndroid.kt b/savedstate/savedstate/src/nonAndroidTest/kotlin/androidx/savedstate/SavedStateCodecTestUtils.nonAndroid.kt
new file mode 100644
index 0000000..023701a8
--- /dev/null
+++ b/savedstate/savedstate/src/nonAndroidTest/kotlin/androidx/savedstate/SavedStateCodecTestUtils.nonAndroid.kt
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2025 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.savedstate
+
+// No parceling in non-Android platforms.
+actual fun platformEncodeDecode(savedState: SavedState): SavedState = savedState
diff --git a/test/uiautomator/uiautomator/api/current.txt b/test/uiautomator/uiautomator/api/current.txt
index 718c737..6b48582 100644
--- a/test/uiautomator/uiautomator/api/current.txt
+++ b/test/uiautomator/uiautomator/api/current.txt
@@ -99,6 +99,7 @@
public final class Configurator {
method public long getActionAcknowledgmentTimeout();
+ method public int getDefaultDisplayId();
method public static androidx.test.uiautomator.Configurator getInstance();
method @Deprecated public long getKeyInjectionDelay();
method public long getScrollAcknowledgmentTimeout();
@@ -106,7 +107,9 @@
method public int getUiAutomationFlags();
method public long getWaitForIdleTimeout();
method public long getWaitForSelectorTimeout();
+ method public androidx.test.uiautomator.Configurator resetDefaultDisplayId();
method public androidx.test.uiautomator.Configurator setActionAcknowledgmentTimeout(long);
+ method public androidx.test.uiautomator.Configurator setDefaultDisplayId(int);
method @Deprecated public androidx.test.uiautomator.Configurator setKeyInjectionDelay(long);
method public androidx.test.uiautomator.Configurator setScrollAcknowledgmentTimeout(long);
method public androidx.test.uiautomator.Configurator setToolType(int);
diff --git a/test/uiautomator/uiautomator/api/restricted_current.txt b/test/uiautomator/uiautomator/api/restricted_current.txt
index 718c737..6b48582 100644
--- a/test/uiautomator/uiautomator/api/restricted_current.txt
+++ b/test/uiautomator/uiautomator/api/restricted_current.txt
@@ -99,6 +99,7 @@
public final class Configurator {
method public long getActionAcknowledgmentTimeout();
+ method public int getDefaultDisplayId();
method public static androidx.test.uiautomator.Configurator getInstance();
method @Deprecated public long getKeyInjectionDelay();
method public long getScrollAcknowledgmentTimeout();
@@ -106,7 +107,9 @@
method public int getUiAutomationFlags();
method public long getWaitForIdleTimeout();
method public long getWaitForSelectorTimeout();
+ method public androidx.test.uiautomator.Configurator resetDefaultDisplayId();
method public androidx.test.uiautomator.Configurator setActionAcknowledgmentTimeout(long);
+ method public androidx.test.uiautomator.Configurator setDefaultDisplayId(int);
method @Deprecated public androidx.test.uiautomator.Configurator setKeyInjectionDelay(long);
method public androidx.test.uiautomator.Configurator setScrollAcknowledgmentTimeout(long);
method public androidx.test.uiautomator.Configurator setToolType(int);
diff --git a/test/uiautomator/uiautomator/src/main/java/androidx/test/uiautomator/BySelector.java b/test/uiautomator/uiautomator/src/main/java/androidx/test/uiautomator/BySelector.java
index 04055d4..9fa089f 100644
--- a/test/uiautomator/uiautomator/src/main/java/androidx/test/uiautomator/BySelector.java
+++ b/test/uiautomator/uiautomator/src/main/java/androidx/test/uiautomator/BySelector.java
@@ -16,6 +16,8 @@
package androidx.test.uiautomator;
+import static android.view.Display.INVALID_DISPLAY;
+
import static java.util.Objects.requireNonNull;
import androidx.annotation.IntRange;
@@ -69,7 +71,10 @@
/** Clients should not instanciate this class directly. Use the {@link By} factory class instead. */
- BySelector() { }
+ BySelector() {
+ final int defaultDisplayId = Configurator.getInstance().getDefaultDisplayId();
+ mDisplayId = defaultDisplayId == INVALID_DISPLAY ? null : defaultDisplayId;
+ }
/**
* Constructs a new {@link BySelector} and copies the criteria from {@code original}.
diff --git a/test/uiautomator/uiautomator/src/main/java/androidx/test/uiautomator/Configurator.java b/test/uiautomator/uiautomator/src/main/java/androidx/test/uiautomator/Configurator.java
index c8fd7e9..a2e2aec 100644
--- a/test/uiautomator/uiautomator/src/main/java/androidx/test/uiautomator/Configurator.java
+++ b/test/uiautomator/uiautomator/src/main/java/androidx/test/uiautomator/Configurator.java
@@ -16,6 +16,8 @@
package androidx.test.uiautomator;
+import static android.view.Display.INVALID_DISPLAY;
+
import android.view.MotionEvent;
import org.jspecify.annotations.NonNull;
@@ -48,6 +50,9 @@
static final int DEFAULT_UIAUTOMATION_FLAGS = 0;
private int mUiAutomationFlags = DEFAULT_UIAUTOMATION_FLAGS;
+ // Default display ID when obtaining a BySelector instance
+ private int mDefaultDisplayId = INVALID_DISPLAY;
+
// Singleton instance.
private static Configurator sConfigurator;
@@ -242,4 +247,39 @@
public int getUiAutomationFlags() {
return mUiAutomationFlags;
}
+
+ /**
+ * Sets the default display ID to use when obtaining a
+ * {@link androidx.test.uiautomator.BySelector} instance. To avoid interfering with other tests,
+ * the caller must call {@link #resetDefaultDisplayId} when the test is finished.
+ *
+ * @param defaultDisplayId the default display ID to use
+ * @return self
+ */
+ public @NonNull Configurator setDefaultDisplayId(int defaultDisplayId) {
+ mDefaultDisplayId = defaultDisplayId;
+ return this;
+ }
+
+ /**
+ * Resets the default display ID to use when obtaining a
+ * {@link androidx.test.uiautomator.BySelector} instance.
+ *
+ * @return self
+ */
+ public @NonNull Configurator resetDefaultDisplayId() {
+ mDefaultDisplayId = INVALID_DISPLAY;
+ return this;
+ }
+
+ /**
+ * Gets the default display ID to use when obtaining a
+ * {@link androidx.test.uiautomator.BySelector} instance, or returns
+ * {@link android.view.Display#INVALID_DISPLAY} if the default display ID is not set yet.
+ *
+ * @return the default display ID
+ */
+ public int getDefaultDisplayId() {
+ return mDefaultDisplayId;
+ }
}
diff --git a/wear/compose/compose-material3/api/current.txt b/wear/compose/compose-material3/api/current.txt
index 4e9ba65..df002aa 100644
--- a/wear/compose/compose-material3/api/current.txt
+++ b/wear/compose/compose-material3/api/current.txt
@@ -166,9 +166,10 @@
method @androidx.compose.runtime.Composable public static void ButtonGroup(optional androidx.compose.ui.Modifier modifier, optional float spacing, optional float expansionWidth, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.ui.Alignment.Vertical verticalAlignment, kotlin.jvm.functions.Function1<? super androidx.wear.compose.material3.ButtonGroupScope,kotlin.Unit> content);
}
- public final class ButtonGroupScope {
- ctor public ButtonGroupScope();
- method public boolean buttonGroupItem(androidx.compose.foundation.interaction.InteractionSource interactionSource, optional float minWidth, optional float weight, kotlin.jvm.functions.Function0<kotlin.Unit> content);
+ public interface ButtonGroupScope {
+ method public androidx.compose.ui.Modifier enlargeOnPress(androidx.compose.ui.Modifier, androidx.compose.foundation.interaction.MutableInteractionSource interactionSource);
+ method public androidx.compose.ui.Modifier minWidth(androidx.compose.ui.Modifier, optional float minWidth);
+ method public androidx.compose.ui.Modifier weight(androidx.compose.ui.Modifier, @FloatRange(from=0.0, fromInclusive=false) float weight);
}
public final class ButtonKt {
@@ -477,7 +478,7 @@
}
public final class DatePickerKt {
- method @androidx.compose.runtime.Composable public static void DatePicker(java.time.LocalDate initialDate, kotlin.jvm.functions.Function1<? super java.time.LocalDate,kotlin.Unit> onDatePicked, optional androidx.compose.ui.Modifier modifier, optional java.time.LocalDate? minValidDate, optional java.time.LocalDate? maxValidDate, optional int datePickerType, optional androidx.wear.compose.material3.DatePickerColors colors);
+ method @RequiresApi(android.os.Build.VERSION_CODES.O) @androidx.compose.runtime.Composable public static void DatePicker(java.time.LocalDate initialDate, kotlin.jvm.functions.Function1<? super java.time.LocalDate,kotlin.Unit> onDatePicked, optional androidx.compose.ui.Modifier modifier, optional java.time.LocalDate? minValidDate, optional java.time.LocalDate? maxValidDate, optional int datePickerType, optional androidx.wear.compose.material3.DatePickerColors colors);
}
@androidx.compose.runtime.Immutable @kotlin.jvm.JvmInline public final value class DatePickerType {
@@ -1626,7 +1627,7 @@
}
public final class TimePickerKt {
- method @androidx.compose.runtime.Composable public static void TimePicker(java.time.LocalTime initialTime, kotlin.jvm.functions.Function1<? super java.time.LocalTime,kotlin.Unit> onTimePicked, optional androidx.compose.ui.Modifier modifier, optional int timePickerType, optional androidx.wear.compose.material3.TimePickerColors colors);
+ method @RequiresApi(android.os.Build.VERSION_CODES.O) @androidx.compose.runtime.Composable public static void TimePicker(java.time.LocalTime initialTime, kotlin.jvm.functions.Function1<? super java.time.LocalTime,kotlin.Unit> onTimePicked, optional androidx.compose.ui.Modifier modifier, optional int timePickerType, optional androidx.wear.compose.material3.TimePickerColors colors);
}
@androidx.compose.runtime.Immutable @kotlin.jvm.JvmInline public final value class TimePickerType {
diff --git a/wear/compose/compose-material3/api/restricted_current.txt b/wear/compose/compose-material3/api/restricted_current.txt
index 4e9ba65..df002aa 100644
--- a/wear/compose/compose-material3/api/restricted_current.txt
+++ b/wear/compose/compose-material3/api/restricted_current.txt
@@ -166,9 +166,10 @@
method @androidx.compose.runtime.Composable public static void ButtonGroup(optional androidx.compose.ui.Modifier modifier, optional float spacing, optional float expansionWidth, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.ui.Alignment.Vertical verticalAlignment, kotlin.jvm.functions.Function1<? super androidx.wear.compose.material3.ButtonGroupScope,kotlin.Unit> content);
}
- public final class ButtonGroupScope {
- ctor public ButtonGroupScope();
- method public boolean buttonGroupItem(androidx.compose.foundation.interaction.InteractionSource interactionSource, optional float minWidth, optional float weight, kotlin.jvm.functions.Function0<kotlin.Unit> content);
+ public interface ButtonGroupScope {
+ method public androidx.compose.ui.Modifier enlargeOnPress(androidx.compose.ui.Modifier, androidx.compose.foundation.interaction.MutableInteractionSource interactionSource);
+ method public androidx.compose.ui.Modifier minWidth(androidx.compose.ui.Modifier, optional float minWidth);
+ method public androidx.compose.ui.Modifier weight(androidx.compose.ui.Modifier, @FloatRange(from=0.0, fromInclusive=false) float weight);
}
public final class ButtonKt {
@@ -477,7 +478,7 @@
}
public final class DatePickerKt {
- method @androidx.compose.runtime.Composable public static void DatePicker(java.time.LocalDate initialDate, kotlin.jvm.functions.Function1<? super java.time.LocalDate,kotlin.Unit> onDatePicked, optional androidx.compose.ui.Modifier modifier, optional java.time.LocalDate? minValidDate, optional java.time.LocalDate? maxValidDate, optional int datePickerType, optional androidx.wear.compose.material3.DatePickerColors colors);
+ method @RequiresApi(android.os.Build.VERSION_CODES.O) @androidx.compose.runtime.Composable public static void DatePicker(java.time.LocalDate initialDate, kotlin.jvm.functions.Function1<? super java.time.LocalDate,kotlin.Unit> onDatePicked, optional androidx.compose.ui.Modifier modifier, optional java.time.LocalDate? minValidDate, optional java.time.LocalDate? maxValidDate, optional int datePickerType, optional androidx.wear.compose.material3.DatePickerColors colors);
}
@androidx.compose.runtime.Immutable @kotlin.jvm.JvmInline public final value class DatePickerType {
@@ -1626,7 +1627,7 @@
}
public final class TimePickerKt {
- method @androidx.compose.runtime.Composable public static void TimePicker(java.time.LocalTime initialTime, kotlin.jvm.functions.Function1<? super java.time.LocalTime,kotlin.Unit> onTimePicked, optional androidx.compose.ui.Modifier modifier, optional int timePickerType, optional androidx.wear.compose.material3.TimePickerColors colors);
+ method @RequiresApi(android.os.Build.VERSION_CODES.O) @androidx.compose.runtime.Composable public static void TimePicker(java.time.LocalTime initialTime, kotlin.jvm.functions.Function1<? super java.time.LocalTime,kotlin.Unit> onTimePicked, optional androidx.compose.ui.Modifier modifier, optional int timePickerType, optional androidx.wear.compose.material3.TimePickerColors colors);
}
@androidx.compose.runtime.Immutable @kotlin.jvm.JvmInline public final value class TimePickerType {
diff --git a/wear/compose/compose-material3/benchmark/build.gradle b/wear/compose/compose-material3/benchmark/build.gradle
index 9a1b2f5..836aa4e 100644
--- a/wear/compose/compose-material3/benchmark/build.gradle
+++ b/wear/compose/compose-material3/benchmark/build.gradle
@@ -35,7 +35,7 @@
compileSdk = 35
defaultConfig {
- minSdk = 30
+ minSdk = 25
}
buildTypes.configureEach {
consumerProguardFiles "benchmark-proguard-rules.pro"
diff --git a/wear/compose/compose-material3/benchmark/src/androidTest/java/androidx/wear/compose/material3/benchmark/ScrollIndicatorBenchmark.kt b/wear/compose/compose-material3/benchmark/src/androidTest/java/androidx/wear/compose/material3/benchmark/ScrollIndicatorBenchmark.kt
new file mode 100644
index 0000000..de0e838
--- /dev/null
+++ b/wear/compose/compose-material3/benchmark/src/androidTest/java/androidx/wear/compose/material3/benchmark/ScrollIndicatorBenchmark.kt
@@ -0,0 +1,92 @@
+/*
+ * Copyright 2025 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.wear.compose.material3.benchmark
+
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.runtime.Composable
+import androidx.compose.testutils.LayeredComposeTestCase
+import androidx.compose.testutils.benchmark.ComposeBenchmarkRule
+import androidx.compose.testutils.benchmark.benchmarkDrawPerf
+import androidx.compose.testutils.benchmark.benchmarkFirstCompose
+import androidx.compose.testutils.benchmark.benchmarkFirstDraw
+import androidx.compose.testutils.benchmark.benchmarkFirstLayout
+import androidx.compose.testutils.benchmark.benchmarkFirstMeasure
+import androidx.compose.testutils.benchmark.benchmarkLayoutPerf
+import androidx.compose.testutils.benchmark.benchmarkToFirstPixel
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import androidx.wear.compose.material3.MaterialTheme
+import androidx.wear.compose.material3.ScrollIndicator
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@MediumTest
+@RunWith(AndroidJUnit4::class)
+class ScrollIndicatorBenchmark {
+
+ @get:Rule val benchmarkRule = ComposeBenchmarkRule()
+
+ private val testCaseFactory = { ScrollIndicatorTestCase() }
+
+ @Test
+ fun first_pixel() {
+ benchmarkRule.benchmarkToFirstPixel(testCaseFactory)
+ }
+
+ @Test
+ fun first_compose() {
+ benchmarkRule.benchmarkFirstCompose(testCaseFactory)
+ }
+
+ @Test
+ fun first_measure() {
+ benchmarkRule.benchmarkFirstMeasure(testCaseFactory)
+ }
+
+ @Test
+ fun first_layout() {
+ benchmarkRule.benchmarkFirstLayout(testCaseFactory)
+ }
+
+ @Test
+ fun first_draw() {
+ benchmarkRule.benchmarkFirstDraw(testCaseFactory)
+ }
+
+ @Test
+ fun layout() {
+ benchmarkRule.benchmarkLayoutPerf(testCaseFactory)
+ }
+
+ @Test
+ fun draw() {
+ benchmarkRule.benchmarkDrawPerf(testCaseFactory)
+ }
+}
+
+internal class ScrollIndicatorTestCase : LayeredComposeTestCase() {
+ @Composable
+ override fun MeasuredContent() {
+ ScrollIndicator(state = rememberScrollState())
+ }
+
+ @Composable
+ override fun ContentWrappers(content: @Composable () -> Unit) {
+ MaterialTheme { content() }
+ }
+}
diff --git a/wear/compose/compose-material3/build.gradle b/wear/compose/compose-material3/build.gradle
index df4dcfa..f0c49b7 100644
--- a/wear/compose/compose-material3/build.gradle
+++ b/wear/compose/compose-material3/build.gradle
@@ -72,7 +72,7 @@
compileSdk = 35
defaultConfig {
- minSdk = 30
+ minSdk = 25
}
// Use Robolectric 4.+
testOptions.unitTests.includeAndroidResources = true
diff --git a/wear/compose/compose-material3/integration-tests/build.gradle b/wear/compose/compose-material3/integration-tests/build.gradle
index 85e2742..956afb7 100644
--- a/wear/compose/compose-material3/integration-tests/build.gradle
+++ b/wear/compose/compose-material3/integration-tests/build.gradle
@@ -55,7 +55,7 @@
android {
compileSdk = 35
defaultConfig {
- minSdk = 30
+ minSdk = 25
}
namespace = "androidx.wear.compose.material3.demos"
}
diff --git a/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/ButtonGroupDemo.kt b/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/ButtonGroupDemo.kt
index a12ca7e..76c8ba5 100644
--- a/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/ButtonGroupDemo.kt
+++ b/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/ButtonGroupDemo.kt
@@ -18,33 +18,161 @@
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Favorite
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.wear.compose.material3.Button
import androidx.wear.compose.material3.ButtonGroup
+import androidx.wear.compose.material3.ButtonGroupScope
+import androidx.wear.compose.material3.FilledIconButton
+import androidx.wear.compose.material3.Icon
+import androidx.wear.compose.material3.IconToggleButton
+import androidx.wear.compose.material3.IconToggleButtonDefaults
+import androidx.wear.compose.material3.IconToggleButtonShapes
import androidx.wear.compose.material3.Text
+import androidx.wear.compose.material3.TextToggleButton
+import androidx.wear.compose.material3.TextToggleButtonDefaults
+import androidx.wear.compose.material3.TextToggleButtonShapes
+import androidx.wear.compose.material3.samples.icons.WifiOffIcon
+import androidx.wear.compose.material3.samples.icons.WifiOnIcon
@Composable
fun ButtonGroupDemo() {
- val interactionSources = remember { Array(3) { MutableInteractionSource() } }
-
+ val interactionSource1 = remember { MutableInteractionSource() }
+ val interactionSource2 = remember { MutableInteractionSource() }
+ val interactionSource3 = remember { MutableInteractionSource() }
Box(Modifier.size(300.dp), contentAlignment = Alignment.Center) {
ButtonGroup(Modifier.fillMaxWidth()) {
- repeat(3) { index ->
- buttonGroupItem(interactionSource = interactionSources[index]) {
- Button(onClick = {}, interactionSource = interactionSources[index]) {
- Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
- Text(listOf("A", "B", "C")[index])
- }
+ Button(
+ onClick = {},
+ Modifier.enlargeOnPress(interactionSource1),
+ interactionSource = interactionSource1
+ ) {
+ Text("<", modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center)
+ }
+ FilledIconButton(
+ onClick = {},
+ Modifier.enlargeOnPress(interactionSource2),
+ interactionSource = interactionSource2
+ ) {
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Icon(
+ imageVector = Icons.Filled.Favorite,
+ contentDescription = "Favorite icon",
+ modifier = Modifier.size(32.dp)
+ )
+ }
+ }
+ Button(
+ onClick = {},
+ Modifier.enlargeOnPress(interactionSource3),
+ interactionSource = interactionSource3
+ ) {
+ Text(">", modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center)
+ }
+ }
+ }
+}
+
+@Composable
+fun ButtonGroupToggleButtonsDemo() {
+ val iconSize = 32.dp
+ Box(Modifier.size(300.dp), contentAlignment = Alignment.Center) {
+ Column {
+ ButtonGroup(Modifier.fillMaxWidth()) {
+ MyIconToggleButton(IconToggleButtonDefaults.shapes(), Modifier.weight(1.2f)) {
+ Icon(
+ imageVector = Icons.Filled.Favorite,
+ contentDescription = "Favorite icon",
+ modifier = Modifier.size(iconSize)
+ )
+ }
+ MyIconToggleButton(IconToggleButtonDefaults.animatedShapes()) { checked ->
+ if (checked) {
+ WifiOnIcon(Modifier.size(iconSize))
+ } else {
+ WifiOffIcon(Modifier.size(iconSize))
}
}
}
+ Spacer(Modifier.height(8.dp))
+ ButtonGroup(Modifier.fillMaxWidth()) {
+ MyTextToggleButton(TextToggleButtonDefaults.shapes()) { checked ->
+ Text(
+ text = if (checked) "On" else "Off",
+ style = TextToggleButtonDefaults.defaultButtonTextStyle
+ )
+ }
+ MyTextToggleButton(
+ TextToggleButtonDefaults.animatedShapes(),
+ Modifier.weight(1.2f)
+ ) { checked ->
+ Text(
+ text = if (checked) "On" else "Off",
+ style = TextToggleButtonDefaults.defaultButtonTextStyle
+ )
+ }
+ }
}
}
}
+
+@Composable
+private fun ButtonGroupScope.MyIconToggleButton(
+ shapes: IconToggleButtonShapes,
+ modifier: Modifier = Modifier,
+ content: @Composable (Boolean) -> Unit
+) {
+ val interactionSource = remember { MutableInteractionSource() }
+ var checked by remember { mutableStateOf(false) }
+ IconToggleButton(
+ checked = checked,
+ modifier =
+ modifier
+ .height(IconToggleButtonDefaults.SmallButtonSize)
+ .fillMaxWidth()
+ .enlargeOnPress(interactionSource),
+ onCheckedChange = { checked = !checked },
+ shapes = shapes,
+ interactionSource = interactionSource
+ ) {
+ content(checked)
+ }
+}
+
+@Composable
+private fun ButtonGroupScope.MyTextToggleButton(
+ shapes: TextToggleButtonShapes,
+ modifier: Modifier = Modifier,
+ content: @Composable (Boolean) -> Unit
+) {
+ val interactionSource = remember { MutableInteractionSource() }
+ var checked by remember { mutableStateOf(false) }
+ TextToggleButton(
+ checked = checked,
+ modifier =
+ modifier
+ .height(TextToggleButtonDefaults.DefaultButtonSize)
+ .fillMaxWidth()
+ .enlargeOnPress(interactionSource),
+ onCheckedChange = { checked = !checked },
+ shapes = shapes,
+ interactionSource = interactionSource
+ ) {
+ content(checked)
+ }
+}
diff --git a/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/DatePickerDemo.kt b/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/DatePickerDemo.kt
index a80f168..2e020d7 100644
--- a/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/DatePickerDemo.kt
+++ b/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/DatePickerDemo.kt
@@ -16,6 +16,8 @@
package androidx.wear.compose.material3.demos
+import android.os.Build
+import androidx.annotation.RequiresApi
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.icons.Icons
@@ -41,6 +43,7 @@
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
+@RequiresApi(Build.VERSION_CODES.O)
val DatePickerDemos =
listOf(
ComposableDemo("Date Year-Month-Day") { DatePickerYearMonthDaySample() },
@@ -51,6 +54,7 @@
ComposableDemo("Past only") { DatePickerPastOnlyDemo() },
)
+@RequiresApi(Build.VERSION_CODES.O)
@Composable
fun DatePickerDemo(datePickerType: DatePickerType) {
var showDatePicker by remember { mutableStateOf(true) }
@@ -85,6 +89,7 @@
}
}
+@RequiresApi(Build.VERSION_CODES.O)
@Composable
fun DatePickerPastOnlyDemo() {
val currentDate = LocalDate.now()
diff --git a/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/TimePickerDemo.kt b/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/TimePickerDemo.kt
index 7a1f55b..20d4ca7 100644
--- a/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/TimePickerDemo.kt
+++ b/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/TimePickerDemo.kt
@@ -16,6 +16,8 @@
package androidx.wear.compose.material3.demos
+import android.os.Build
+import androidx.annotation.RequiresApi
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.icons.Icons
@@ -39,6 +41,7 @@
import java.time.LocalTime
import java.time.format.DateTimeFormatter
+@RequiresApi(Build.VERSION_CODES.O)
val TimePickerDemos =
listOf(
ComposableDemo("Time HH:MM:SS") { TimePickerWithSecondsSample() },
@@ -47,6 +50,7 @@
ComposableDemo("Time System time format") { TimePickerSample() },
)
+@RequiresApi(Build.VERSION_CODES.O)
@Composable
private fun TimePicker24hWithoutSecondsDemo() {
var showTimePicker by remember { mutableStateOf(true) }
diff --git a/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/TransformingLazyColumnDemo.kt b/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/TransformingLazyColumnDemo.kt
index 4523210..4b6646b 100644
--- a/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/TransformingLazyColumnDemo.kt
+++ b/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/TransformingLazyColumnDemo.kt
@@ -154,21 +154,25 @@
}
item {
TransformExclusion {
- val interactionSourceLeft = remember { MutableInteractionSource() }
- val interactionSourceRight = remember { MutableInteractionSource() }
+ val interactionSource1 = remember { MutableInteractionSource() }
+ val interactionSource2 = remember { MutableInteractionSource() }
ButtonGroup(Modifier.scrollTransform(this@item)) {
- buttonGroupItem(interactionSource = interactionSourceLeft) {
- Button(onClick = {}, interactionSource = interactionSourceLeft) {
- Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
- Text("L")
- }
+ Button(
+ onClick = {},
+ Modifier.enlargeOnPress(interactionSource1),
+ interactionSource = interactionSource1
+ ) {
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Text("L")
}
}
- buttonGroupItem(interactionSource = interactionSourceRight) {
- Button(onClick = {}, interactionSource = interactionSourceRight) {
- Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
- Text("R")
- }
+ Button(
+ onClick = {},
+ Modifier.enlargeOnPress(interactionSource2),
+ interactionSource = interactionSource2
+ ) {
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Text("R")
}
}
}
diff --git a/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/WearMaterial3Demos.kt b/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/WearMaterial3Demos.kt
index 8a93dff..11844f9 100644
--- a/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/WearMaterial3Demos.kt
+++ b/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/WearMaterial3Demos.kt
@@ -26,6 +26,7 @@
import androidx.wear.compose.material3.samples.AnimatedTextSampleButtonResponse
import androidx.wear.compose.material3.samples.AnimatedTextSampleSharedFontRegistry
import androidx.wear.compose.material3.samples.ButtonGroupSample
+import androidx.wear.compose.material3.samples.ButtonGroupThreeButtonsSample
import androidx.wear.compose.material3.samples.EdgeButtonListSample
import androidx.wear.compose.material3.samples.EdgeButtonSample
import androidx.wear.compose.material3.samples.EdgeSwipeForSwipeToDismiss
@@ -113,7 +114,9 @@
"Button Group",
listOf(
ComposableDemo("Two buttons") { ButtonGroupSample() },
- ComposableDemo("Three buttons") { ButtonGroupDemo() },
+ ComposableDemo("ABC") { ButtonGroupThreeButtonsSample() },
+ ComposableDemo("Text And Icon") { ButtonGroupDemo() },
+ ComposableDemo("ToggleButtons") { ButtonGroupToggleButtonsDemo() },
)
),
ComposableDemo("List Header") { Centralize { ListHeaderSample() } },
@@ -138,8 +141,13 @@
Material3DemoCategory("Stepper", StepperDemos),
Material3DemoCategory("Slider", SliderDemos),
Material3DemoCategory("Picker", PickerDemos),
- Material3DemoCategory("TimePicker", TimePickerDemos),
- Material3DemoCategory("DatePicker", DatePickerDemos),
+ // Requires API level 26 or higher due to java.time dependency.
+ *(if (Build.VERSION.SDK_INT >= 26)
+ arrayOf(
+ Material3DemoCategory("TimePicker", TimePickerDemos),
+ Material3DemoCategory("DatePicker", DatePickerDemos)
+ )
+ else emptyArray<Material3DemoCategory>()),
Material3DemoCategory("Progress Indicator", ProgressIndicatorDemos),
Material3DemoCategory("Scroll Indicator", ScrollIndicatorDemos),
Material3DemoCategory("Placeholder", PlaceholderDemos),
diff --git a/wear/compose/compose-material3/macrobenchmark-common/build.gradle b/wear/compose/compose-material3/macrobenchmark-common/build.gradle
index 51008d8..92093e3 100644
--- a/wear/compose/compose-material3/macrobenchmark-common/build.gradle
+++ b/wear/compose/compose-material3/macrobenchmark-common/build.gradle
@@ -10,7 +10,7 @@
compileSdk = 35
defaultConfig {
- minSdk = 30
+ minSdk = 25
}
buildTypes.configureEach {
diff --git a/wear/compose/compose-material3/macrobenchmark-common/src/main/java/androidx/wear/compose/material3/macrobenchmark/common/ButtonGroupBenchmark.kt b/wear/compose/compose-material3/macrobenchmark-common/src/main/java/androidx/wear/compose/material3/macrobenchmark/common/ButtonGroupBenchmark.kt
index bfbc1a9..2d5a664 100644
--- a/wear/compose/compose-material3/macrobenchmark-common/src/main/java/androidx/wear/compose/material3/macrobenchmark/common/ButtonGroupBenchmark.kt
+++ b/wear/compose/compose-material3/macrobenchmark-common/src/main/java/androidx/wear/compose/material3/macrobenchmark/common/ButtonGroupBenchmark.kt
@@ -18,14 +18,12 @@
import android.os.SystemClock
import androidx.benchmark.macro.MacrobenchmarkScope
-import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
@@ -40,30 +38,22 @@
object ButtonGroupBenchmark : MacrobenchmarkScreen {
override val content: @Composable (BoxScope.() -> Unit)
get() = {
- val interactionSourceLeft = remember { MutableInteractionSource() }
- val interactionSourceRight = remember { MutableInteractionSource() }
Box(Modifier.size(300.dp), contentAlignment = Alignment.Center) {
ButtonGroup(Modifier.fillMaxWidth()) {
- buttonGroupItem(interactionSource = interactionSourceLeft) {
- Button(
- modifier = Modifier.semantics { contentDescription = LEFT_BUTTON },
- onClick = {},
- interactionSource = interactionSourceLeft
- ) {
- Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
- Text("Left")
- }
+ Button(
+ modifier = Modifier.semantics { contentDescription = LEFT_BUTTON },
+ onClick = {},
+ ) {
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Text("Left")
}
}
- buttonGroupItem(interactionSource = interactionSourceRight) {
- Button(
- modifier = Modifier.semantics { contentDescription = RIGHT_BUTTON },
- onClick = {},
- interactionSource = interactionSourceRight
- ) {
- Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
- Text("Right")
- }
+ Button(
+ modifier = Modifier.semantics { contentDescription = RIGHT_BUTTON },
+ onClick = {},
+ ) {
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Text("Right")
}
}
}
diff --git a/wear/compose/compose-material3/macrobenchmark-common/src/main/java/androidx/wear/compose/material3/macrobenchmark/common/DatePickerBenchmark.kt b/wear/compose/compose-material3/macrobenchmark-common/src/main/java/androidx/wear/compose/material3/macrobenchmark/common/DatePickerBenchmark.kt
index 3177504..51431d0 100644
--- a/wear/compose/compose-material3/macrobenchmark-common/src/main/java/androidx/wear/compose/material3/macrobenchmark/common/DatePickerBenchmark.kt
+++ b/wear/compose/compose-material3/macrobenchmark-common/src/main/java/androidx/wear/compose/material3/macrobenchmark/common/DatePickerBenchmark.kt
@@ -16,7 +16,9 @@
package androidx.wear.compose.material3.macrobenchmark.common
+import android.os.Build
import android.os.SystemClock
+import androidx.annotation.RequiresApi
import androidx.benchmark.macro.MacrobenchmarkScope
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.material.icons.Icons
@@ -41,6 +43,7 @@
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
+@RequiresApi(Build.VERSION_CODES.O)
object DatePickerBenchmark : MacrobenchmarkScreen {
override val content: @Composable (BoxScope.() -> Unit)
get() = {
diff --git a/wear/compose/compose-material3/macrobenchmark-common/src/main/java/androidx/wear/compose/material3/macrobenchmark/common/TimePickerBenchmark.kt b/wear/compose/compose-material3/macrobenchmark-common/src/main/java/androidx/wear/compose/material3/macrobenchmark/common/TimePickerBenchmark.kt
index 1c9103b..7d15ce7 100644
--- a/wear/compose/compose-material3/macrobenchmark-common/src/main/java/androidx/wear/compose/material3/macrobenchmark/common/TimePickerBenchmark.kt
+++ b/wear/compose/compose-material3/macrobenchmark-common/src/main/java/androidx/wear/compose/material3/macrobenchmark/common/TimePickerBenchmark.kt
@@ -16,7 +16,9 @@
package androidx.wear.compose.material3.macrobenchmark.common
+import android.os.Build
import android.os.SystemClock
+import androidx.annotation.RequiresApi
import androidx.benchmark.macro.MacrobenchmarkScope
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.runtime.Composable
@@ -24,6 +26,7 @@
import androidx.wear.compose.material3.TimePickerType
import java.time.LocalTime
+@RequiresApi(Build.VERSION_CODES.O)
object TimePickerBenchmark : MacrobenchmarkScreen {
override val content: @Composable (BoxScope.() -> Unit)
get() = {
diff --git a/wear/compose/compose-material3/macrobenchmark-common/src/main/java/androidx/wear/compose/material3/macrobenchmark/common/baselineprofile/DatePickerScreen.kt b/wear/compose/compose-material3/macrobenchmark-common/src/main/java/androidx/wear/compose/material3/macrobenchmark/common/baselineprofile/DatePickerScreen.kt
index 8e3685d1..04e9ca75 100644
--- a/wear/compose/compose-material3/macrobenchmark-common/src/main/java/androidx/wear/compose/material3/macrobenchmark/common/baselineprofile/DatePickerScreen.kt
+++ b/wear/compose/compose-material3/macrobenchmark-common/src/main/java/androidx/wear/compose/material3/macrobenchmark/common/baselineprofile/DatePickerScreen.kt
@@ -16,6 +16,7 @@
package androidx.wear.compose.material3.macrobenchmark.common.baselineprofile
+import android.os.Build
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.runtime.Composable
import androidx.wear.compose.material3.DatePicker
@@ -27,14 +28,16 @@
object : MacrobenchmarkScreen {
override val content: @Composable BoxScope.() -> Unit
get() = {
- val minDate = LocalDate.of(2022, 10, 15)
- val maxDate = LocalDate.of(2025, 2, 4)
- DatePicker(
- initialDate = LocalDate.of(2024, 9, 2),
- onDatePicked = {},
- minValidDate = minDate,
- maxValidDate = maxDate,
- datePickerType = DatePickerType.YearMonthDay
- )
+ if (Build.VERSION.SDK_INT >= 26) {
+ val minDate = LocalDate.of(2022, 10, 15)
+ val maxDate = LocalDate.of(2025, 2, 4)
+ DatePicker(
+ initialDate = LocalDate.of(2024, 9, 2),
+ onDatePicked = {},
+ minValidDate = minDate,
+ maxValidDate = maxDate,
+ datePickerType = DatePickerType.YearMonthDay
+ )
+ }
}
}
diff --git a/wear/compose/compose-material3/macrobenchmark-target/build.gradle b/wear/compose/compose-material3/macrobenchmark-target/build.gradle
index eaba1e7..82913ef 100644
--- a/wear/compose/compose-material3/macrobenchmark-target/build.gradle
+++ b/wear/compose/compose-material3/macrobenchmark-target/build.gradle
@@ -46,4 +46,4 @@
implementation(project(":wear:compose:compose-material3-macrobenchmark-common"))
}
-android.defaultConfig.minSdk = 30
+android.defaultConfig.minSdk = 25
diff --git a/wear/compose/compose-material3/macrobenchmark-target/src/main/AndroidManifest.xml b/wear/compose/compose-material3/macrobenchmark-target/src/main/AndroidManifest.xml
index a68fd42..6ec1884 100644
--- a/wear/compose/compose-material3/macrobenchmark-target/src/main/AndroidManifest.xml
+++ b/wear/compose/compose-material3/macrobenchmark-target/src/main/AndroidManifest.xml
@@ -130,7 +130,8 @@
<activity
android:name=".DatePickerActivity"
android:theme="@style/AppTheme"
- android:exported="true">
+ android:exported="true"
+ tools:targetApi="o">
<intent-filter>
<action android:name=
"androidx.wear.compose.material3.macrobenchmark.target.DATE_PICKER_ACTIVITY" />
@@ -269,7 +270,8 @@
<activity
android:name=".TimePickerActivity"
android:theme="@style/AppTheme"
- android:exported="true">
+ android:exported="true"
+ tools:targetApi="o">
<intent-filter>
<action android:name=
"androidx.wear.compose.material3.macrobenchmark.target.TIME_PICKER_ACTIVITY" />
diff --git a/wear/compose/compose-material3/macrobenchmark-target/src/main/java/androidx/wear/compose/material3/macrobenchmark/target/DatePickerActivity.kt b/wear/compose/compose-material3/macrobenchmark-target/src/main/java/androidx/wear/compose/material3/macrobenchmark/target/DatePickerActivity.kt
index 4256fdc..d18d12d 100644
--- a/wear/compose/compose-material3/macrobenchmark-target/src/main/java/androidx/wear/compose/material3/macrobenchmark/target/DatePickerActivity.kt
+++ b/wear/compose/compose-material3/macrobenchmark-target/src/main/java/androidx/wear/compose/material3/macrobenchmark/target/DatePickerActivity.kt
@@ -16,6 +16,9 @@
package androidx.wear.compose.material3.macrobenchmark.target
+import android.os.Build
+import androidx.annotation.RequiresApi
import androidx.wear.compose.material3.macrobenchmark.common.DatePickerBenchmark
+@RequiresApi(Build.VERSION_CODES.O)
class DatePickerActivity : BenchmarkBaseActivity(DatePickerBenchmark)
diff --git a/wear/compose/compose-material3/macrobenchmark-target/src/main/java/androidx/wear/compose/material3/macrobenchmark/target/TimePickerActivity.kt b/wear/compose/compose-material3/macrobenchmark-target/src/main/java/androidx/wear/compose/material3/macrobenchmark/target/TimePickerActivity.kt
index 6188ab7..e134065 100644
--- a/wear/compose/compose-material3/macrobenchmark-target/src/main/java/androidx/wear/compose/material3/macrobenchmark/target/TimePickerActivity.kt
+++ b/wear/compose/compose-material3/macrobenchmark-target/src/main/java/androidx/wear/compose/material3/macrobenchmark/target/TimePickerActivity.kt
@@ -16,6 +16,9 @@
package androidx.wear.compose.material3.macrobenchmark.target
+import android.os.Build
+import androidx.annotation.RequiresApi
import androidx.wear.compose.material3.macrobenchmark.common.TimePickerBenchmark
+@RequiresApi(Build.VERSION_CODES.O)
class TimePickerActivity : BenchmarkBaseActivity(TimePickerBenchmark)
diff --git a/wear/compose/compose-material3/macrobenchmark/build.gradle b/wear/compose/compose-material3/macrobenchmark/build.gradle
index bcab91e..1940fef 100644
--- a/wear/compose/compose-material3/macrobenchmark/build.gradle
+++ b/wear/compose/compose-material3/macrobenchmark/build.gradle
@@ -23,7 +23,7 @@
android {
compileSdk = 35
defaultConfig {
- minSdk = 30
+ minSdk = 29
}
namespace = "androidx.wear.compose.material3.macrobenchmark"
targetProjectPath = ":wear:compose:compose-material3-macrobenchmark-target"
diff --git a/wear/compose/compose-material3/macrobenchmark/src/main/java/androidx/wear/compose/material3/macrobenchmark/DatePickerBenchmarkTest.kt b/wear/compose/compose-material3/macrobenchmark/src/main/java/androidx/wear/compose/material3/macrobenchmark/DatePickerBenchmarkTest.kt
index b9abc42..3316732 100644
--- a/wear/compose/compose-material3/macrobenchmark/src/main/java/androidx/wear/compose/material3/macrobenchmark/DatePickerBenchmarkTest.kt
+++ b/wear/compose/compose-material3/macrobenchmark/src/main/java/androidx/wear/compose/material3/macrobenchmark/DatePickerBenchmarkTest.kt
@@ -16,12 +16,15 @@
package androidx.wear.compose.material3.macrobenchmark
+import android.os.Build
+import androidx.annotation.RequiresApi
import androidx.benchmark.macro.CompilationMode
import androidx.test.filters.LargeTest
import androidx.wear.compose.material3.macrobenchmark.common.DatePickerBenchmark
import org.junit.runner.RunWith
import org.junit.runners.Parameterized
+@RequiresApi(Build.VERSION_CODES.O)
@LargeTest
@RunWith(Parameterized::class)
class DatePickerBenchmarkTest(compilationMode: CompilationMode) :
diff --git a/wear/compose/compose-material3/macrobenchmark/src/main/java/androidx/wear/compose/material3/macrobenchmark/TimePickerBenchmarkTest.kt b/wear/compose/compose-material3/macrobenchmark/src/main/java/androidx/wear/compose/material3/macrobenchmark/TimePickerBenchmarkTest.kt
index 1533377..52a5a38 100644
--- a/wear/compose/compose-material3/macrobenchmark/src/main/java/androidx/wear/compose/material3/macrobenchmark/TimePickerBenchmarkTest.kt
+++ b/wear/compose/compose-material3/macrobenchmark/src/main/java/androidx/wear/compose/material3/macrobenchmark/TimePickerBenchmarkTest.kt
@@ -16,12 +16,15 @@
package androidx.wear.compose.material3.macrobenchmark
+import android.os.Build
+import androidx.annotation.RequiresApi
import androidx.benchmark.macro.CompilationMode
import androidx.test.filters.LargeTest
import androidx.wear.compose.material3.macrobenchmark.common.TimePickerBenchmark
import org.junit.runner.RunWith
import org.junit.runners.Parameterized
+@RequiresApi(Build.VERSION_CODES.O)
@LargeTest
@RunWith(Parameterized::class)
class TimePickerBenchmarkTest(compilationMode: CompilationMode) :
diff --git a/wear/compose/compose-material3/samples/build.gradle b/wear/compose/compose-material3/samples/build.gradle
index 814f3e9..02e43d8 100644
--- a/wear/compose/compose-material3/samples/build.gradle
+++ b/wear/compose/compose-material3/samples/build.gradle
@@ -50,7 +50,7 @@
android {
compileSdk = 35
defaultConfig {
- minSdk = 30
+ minSdk = 25
}
namespace = "androidx.wear.compose.material3.samples"
}
diff --git a/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/ButtonGroupSample.kt b/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/ButtonGroupSample.kt
index a467fe1..9b6d145 100644
--- a/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/ButtonGroupSample.kt
+++ b/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/ButtonGroupSample.kt
@@ -34,19 +34,58 @@
@Sampled
@Composable
fun ButtonGroupSample() {
- val interactionSourceLeft = remember { MutableInteractionSource() }
- val interactionSourceRight = remember { MutableInteractionSource() }
+ val interactionSource1 = remember { MutableInteractionSource() }
+ val interactionSource2 = remember { MutableInteractionSource() }
+
Box(Modifier.size(300.dp), contentAlignment = Alignment.Center) {
ButtonGroup(Modifier.fillMaxWidth()) {
- buttonGroupItem(interactionSource = interactionSourceLeft) {
- Button(onClick = {}, interactionSource = interactionSourceLeft) {
- Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text("L") }
- }
+ Button(
+ onClick = {},
+ modifier = Modifier.enlargeOnPress(interactionSource1),
+ interactionSource = interactionSource1
+ ) {
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text("L") }
}
- buttonGroupItem(interactionSource = interactionSourceRight) {
- Button(onClick = {}, interactionSource = interactionSourceRight) {
- Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text("R") }
- }
+ Button(
+ onClick = {},
+ modifier = Modifier.enlargeOnPress(interactionSource2),
+ interactionSource = interactionSource2
+ ) {
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text("R") }
+ }
+ }
+ }
+}
+
+@Sampled
+@Composable
+fun ButtonGroupThreeButtonsSample() {
+ val interactionSource1 = remember { MutableInteractionSource() }
+ val interactionSource2 = remember { MutableInteractionSource() }
+ val interactionSource3 = remember { MutableInteractionSource() }
+
+ Box(Modifier.size(300.dp), contentAlignment = Alignment.Center) {
+ ButtonGroup(Modifier.fillMaxWidth()) {
+ Button(
+ onClick = {},
+ modifier = Modifier.enlargeOnPress(interactionSource1),
+ interactionSource = interactionSource1
+ ) {
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text("A") }
+ }
+ Button(
+ onClick = {},
+ modifier = Modifier.weight(1.5f).enlargeOnPress(interactionSource2),
+ interactionSource = interactionSource2
+ ) {
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text("B") }
+ }
+ Button(
+ onClick = {},
+ modifier = Modifier.enlargeOnPress(interactionSource3),
+ interactionSource = interactionSource3
+ ) {
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text("C") }
}
}
}
diff --git a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/ButtonGroupScreenshotTest.kt b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/ButtonGroupScreenshotTest.kt
index bfd690a..0b84d2b 100644
--- a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/ButtonGroupScreenshotTest.kt
+++ b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/ButtonGroupScreenshotTest.kt
@@ -18,8 +18,6 @@
import android.os.Build
import androidx.compose.foundation.background
-import androidx.compose.foundation.interaction.MutableInteractionSource
-import androidx.compose.runtime.remember
import androidx.compose.testutils.assertAgainstGolden
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -72,25 +70,25 @@
require(numItems in 1..3)
rule.setContentWithTheme {
ScreenConfiguration(SCREEN_SIZE_SMALL) {
- val interactionSource1 = remember { MutableInteractionSource() }
- val interactionSource2 = remember { MutableInteractionSource() }
- val interactionSource3 = remember { MutableInteractionSource() }
ButtonGroup(
Modifier.testTag(TEST_TAG),
spacing = spacing,
expansionWidth = expansionWidth
) {
- buttonGroupItem(interactionSource1, minWidth1, weight1) {
- Text("A", Modifier.background(Color.Gray))
+ // Modifiers inverted here to check order doesn't matter
+ Text("A", Modifier.background(Color.Gray).weight(weight1).minWidth(minWidth1))
+ if (numItems >= 2) {
+ Text(
+ "B",
+ Modifier.background(Color.Gray).minWidth(minWidth2).weight(weight2)
+ )
}
- if (numItems >= 2)
- buttonGroupItem(interactionSource2, minWidth2, weight2) {
- Text("B", Modifier.background(Color.Gray))
- }
- if (numItems >= 3)
- buttonGroupItem(interactionSource3, minWidth3, weight3) {
- Text("C", Modifier.background(Color.Gray))
- }
+ if (numItems >= 3) {
+ Text(
+ "C",
+ Modifier.background(Color.Gray).minWidth(minWidth3).weight(weight3)
+ )
+ }
}
}
}
diff --git a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/ButtonGroupTest.kt b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/ButtonGroupTest.kt
index b24182d..e28002d 100644
--- a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/ButtonGroupTest.kt
+++ b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/ButtonGroupTest.kt
@@ -16,12 +16,10 @@
package androidx.wear.compose.material3
-import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
-import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.assertWidthIsEqualTo
@@ -38,13 +36,10 @@
@Test
fun supports_testtag() {
rule.setContentWithTheme {
- val interactionSource = remember { MutableInteractionSource() }
ButtonGroup(modifier = Modifier.testTag(TEST_TAG)) {
- buttonGroupItem(interactionSource) {
- Box(
- modifier = Modifier.fillMaxSize(),
- )
- }
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ )
}
}
@@ -53,76 +48,94 @@
@Test
fun two_items_equally_sized_by_default() =
- doTest(
+ verifyWidths(
2,
expectedWidths = { availableSpace -> arrayOf(availableSpace / 2, availableSpace / 2) }
)
@Test
fun two_items_one_double_size() =
- doTest(
+ verifyWidths(
2,
expectedWidths = { availableSpace ->
arrayOf(availableSpace / 3, availableSpace / 3 * 2)
},
- minWidthsAndWeights = arrayOf(50.dp to 1f, 50.dp to 2f)
+ minWidthAndWeights = arrayOf(50.dp to 1f, 50.dp to 2f)
)
@Test
fun respects_min_width() =
- doTest(
+ verifyWidths(
2,
expectedWidths = { availableSpace -> arrayOf(30.dp, availableSpace - 30.dp) },
size = 100.dp,
- minWidthsAndWeights = arrayOf(30.dp to 1f, 30.dp to 10f)
+ minWidthAndWeights = arrayOf(30.dp to 1f, 30.dp to 10f)
)
@Test
fun three_equal_buttons() =
- doTest(3, expectedWidths = { availableSpace -> Array(3) { availableSpace / 3 } })
+ verifyWidths(3, expectedWidths = { availableSpace -> Array(3) { availableSpace / 3 } })
@Test
fun three_buttons_one_two_one() =
- doTest(
+ verifyWidths(
3,
expectedWidths = { availableSpace ->
arrayOf(availableSpace / 4, availableSpace / 2, availableSpace / 4)
},
- minWidthsAndWeights = arrayOf(50.dp to 1f, 50.dp to 2f, 50.dp to 1f)
+ minWidthAndWeights = arrayOf(50.dp to 1f, 50.dp to 2f, 50.dp to 1f)
)
- private fun doTest(
+ @Test
+ fun modifier_order_ignored() {
+ val size = 300.dp
+ rule.setContentWithTheme {
+ ButtonGroup(
+ modifier = Modifier.size(size),
+ contentPadding = PaddingValues(0.dp),
+ spacing = 0.dp
+ ) {
+ Box(Modifier.weight(1f).minWidth(60.dp).testTag("${TEST_TAG}0"))
+ Box(Modifier.minWidth(60.dp).weight(1f).testTag("${TEST_TAG}1"))
+ Box(Modifier.weight(2f).minWidth(60.dp).testTag("${TEST_TAG}2"))
+ Box(Modifier.minWidth(60.dp).weight(2f).testTag("${TEST_TAG}3"))
+ }
+ }
+
+ // Items 0 & 1 should be 60.dp, 2 & 3 should be 90.dp
+ listOf(60.dp, 60.dp, 90.dp, 90.dp).forEachIndexed { index, dp ->
+ rule.onNodeWithTag(TEST_TAG + index.toString()).assertWidthIsEqualTo(dp)
+ }
+ }
+
+ private fun verifyWidths(
numItems: Int,
expectedWidths: (Dp) -> Array<Dp>,
size: Dp = 300.dp,
spacing: Dp = 10.dp,
- minWidthsAndWeights: Array<Pair<Dp, Float>> = Array(numItems) { 48.dp to 1f },
+ minWidthAndWeights: Array<Pair<Dp, Float>> = Array(numItems) { 48.dp to 1f },
) {
val horizontalPadding = 10.dp
val actualExpectedWidths =
expectedWidths(size - horizontalPadding * 2 - spacing * (numItems - 1))
require(numItems == actualExpectedWidths.size)
- require(numItems == minWidthsAndWeights.size)
+ require(numItems == minWidthAndWeights.size)
rule.setContentWithTheme {
- val interactionSources = remember { Array(numItems) { MutableInteractionSource() } }
ButtonGroup(
modifier = Modifier.size(size),
contentPadding = PaddingValues(horizontal = horizontalPadding),
spacing = spacing
) {
repeat(numItems) { ix ->
- buttonGroupItem(
- interactionSources[ix],
- minWidth = minWidthsAndWeights[ix].first,
- weight = minWidthsAndWeights[ix].second
- ) {
- Box(
- modifier =
- Modifier.testTag(TEST_TAG + (ix + 1).toString()).fillMaxSize(),
- )
- }
+ Box(
+ modifier =
+ Modifier.testTag(TEST_TAG + (ix + 1).toString())
+ .fillMaxSize()
+ .weight(minWidthAndWeights[ix].second)
+ .minWidth(minWidthAndWeights[ix].first)
+ )
}
}
}
diff --git a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/ButtonTest.kt b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/ButtonTest.kt
index 722675e..ba35ea0 100644
--- a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/ButtonTest.kt
+++ b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/ButtonTest.kt
@@ -16,16 +16,24 @@
package androidx.wear.compose.material3
+import android.os.Build
+import androidx.annotation.RequiresApi
+import androidx.compose.animation.animateContentSize
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.requiredHeight
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.foundation.shape.CutCornerShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.mutableStateOf
import androidx.compose.testutils.assertContainsColor
import androidx.compose.testutils.assertShape
import androidx.compose.ui.Modifier
@@ -277,6 +285,7 @@
assertEquals(expectedSecondaryTextStyle, actualSecondaryLabelTextStyle)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun default_shape_is_stadium() {
rule.isShape(
@@ -289,6 +298,7 @@
}
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun allows_custom_shape_override() {
val shape = CutCornerShape(4.dp)
@@ -357,6 +367,50 @@
}
@Test
+ fun button_animate_content_size_animates_height() {
+ val boxHeight = mutableStateOf(60.dp)
+ val frames = 14
+ val animationMillis = frames * 16
+ val buttonPadding = ButtonDefaults.ButtonVerticalPadding
+
+ rule.setContentWithTheme {
+ Button(onClick = {}, modifier = Modifier.testTag(TEST_TAG).fillMaxWidth()) {
+ Box(
+ modifier =
+ Modifier.animateContentSize(
+ animationSpec = tween(animationMillis, easing = LinearEasing)
+ )
+ .fillMaxWidth()
+ .requiredHeight(boxHeight.value)
+ ) {}
+ }
+ }
+ // Verify initial height
+ rule.onNodeWithTag(TEST_TAG).assertHeightIsEqualTo(60.dp + buttonPadding * 2)
+
+ // Set autoAdvance off to test the content size animation
+ rule.mainClock.autoAdvance = false
+ boxHeight.value = 100.dp
+ // Advance to the actual start of the animation
+ rule.mainClock.advanceTimeByFrame()
+ rule.mainClock.advanceTimeByFrame()
+
+ // Advance to middle of animation
+ rule.mainClock.advanceTimeBy(animationMillis / 2L)
+ rule.waitForIdle()
+ // Verify that the animation is halfway finished
+ rule
+ .onNodeWithTag(TEST_TAG)
+ .assertHeightIsEqualTo(80.dp + buttonPadding * 2, tolerance = 2.dp)
+
+ // Set autoAdvance back on to finish the animation
+ rule.mainClock.autoAdvance = true
+ rule.waitForIdle()
+ // Verify end height is correct
+ rule.onNodeWithTag(TEST_TAG).assertHeightIsEqualTo(100.dp + buttonPadding * 2)
+ }
+
+ @Test
fun has_icon_in_correct_location_for_three_slot_button_and_label_only() {
val iconTag = "TestIcon"
rule.setContentWithThemeForSizeAssertions(useUnmergedTree = true) {
@@ -376,6 +430,7 @@
.assertTopPositionInRootIsEqualTo((itemBounds.height - iconBounds.height) / 2)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun gives_enabled_base_button_correct_colors() {
rule.verifyButtonColors(
@@ -385,6 +440,7 @@
)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun gives_disabled_base_button_correct_colors() {
rule.verifyButtonColors(
@@ -398,6 +454,7 @@
)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun gives_enabled_filled_tonal_base_button_correct_colors() {
rule.verifyButtonColors(
@@ -408,6 +465,7 @@
)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun gives_disabled_filled_tonal_base_button_correct_colors() {
rule.verifyButtonColors(
@@ -422,6 +480,7 @@
)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun gives_enabled_button_correct_filled_variant_colors() {
rule.verifyButtonColors(
@@ -432,6 +491,7 @@
)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun gives_disabled_button_correct_filled_variant_colors() {
rule.verifyButtonColors(
@@ -446,6 +506,7 @@
)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun gives_enabled_outlined_base_button_correct_colors() {
rule.verifyButtonColors(
@@ -456,6 +517,7 @@
)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun gives_disabled_outlined_base_button_correct_colors() {
rule.verifyButtonColors(
@@ -468,6 +530,7 @@
)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun gives_enabled_child_base_button_correct_colors() {
rule.verifyButtonColors(
@@ -478,6 +541,7 @@
)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun gives_disabled_child_base_button_correct_colors() {
rule.verifyButtonColors(
@@ -490,6 +554,7 @@
)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun gives_enabled_three_slot_button_correct_colors() {
rule.verifyThreeSlotButtonColors(
@@ -499,6 +564,7 @@
)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun gives_disabled_three_slot_button_correct_colors() {
rule.verifyThreeSlotButtonColors(
@@ -508,6 +574,7 @@
)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun gives_enabled_filled_tonal_three_slot_button_correct_colors() {
rule.verifyThreeSlotButtonColors(
@@ -517,6 +584,7 @@
)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun gives_disabled_filled_tonal_three_slot_button_correct_colors() {
rule.verifyThreeSlotButtonColors(
@@ -526,6 +594,7 @@
)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun gives_enabled_outlined_three_slot_button_correct_colors() {
rule.verifyThreeSlotButtonColors(
@@ -535,6 +604,7 @@
)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun gives_disabled_outlined_three_slot_button_correct_colors() {
rule.verifyThreeSlotButtonColors(
@@ -544,6 +614,7 @@
)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun gives_enabled_child_three_slot_button_correct_colors() {
rule.verifyThreeSlotButtonColors(
@@ -553,6 +624,7 @@
)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun gives_disabled_child_three_slot_button_correct_colors() {
rule.verifyThreeSlotButtonColors(
@@ -562,6 +634,7 @@
)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun gives_enabled_outlined_button_correct_border_colors() {
val status = Status.Enabled
@@ -573,6 +646,7 @@
)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun gives_disabled_outlined_button_correct_border_colors() {
val status = Status.Disabled
@@ -594,6 +668,7 @@
)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun overrides_enabled_outlined_button_border_color() {
val status = Status.Enabled
@@ -615,6 +690,7 @@
)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun overrides_disabled_outlined_button_border_color() {
val status = Status.Disabled
@@ -752,6 +828,7 @@
)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun gives_enabled_compact_button_correct_colors() {
rule.verifyCompactButtonColors(
@@ -760,6 +837,7 @@
)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun gives_disabled_compact_button_correct_colors() {
rule.verifyCompactButtonColors(
@@ -768,6 +846,7 @@
)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun gives_enabled_filled_tonal_compact_button_correct_colors() {
rule.verifyCompactButtonColors(
@@ -776,6 +855,7 @@
)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun gives_disabled_filled_tonal_compact_button_correct_colors() {
rule.verifyCompactButtonColors(
@@ -784,6 +864,7 @@
)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun gives_enabled_outlined_compact_button_correct_colors() {
rule.verifyCompactButtonColors(
@@ -792,6 +873,7 @@
)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun gives_disabled_outlined_compact_button_correct_colors() {
rule.verifyCompactButtonColors(
@@ -800,6 +882,7 @@
)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun gives_enabled_child_compact_button_correct_colors() {
rule.verifyCompactButtonColors(
@@ -808,6 +891,7 @@
)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun gives_disabled_child_compact_button_correct_colors() {
rule.verifyCompactButtonColors(
@@ -1228,6 +1312,7 @@
}
}
+@RequiresApi(Build.VERSION_CODES.O)
private fun ComposeContentTestRule.verifyButtonColors(
status: Status,
expectedContainerColor: @Composable () -> Color,
@@ -1317,6 +1402,7 @@
return actualContentColor
}
+@RequiresApi(Build.VERSION_CODES.O)
private fun ComposeContentTestRule.verifyThreeSlotButtonColors(
status: Status,
expectedColor: @Composable () -> ButtonColors,
@@ -1419,6 +1505,7 @@
return ThreeSlotButtonColors(actualLabelColor, actualSecondaryLabelColor, actualIconColor)
}
+@RequiresApi(Build.VERSION_CODES.O)
internal fun ComposeContentTestRule.verifyButtonBorderColor(
expectedBorderColor: @Composable () -> Color,
content: @Composable (Modifier) -> Unit
@@ -1436,6 +1523,7 @@
onNodeWithTag(TEST_TAG).captureToImage().assertContainsColor(finalExpectedBorderColor)
}
+@RequiresApi(Build.VERSION_CODES.O)
private fun ComposeContentTestRule.isShape(
expectedShape: Shape,
colors: @Composable () -> ButtonColors,
@@ -1469,6 +1557,7 @@
)
}
+@RequiresApi(Build.VERSION_CODES.O)
private fun ComposeContentTestRule.verifyCompactButtonColors(
status: Status,
colors: @Composable () -> ButtonColors
diff --git a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/CardTest.kt b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/CardTest.kt
index 62c88b3..98b8213 100644
--- a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/CardTest.kt
+++ b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/CardTest.kt
@@ -16,6 +16,8 @@
package androidx.wear.compose.material3
+import android.os.Build
+import androidx.annotation.RequiresApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
@@ -540,6 +542,7 @@
assertEquals(expectedTitleColor, actualTitleColor)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun outlined_card_has_outlined_border_and_transparent() {
val outlineColor = Color.Red
@@ -563,6 +566,7 @@
.assertColorInPercentageRange(testBackground, 93f..97f)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun outlined_titlecard_has_outlined_border_and_transparent() {
val outlineColor = Color.Red
@@ -588,6 +592,7 @@
.assertColorInPercentageRange(testBackground, 93f..97f)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun outlined_appcard_has_outlined_border_and_transparent() {
val outlineColor = Color.Red
@@ -691,6 +696,7 @@
assertEquals(expectedContentTextStyle, actuaContentTextStyle)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun outlined_app_card_gives_correct_text_style_base() {
var actualAppTextStyle = TextStyle.Default
diff --git a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/CheckboxButtonTest.kt b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/CheckboxButtonTest.kt
index 0c34439..3b4f228 100644
--- a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/CheckboxButtonTest.kt
+++ b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/CheckboxButtonTest.kt
@@ -16,6 +16,8 @@
package androidx.wear.compose.material3
+import android.os.Build
+import androidx.annotation.RequiresApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
@@ -549,6 +551,7 @@
Assert.assertEquals(2, secondaryLabelMaxLines)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun checkbox_button_allows_checked_background_color_override() =
verifyToggleButtonBackgroundColor(
@@ -557,6 +560,7 @@
expectedColor = CHECKED_COLOR
)
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun checkbox_button_allows_unchecked_background_color_override() =
verifyToggleButtonBackgroundColor(
@@ -565,6 +569,7 @@
expectedColor = UNCHECKED_COLOR
)
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun split_checkbox_button_allows_checked_background_color_override() =
verifySplitToggleButtonBackgroundColor(
@@ -573,6 +578,7 @@
expectedColor = CHECKED_COLOR
)
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun split_checkbox_button_allows_unchecked_background_color_override() =
verifySplitToggleButtonBackgroundColor(
@@ -581,46 +587,55 @@
expectedColor = UNCHECKED_COLOR
)
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun verify_checkbox_button_colors_enabled_and_checked() {
rule.verifyCheckboxButtonColors(checked = true, enabled = true)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun verify_checkbox_button_colors_enabled_and_unchecked() {
rule.verifyCheckboxButtonColors(checked = false, enabled = true)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun verify_checkbox_button_colors_disabled_and_checked() {
rule.verifyCheckboxButtonColors(checked = true, enabled = false)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun verify_checkbox_button_colors_disabled_and_unchecked() {
rule.verifyCheckboxButtonColors(checked = false, enabled = false)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun verify_split_checkbox_button_colors_enabled_and_checked() {
rule.verifySplitCheckboxButtonColors(checked = true, enabled = true)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun verify_split_checkbox_button_colors_enabled_and_unchecked() {
rule.verifySplitCheckboxButtonColors(checked = false, enabled = true)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun verify_split_checkbox_button_colors_disabled_and_checked() {
rule.verifySplitCheckboxButtonColors(checked = true, enabled = false)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun verify_split_checkbox_button_colors_disabled_and_unchecked() {
rule.verifySplitCheckboxButtonColors(checked = false, enabled = false)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun checkbox_checked_colors_are_customisable() {
val boxColor = Color.Green
@@ -643,6 +658,7 @@
checkboxImage.assertContainsColor(checkmarkColor)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun checkbox_unchecked_colors_are_customisable() {
// NB checkmark is erased during animation, so we don't test uncheckedCheckmarkColor
@@ -664,6 +680,7 @@
checkboxImage.assertContainsColor(boxColor)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun disabled_checkbox_checked_colors_are_customisable() {
val boxColor = Color.Green
@@ -686,6 +703,7 @@
checkboxImage.assertContainsColor(checkmarkColor)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun disabled_checkbox_unchecked_colors_are_customisable() {
// NB checkmark is erased during animation, so we don't test uncheckedCheckmarkColor
@@ -707,6 +725,7 @@
checkboxImage.assertContainsColor(boxColor)
}
+ @RequiresApi(Build.VERSION_CODES.O)
private fun verifyToggleButtonBackgroundColor(
checked: Boolean,
enabled: Boolean,
@@ -729,6 +748,7 @@
rule.onNodeWithTag(TEST_TAG).captureToImage().assertContainsColor(expectedColor)
}
+ @RequiresApi(Build.VERSION_CODES.O)
private fun verifySplitToggleButtonBackgroundColor(
checked: Boolean,
enabled: Boolean,
@@ -797,6 +817,7 @@
toggleContentDescription = "description",
)
+@RequiresApi(Build.VERSION_CODES.O)
private fun ComposeContentTestRule.verifyCheckboxButtonColors(enabled: Boolean, checked: Boolean) {
val testBackgroundColor = Color.White
var expectedContainerColor = Color.Transparent
@@ -836,6 +857,7 @@
)
}
+@RequiresApi(Build.VERSION_CODES.O)
private fun ComposeContentTestRule.verifySplitCheckboxButtonColors(
enabled: Boolean,
checked: Boolean
diff --git a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/CurvedTextTest.kt b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/CurvedTextTest.kt
index 2d16628..f42a497 100644
--- a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/CurvedTextTest.kt
+++ b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/CurvedTextTest.kt
@@ -16,6 +16,8 @@
package androidx.wear.compose.material3
+import android.os.Build
+import androidx.annotation.RequiresApi
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.testutils.assertContainsColor
import androidx.compose.testutils.assertDoesNotContainColor
@@ -35,6 +37,7 @@
@SmallTest
@RunWith(AndroidJUnit4::class)
+@RequiresApi(Build.VERSION_CODES.O)
class CurvedTextTest {
@get:Rule val rule = createComposeRule()
diff --git a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/DatePickerScreenshotTest.kt b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/DatePickerScreenshotTest.kt
index fba9409..057cf19 100644
--- a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/DatePickerScreenshotTest.kt
+++ b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/DatePickerScreenshotTest.kt
@@ -17,6 +17,7 @@
package androidx.wear.compose.material3
import android.os.Build
+import androidx.annotation.RequiresApi
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.testutils.assertAgainstGolden
@@ -307,6 +308,7 @@
)
.onFirst()
+ @RequiresApi(Build.VERSION_CODES.O)
private fun ComposeContentTestRule.verifyDatePickerScreenshot(
testName: TestName,
screenshotRule: AndroidXScreenshotTestRule,
diff --git a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/IconToggleButtonTest.kt b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/IconToggleButtonTest.kt
index 0d321cd..374863e 100644
--- a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/IconToggleButtonTest.kt
+++ b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/IconToggleButtonTest.kt
@@ -16,6 +16,8 @@
package androidx.wear.compose.material3
+import android.os.Build
+import androidx.annotation.RequiresApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
@@ -239,6 +241,7 @@
rule.onNodeWithTag(TEST_TAG).assertIsOff().performClick().assertIsOff()
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun is_circular_under_ltr() =
rule.isShape(
@@ -255,6 +258,7 @@
)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun is_circular_under_rtl() =
rule.isShape(
@@ -271,6 +275,7 @@
)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun allows_shape_overrides() =
rule.isShape(
@@ -384,6 +389,7 @@
)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun gives_checked_primary_colors() =
rule.verifyIconToggleButtonColors(
@@ -394,6 +400,7 @@
contentColor = { MaterialTheme.colorScheme.onPrimary }
)
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun gives_unchecked_surface_colors() =
rule.verifyIconToggleButtonColors(
@@ -404,6 +411,7 @@
contentColor = { MaterialTheme.colorScheme.onSurfaceVariant }
)
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun gives_disabled_unchecked_surface_colors_with_alpha() =
rule.verifyIconToggleButtonColors(
@@ -416,6 +424,7 @@
contentColor = { MaterialTheme.colorScheme.onSurface.toDisabledColor() }
)
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun gives_disabled_primary_checked_contrasting_content_color() =
rule.verifyIconToggleButtonColors(
@@ -428,6 +437,7 @@
contentColor = { MaterialTheme.colorScheme.onSurface.toDisabledColor() },
)
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun allows_custom_checked_background_override() {
val overrideColor = Color.Yellow
@@ -445,6 +455,7 @@
)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun allows_custom_checked_content_override() {
val overrideColor = Color.Green
@@ -460,6 +471,7 @@
)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun allows_custom_unchecked_background_override() {
val overrideColor = Color.Red
@@ -477,6 +489,7 @@
)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun allows_custom_unchecked_content_override() {
val overrideColor = Color.Green
@@ -494,6 +507,7 @@
)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun allows_custom_checked_disabled_background_override() {
val overrideColor = Color.Yellow
@@ -512,6 +526,7 @@
)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun allows_custom_checked_disabled_content_override() {
val overrideColor = Color.Green
@@ -532,6 +547,7 @@
)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun allows_custom_unchecked_disabled_background_override() {
val overrideColor = Color.Red
@@ -550,6 +566,7 @@
)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun allows_custom_unchecked_disabled_content_override() {
val overrideColor = Color.Green
@@ -606,6 +623,7 @@
.assert(SemanticsMatcher.expectValue(SemanticsProperties.Role, overrideRole))
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun animates_corners_to_75_percent_on_click() {
val uncheckedShape = RoundedCornerShape(20.dp)
@@ -641,6 +659,7 @@
}
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun changes_unchecked_to_checked_shape_on_click() {
val uncheckedShape = RoundedCornerShape(20.dp)
@@ -664,6 +683,7 @@
}
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun changes_checked_to_unchecked_shape_on_click() {
val uncheckedShape = RoundedCornerShape(10.dp)
@@ -688,6 +708,7 @@
}
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun changes_to_unchecked_pressed_shape_when_pressed_on_unchecked() {
val uncheckedShape = RoundedCornerShape(20.dp)
@@ -720,6 +741,7 @@
}
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun changes_to_checked_pressed_shape_when_pressed_on_checked() {
val uncheckedShape = RoundedCornerShape(10.dp)
@@ -752,6 +774,7 @@
}
}
+ @RequiresApi(Build.VERSION_CODES.O)
private fun ComposeContentTestRule.verifyIconToggleButtonColors(
status: Status,
checked: Boolean,
@@ -778,6 +801,7 @@
)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun changes_unchecked_to_checked_shape_when_checked_changed() {
val uncheckedShape = RoundedCornerShape(20.dp)
@@ -802,6 +826,7 @@
)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun changes_checked_to_unchecked_shape_when_checked_changed() {
val uncheckedShape = RoundedCornerShape(20.dp)
@@ -833,6 +858,7 @@
.value
}
+ @RequiresApi(Build.VERSION_CODES.O)
private fun ComposeContentTestRule.isShape(
shape: Shape = CircleShape,
layoutDirection: LayoutDirection,
@@ -863,6 +889,7 @@
)
}
+ @RequiresApi(Build.VERSION_CODES.O)
private fun ComposeContentTestRule.verifyColors(
expectedContainerColor: @Composable () -> Color,
expectedContentColor: @Composable () -> Color,
@@ -884,6 +911,7 @@
onNodeWithTag(TEST_TAG).captureToImage().assertContainsColor(finalExpectedContainerColor)
}
+ @RequiresApi(Build.VERSION_CODES.O)
private fun ComposeContentTestRule.verifyCheckedStateChange(
updateState: () -> Unit,
startShape: Shape,
diff --git a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/LevelIndicatorTest.kt b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/LevelIndicatorTest.kt
index bda423d..4645acf 100644
--- a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/LevelIndicatorTest.kt
+++ b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/LevelIndicatorTest.kt
@@ -16,6 +16,8 @@
package androidx.wear.compose.material3
+import android.os.Build
+import androidx.annotation.RequiresApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
@@ -46,6 +48,7 @@
rule.onNodeWithTag(TEST_TAG).assertExists()
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun gives_indicator_correct_color() {
var expectedColor: Color = Color.Unspecified
@@ -58,6 +61,7 @@
rule.onNodeWithTag(TEST_TAG).captureToImage().assertContainsColor(expectedColor)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun gives_track_correct_color() {
var expectedColor: Color = Color.Unspecified
@@ -70,6 +74,7 @@
rule.onNodeWithTag(TEST_TAG).captureToImage().assertContainsColor(expectedColor)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun gives_indicator_custom_color() {
val customColor = Color.Red
@@ -84,6 +89,7 @@
rule.onNodeWithTag(TEST_TAG).captureToImage().assertContainsColor(customColor)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun gives_track_custom_color() {
val customColor = Color.Red
diff --git a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/Material3Test.kt b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/Material3Test.kt
index d990dc1a..5b334ce 100644
--- a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/Material3Test.kt
+++ b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/Material3Test.kt
@@ -17,13 +17,14 @@
package androidx.wear.compose.material3
import android.content.res.Configuration
+import android.os.Build
+import androidx.annotation.RequiresApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.sizeIn
@@ -178,9 +179,7 @@
@Composable
fun CenteredText(text: String) {
- Column(modifier = Modifier.fillMaxHeight(), verticalArrangement = Arrangement.Center) {
- Text(text)
- }
+ Column(verticalArrangement = Arrangement.Center) { Text(text) }
}
fun ComposeContentTestRule.setContentWithThemeForSizeAssertions(
@@ -242,6 +241,7 @@
onNodeWithTag(TEST_TAG).assertHeightIsEqualTo(expectedSize).assertWidthIsEqualTo(expectedSize)
}
+@RequiresApi(Build.VERSION_CODES.O)
internal fun ComposeContentTestRule.verifyColors(
status: Status,
expectedContainerColor: @Composable () -> Color,
@@ -376,6 +376,7 @@
}
}
+@RequiresApi(Build.VERSION_CODES.O)
internal fun ComposeContentTestRule.verifyScreenshot(
methodName: String,
screenshotRule: AndroidXScreenshotTestRule,
@@ -396,6 +397,7 @@
onNodeWithTag(testTag).captureToImage().assertAgainstGolden(screenshotRule, methodName)
}
+@RequiresApi(Build.VERSION_CODES.O)
fun ComposeContentTestRule.verifyRoundedButtonTapAnimationEnd(
baseShape: RoundedCornerShape,
pressedShape: RoundedCornerShape,
diff --git a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/PageIndicatorTest.kt b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/PageIndicatorTest.kt
index 84d54f5..203c279 100644
--- a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/PageIndicatorTest.kt
+++ b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/PageIndicatorTest.kt
@@ -16,6 +16,8 @@
package androidx.wear.compose.material3
+import android.os.Build
+import androidx.annotation.RequiresApi
import androidx.compose.testutils.assertContainsColor
import androidx.compose.testutils.assertDoesNotContainColor
import androidx.compose.ui.Modifier
@@ -33,6 +35,7 @@
import org.junit.Rule
import org.junit.Test
+@RequiresApi(Build.VERSION_CODES.O)
class PageIndicatorTest {
@get:Rule val rule = createComposeRule()
diff --git a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/PlaceholderTest.kt b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/PlaceholderTest.kt
index e98e268..3234616 100644
--- a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/PlaceholderTest.kt
+++ b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/PlaceholderTest.kt
@@ -16,6 +16,8 @@
package androidx.wear.compose.material3
+import android.os.Build
+import androidx.annotation.RequiresApi
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -41,6 +43,7 @@
import org.junit.Rule
import org.junit.Test
+@RequiresApi(Build.VERSION_CODES.O)
class PlaceholderTest {
@get:Rule val rule = createComposeRule()
diff --git a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/RadioButtonTest.kt b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/RadioButtonTest.kt
index 44d9c68..4b6a973 100644
--- a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/RadioButtonTest.kt
+++ b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/RadioButtonTest.kt
@@ -16,6 +16,8 @@
package androidx.wear.compose.material3
+import android.os.Build
+import androidx.annotation.RequiresApi
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.RowScope
@@ -583,6 +585,7 @@
Assert.assertEquals(2, secondaryLabelMaxLines)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun radio_button_allows_checked_background_color_override() =
verifyRadioButtonBackgroundColor(
@@ -591,6 +594,7 @@
expectedColor = SELECTED_COLOR
)
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun radio_button_allows_unchecked_background_color_override() =
verifyRadioButtonBackgroundColor(
@@ -599,6 +603,7 @@
expectedColor = UNSELECTED_COLOR
)
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun split_radio_button_allows_checked_background_color_override() =
verifySplitRadioButtonBackgroundColor(
@@ -607,6 +612,7 @@
expectedColor = SELECTED_COLOR
)
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun split_radio_button_allows_unchecked_background_color_override() =
verifySplitRadioButtonBackgroundColor(
@@ -615,6 +621,7 @@
expectedColor = UNSELECTED_COLOR
)
+ @RequiresApi(Build.VERSION_CODES.O)
private fun verifyRadioButtonBackgroundColor(
selected: Boolean,
enabled: Boolean,
@@ -637,6 +644,7 @@
rule.onNodeWithTag(TEST_TAG).captureToImage().assertContainsColor(expectedColor)
}
+ @RequiresApi(Build.VERSION_CODES.O)
private fun verifySplitRadioButtonBackgroundColor(
selected: Boolean,
enabled: Boolean,
diff --git a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/ScrollAwayTest.kt b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/ScrollAwayTest.kt
index 9bcedf1..90674cb 100644
--- a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/ScrollAwayTest.kt
+++ b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/ScrollAwayTest.kt
@@ -16,6 +16,8 @@
package androidx.wear.compose.material3
+import android.os.Build
+import androidx.annotation.RequiresApi
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
@@ -59,6 +61,7 @@
class ScrollAwayTest {
@get:Rule val rule = createComposeRule()
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun showsTimeTextWithScalingLazyColumnInitially() {
val timeTextColor = Color.Red
@@ -89,6 +92,7 @@
rule.onNodeWithTag(TIME_TEXT_TAG).assertIsDisplayed()
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun showsTimeTextWithLazyColumnInitially() {
val timeTextColor = Color.Red
@@ -101,6 +105,7 @@
rule.onNodeWithTag(TEST_TAG).captureToImage().assertContainsColor(timeTextColor)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun showsTimeTextWithColumnInitially() {
val timeTextColor = Color.Red
@@ -113,6 +118,7 @@
rule.onNodeWithTag(TEST_TAG).captureToImage().assertContainsColor(timeTextColor)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun hidesTimeTextAfterScrollingScalingLazyColumn() {
val timeTextColor = Color.Red
@@ -130,6 +136,7 @@
rule.onNodeWithTag(TEST_TAG).captureToImage().assertDoesNotContainColor(timeTextColor)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun hidesTimeTextWithLazyColumn() {
val timeTextColor = Color.Red
@@ -152,6 +159,7 @@
rule.onNodeWithTag(TEST_TAG).captureToImage().assertDoesNotContainColor(timeTextColor)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun hidesTimeTextWithColumn() {
val timeTextColor = Color.Red
diff --git a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/SwitchButtonTest.kt b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/SwitchButtonTest.kt
index f9dd312..ec60445 100644
--- a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/SwitchButtonTest.kt
+++ b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/SwitchButtonTest.kt
@@ -16,6 +16,8 @@
package androidx.wear.compose.material3
+import android.os.Build
+import androidx.annotation.RequiresApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
@@ -545,6 +547,7 @@
Assert.assertEquals(2, secondaryLabelMaxLines)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun switch_button_allows_checked_background_color_override() =
verifySwitchButtonBackgroundColor(
@@ -553,6 +556,7 @@
expectedColor = CHECKED_COLOR
)
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun switch_button_allows_unchecked_background_color_override() =
verifySwitchButtonBackgroundColor(
@@ -561,6 +565,7 @@
expectedColor = UNCHECKED_COLOR
)
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun split_switch_button_allows_checked_background_color_override() =
verifySplitSwitchButtonBackgroundColor(
@@ -569,6 +574,7 @@
expectedColor = CHECKED_COLOR
)
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun split_switch_button_allows_unchecked_background_color_override() =
verifySplitSwitchButtonBackgroundColor(
@@ -577,46 +583,55 @@
expectedColor = UNCHECKED_COLOR
)
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun verify_switch_button_colors_enabled_and_checked() {
rule.verifySwitchButtonColors(checked = true, enabled = true)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun verify_switch_button_colors_enabled_and_unchecked() {
rule.verifySwitchButtonColors(checked = false, enabled = true)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun verify_switch_button_colors_disabled_and_checked() {
rule.verifySwitchButtonColors(checked = true, enabled = false)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun verify_switch_button_colors_disabled_and_unchecked() {
rule.verifySwitchButtonColors(checked = false, enabled = false)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun verify_split_switch_button_colors_enabled_and_checked() {
rule.verifySplitToggleButtonColors(checked = true, enabled = true)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun verify_split_switch_button_colors_enabled_and_unchecked() {
rule.verifySplitToggleButtonColors(checked = false, enabled = true)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun verify_split_switch_button_colors_disabled_and_checked() {
rule.verifySplitToggleButtonColors(checked = true, enabled = false)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun verify_split_toggle_button_colors_disabled_and_unchecked() {
rule.verifySplitToggleButtonColors(checked = false, enabled = false)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun switch_checked_colors_are_customisable() {
val thumbColor = Color.Green
@@ -645,6 +660,7 @@
image.assertContainsColor(trackBorderColor)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun switch_unchecked_colors_are_customisable() {
val thumbColor = Color.Green
@@ -672,6 +688,7 @@
image.assertContainsColor(trackBorderColor)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun disabled_switch_checked_colors_are_customisable() {
val thumbColor = Color.Green
@@ -700,6 +717,7 @@
image.assertContainsColor(trackBorderColor)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun disabled_switch_unchecked_colors_are_customisable() {
val thumbColor = Color.Green
@@ -724,6 +742,7 @@
image.assertContainsColor(trackBorderColor)
}
+ @RequiresApi(Build.VERSION_CODES.O)
private fun verifySwitchButtonBackgroundColor(
checked: Boolean,
enabled: Boolean,
@@ -746,6 +765,7 @@
rule.onNodeWithTag(TEST_TAG).captureToImage().assertContainsColor(expectedColor)
}
+ @RequiresApi(Build.VERSION_CODES.O)
private fun verifySplitSwitchButtonBackgroundColor(
checked: Boolean,
enabled: Boolean,
@@ -814,6 +834,7 @@
toggleContentDescription = "description",
)
+@RequiresApi(Build.VERSION_CODES.O)
private fun ComposeContentTestRule.verifySwitchButtonColors(enabled: Boolean, checked: Boolean) {
val testBackgroundColor = Color.White
var expectedContainerColor = Color.Transparent
@@ -853,6 +874,7 @@
)
}
+@RequiresApi(Build.VERSION_CODES.O)
private fun ComposeContentTestRule.verifySplitToggleButtonColors(
enabled: Boolean,
checked: Boolean
diff --git a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/TextButtonTest.kt b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/TextButtonTest.kt
index ddfc1b0..884643a 100644
--- a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/TextButtonTest.kt
+++ b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/TextButtonTest.kt
@@ -16,6 +16,8 @@
package androidx.wear.compose.material3
+import android.os.Build
+import androidx.annotation.RequiresApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
@@ -335,6 +337,7 @@
}
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun default_shape_is_circular() {
rule.isShape(
@@ -347,6 +350,7 @@
}
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun allows_custom_shape_override() {
val shape = CutCornerShape(4.dp)
@@ -363,6 +367,7 @@
}
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun gives_enabled_text_button_colors() {
rule.verifyTextButtonColors(
@@ -373,6 +378,7 @@
)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun gives_disabled_text_button_colors() {
rule.verifyTextButtonColors(
@@ -385,6 +391,7 @@
)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun gives_enabled_filled_text_button_colors() {
rule.verifyTextButtonColors(
@@ -395,6 +402,7 @@
)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun gives_disabled_filled_text_button_colors() {
rule.verifyTextButtonColors(
@@ -409,6 +417,7 @@
)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun gives_enabled_filled_variant_text_button_colors() {
rule.verifyTextButtonColors(
@@ -419,6 +428,7 @@
)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun gives_disabled_filled_variant_text_button_colors() {
rule.verifyTextButtonColors(
@@ -433,6 +443,7 @@
)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun gives_enabled_filled_tonal_text_button_colors() {
rule.verifyTextButtonColors(
@@ -443,6 +454,7 @@
)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun gives_disabled_filled_tonal_text_button_colors() {
rule.verifyTextButtonColors(
@@ -457,6 +469,7 @@
)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun gives_enabled_outlined_text_button_colors() {
rule.verifyTextButtonColors(
@@ -467,6 +480,7 @@
)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun gives_disabled_outlined_text_button_colors() {
rule.verifyTextButtonColors(
@@ -479,6 +493,7 @@
)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun gives_enabled_outlined_text_button_correct_border_colors() {
val status = Status.Enabled
@@ -496,6 +511,7 @@
)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun gives_disabled_outlined_text_button_correct_border_colors() {
val status = Status.Disabled
@@ -515,6 +531,7 @@
)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun overrides_outlined_text_button_border_color() {
val status = Status.Enabled
@@ -536,6 +553,7 @@
)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun animates_corners_to_75_percent_on_click() {
val baseShape = RoundedCornerShape(20.dp)
@@ -557,6 +575,7 @@
}
}
+ @RequiresApi(Build.VERSION_CODES.O)
private fun ComposeContentTestRule.verifyTextButtonColors(
status: Status,
colors: @Composable () -> TextButtonColors,
@@ -584,6 +603,7 @@
}
}
+@RequiresApi(Build.VERSION_CODES.O)
private fun ComposeContentTestRule.isShape(
expectedShape: Shape,
colors: @Composable () -> TextButtonColors,
diff --git a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/TextToggleButtonTest.kt b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/TextToggleButtonTest.kt
index 0d1b67e..fae1c050 100644
--- a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/TextToggleButtonTest.kt
+++ b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/TextToggleButtonTest.kt
@@ -16,6 +16,8 @@
package androidx.wear.compose.material3
+import android.os.Build
+import androidx.annotation.RequiresApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
@@ -241,6 +243,7 @@
rule.onNodeWithTag(TEST_TAG).assertIsOff().performClick().assertIsOff()
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun is_circular_under_ltr() =
rule.isShape(
@@ -257,6 +260,7 @@
)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun is_circular_under_rtl() =
rule.isShape(
@@ -273,6 +277,7 @@
)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun allows_shape_overrides() =
rule.isShape(
@@ -374,6 +379,7 @@
)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun gives_checked_primary_colors() =
rule.verifyTextToggleButtonColors(
@@ -384,6 +390,7 @@
contentColor = { MaterialTheme.colorScheme.onPrimary }
)
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun gives_unchecked_surface_colors() =
rule.verifyTextToggleButtonColors(
@@ -394,6 +401,7 @@
contentColor = { MaterialTheme.colorScheme.onSurfaceVariant }
)
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun gives_disabled_unchecked_surface_colors_with_alpha() =
rule.verifyTextToggleButtonColors(
@@ -406,6 +414,7 @@
contentColor = { MaterialTheme.colorScheme.onSurface.toDisabledColor() }
)
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun gives_disabled_primary_checked_contrasting_content_color() =
rule.verifyTextToggleButtonColors(
@@ -418,6 +427,7 @@
contentColor = { MaterialTheme.colorScheme.onSurface.toDisabledColor() },
)
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun allows_custom_checked_background_override() {
val override = Color.Yellow
@@ -433,6 +443,7 @@
)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun allows_custom_checked_content_override() {
val override = Color.Green
@@ -448,6 +459,7 @@
)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun allows_custom_unchecked_background_override() {
val override = Color.Red
@@ -463,6 +475,7 @@
)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun allows_custom_unchecked_content_override() {
val override = Color.Green
@@ -478,6 +491,7 @@
)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun allows_custom_checked_disabled_background_override() {
val override = Color.Yellow
@@ -495,6 +509,7 @@
)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun allows_custom_checked_disabled_content_override() {
val override = Color.Green
@@ -515,6 +530,7 @@
)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun allows_custom_unchecked_disabled_background_override() {
val override = Color.Red
@@ -533,6 +549,7 @@
)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun allows_custom_unchecked_disabled_content_override() {
val override = Color.Green
@@ -590,6 +607,7 @@
.assert(SemanticsMatcher.expectValue(SemanticsProperties.Role, overrideRole))
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun animates_corners_to_75_percent_on_click() {
val uncheckedShape = RoundedCornerShape(20.dp)
@@ -625,6 +643,7 @@
}
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun changes_unchecked_to_checked_shape_on_click() {
val uncheckedShape = RoundedCornerShape(20.dp)
@@ -648,6 +667,7 @@
}
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun changes_checked_to_unchecked_shape_on_click() {
val uncheckedShape = RoundedCornerShape(10.dp)
@@ -671,6 +691,7 @@
}
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun changes_to_unchecked_pressed_shape_when_pressed_on_unchecked() {
val uncheckedShape = RoundedCornerShape(20.dp)
@@ -703,6 +724,7 @@
}
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun changes_to_checked_pressed_shape_when_pressed_on_checked() {
val uncheckedShape = RoundedCornerShape(10.dp)
@@ -735,6 +757,7 @@
}
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun changes_unchecked_to_checked_shape_when_checked_changed() {
val uncheckedShape = RoundedCornerShape(20.dp)
@@ -759,6 +782,7 @@
)
}
+ @RequiresApi(Build.VERSION_CODES.O)
@Test
fun changes_checked_to_unchecked_shape_when_checked_changed() {
val uncheckedShape = RoundedCornerShape(20.dp)
@@ -790,6 +814,7 @@
.value
}
+ @RequiresApi(Build.VERSION_CODES.O)
private fun ComposeContentTestRule.verifyTextToggleButtonColors(
status: Status,
checked: Boolean,
@@ -816,6 +841,7 @@
)
}
+ @RequiresApi(Build.VERSION_CODES.O)
private fun ComposeContentTestRule.isShape(
shape: Shape = CircleShape,
layoutDirection: LayoutDirection,
@@ -846,6 +872,7 @@
)
}
+ @RequiresApi(Build.VERSION_CODES.O)
private fun ComposeContentTestRule.verifyColors(
expectedContainerColor: @Composable () -> Color,
expectedContentColor: @Composable () -> Color,
@@ -879,6 +906,7 @@
.assertWidthIsEqualTo(expectedSize)
}
+ @RequiresApi(Build.VERSION_CODES.O)
private fun ComposeContentTestRule.verifyCheckedStateChange(
updateState: () -> Unit,
startShape: Shape,
diff --git a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/TimePickerScreenshotTest.kt b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/TimePickerScreenshotTest.kt
index 1e80a24..3511aa3 100644
--- a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/TimePickerScreenshotTest.kt
+++ b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/TimePickerScreenshotTest.kt
@@ -17,6 +17,7 @@
package androidx.wear.compose.material3
import android.os.Build
+import androidx.annotation.RequiresApi
import androidx.compose.runtime.Composable
import androidx.compose.testutils.assertAgainstGolden
import androidx.compose.ui.Modifier
@@ -138,6 +139,7 @@
}
)
+ @RequiresApi(Build.VERSION_CODES.O)
private fun ComposeContentTestRule.verifyTimePickerScreenshot(
methodName: String,
screenshotRule: AndroidXScreenshotTestRule,
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/Button.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/Button.kt
index b3a9562..dcdb65b 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/Button.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/Button.kt
@@ -30,7 +30,6 @@
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
-import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
@@ -1826,7 +1825,7 @@
@Composable
private fun Modifier.buttonSizeModifier(): Modifier =
- this.defaultMinSize(minHeight = ButtonDefaults.Height).height(IntrinsicSize.Min)
+ this.defaultMinSize(minHeight = ButtonDefaults.Height)
@Composable
private fun Modifier.compactButtonModifier(): Modifier =
@@ -1858,7 +1857,6 @@
// want them to be able to fit their content
modifier =
modifier
- .fillMaxHeight()
.width(intrinsicSize = IntrinsicSize.Max)
.container(colors.containerPainter(enabled = enabled), shape, border)
.combinedClickable(
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/ButtonGroup.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/ButtonGroup.kt
index 56b0729..ebfc393f 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/ButtonGroup.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/ButtonGroup.kt
@@ -16,67 +16,53 @@
package androidx.wear.compose.material3
+import androidx.annotation.FloatRange
import androidx.annotation.VisibleForTesting
import androidx.compose.animation.core.Animatable
-import androidx.compose.foundation.interaction.InteractionSource
+import androidx.compose.animation.core.AnimationSpec
+import androidx.compose.animation.core.AnimationVector1D
+import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.PressInteraction
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
-import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.Layout
+import androidx.compose.ui.node.ModifierNodeElement
+import androidx.compose.ui.node.ParentDataModifierNode
+import androidx.compose.ui.platform.InspectorInfo
import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
-import androidx.compose.ui.util.fastForEach
+import androidx.compose.ui.unit.takeOrElse
import androidx.compose.ui.util.fastForEachIndexed
+import androidx.compose.ui.util.fastIsFinite
import androidx.compose.ui.util.fastMap
import androidx.compose.ui.util.fastMapIndexed
import androidx.wear.compose.materialcore.screenHeightDp
import kotlin.math.abs
import kotlin.math.roundToInt
+import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
-import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.launch
-/** Scope for the children of a [ButtonGroup] */
-public class ButtonGroupScope {
- internal val items = mutableListOf<ButtonGroupItem>()
-
- /**
- * Adds an item to a [ButtonGroup]
- *
- * @param interactionSource the interactionSource used to detect press/release events. Should be
- * the same one used in the content in this slot, which is typically a [Button].
- * @param minWidth the minimum width this item can be. This will only be used if distributing
- * the available space results on a item falling below it's minimum width.
- * @param weight the main way of distributing available space. In most cases, items will have a
- * width assigned proportional to their width (and available space). The exception is if that
- * will make some item(s) width fall below it's minWidth.
- * @param content the content to use for this item. Usually, this will be one of the [Button]
- * variants.
- */
- public fun buttonGroupItem(
- interactionSource: InteractionSource,
- minWidth: Dp = minimumInteractiveComponentSize,
- weight: Float = 1f,
- content: @Composable () -> Unit
- ): Boolean = items.add(ButtonGroupItem(interactionSource, minWidth, weight, content))
-}
-
/**
- * Layout component to implement an expressive group of buttons, that react to touch by growing the
- * touched button, (while the neighbor(s) shrink to accommodate and keep the group width constant).
+ * Layout component to implement an expressive group of buttons in a row, that react to touch by
+ * growing the touched button, (while the neighbor(s) shrink to accommodate and keep the group width
+ * constant).
*
* Example of a [ButtonGroup]:
*
* @sample androidx.wear.compose.material3.samples.ButtonGroupSample
+ *
+ * Example of 3 buttons, the middle one bigger [ButtonGroup]:
+ *
+ * @sample androidx.wear.compose.material3.samples.ButtonGroupThreeButtonsSample
* @param modifier Modifier to be applied to the button group
* @param spacing the amount of spacing between buttons
* @param expansionWidth how much buttons grow when pressed
@@ -84,7 +70,8 @@
* content
* @param verticalAlignment the vertical alignment of the button group's children.
* @param content the content and properties of each button. The Ux guidance is to use no more than
- * 3 buttons within a ButtonGroup.
+ * 3 buttons within a ButtonGroup. Note that this content is on the [ButtonGroupScope], to provide
+ * access to 3 new modifiers to configure the buttons.
*/
@Composable
public fun ButtonGroup(
@@ -93,98 +80,77 @@
expansionWidth: Dp = ButtonGroupDefaults.ExpansionWidth,
contentPadding: PaddingValues = ButtonGroupDefaults.fullWidthPaddings(),
verticalAlignment: Alignment.Vertical = Alignment.CenterVertically,
- content: ButtonGroupScope.() -> Unit
+ content: @Composable ButtonGroupScope.() -> Unit
) {
- val actualContent = ButtonGroupScope().apply(block = content)
-
- val pressedStates = remember { Array(actualContent.items.size) { mutableStateOf(false) } }
-
- val animatedSizes = remember { Array(actualContent.items.size) { Animatable(0f) } }
-
val expandAmountPx = with(LocalDensity.current) { expansionWidth.toPx() }
val downAnimSpec = MaterialTheme.motionScheme.fastSpatialSpec<Float>().faster(100f)
val upAnimSpec = MaterialTheme.motionScheme.slowSpatialSpec<Float>()
- LaunchedEffect(actualContent.items) {
- launch {
- val pressInteractions =
- Array(actualContent.items.size) { mutableListOf<PressInteraction.Press>() }
-
- merge(
- flows =
- Array(actualContent.items.size) { index ->
- // Annotate each flow with the item index it is related to.
- actualContent.items[index].interactionSource.interactions.map {
- interaction ->
- index to interaction
- }
- }
- )
- .collect { (index, interaction) ->
- when (interaction) {
- is PressInteraction.Press -> pressInteractions[index].add(interaction)
- is PressInteraction.Release ->
- pressInteractions[index].remove(interaction.press)
- is PressInteraction.Cancel ->
- pressInteractions[index].remove(interaction.press)
- }
- pressedStates[index].value = pressInteractions[index].isNotEmpty()
+ val scope = remember {
+ object : ButtonGroupScope {
+ override fun Modifier.weight(weight: Float): Modifier {
+ require(weight >= 0.0) {
+ "invalid weight $weight; must be greater or equal to zero"
}
- }
-
- actualContent.items.indices.forEach { index ->
- launch {
- snapshotFlow { pressedStates[index].value }
- .collectLatest { value ->
- if (value) {
- animatedSizes[index].animateTo(expandAmountPx, downAnimSpec)
- } else {
- animatedSizes[index].animateTo(0f, upAnimSpec)
- }
- }
+ return this.then(ButtonGroupElement(weight = weight))
}
+
+ override fun Modifier.minWidth(minWidth: Dp): Modifier {
+ require(minWidth > 0.dp) { "invalid minWidth $minWidth; must be greater than zero" }
+ return this.then(ButtonGroupElement(minWidth = minWidth))
+ }
+
+ override fun Modifier.enlargeOnPress(interactionSource: MutableInteractionSource) =
+ this.then(
+ EnlargeOnPressElement(
+ interactionSource = interactionSource,
+ downAnimSpec,
+ upAnimSpec
+ )
+ )
}
}
- Layout(
- modifier = modifier.padding(contentPadding),
- content = { actualContent.items.fastForEach { it.content() } }
- ) { measurables, constraints ->
+ Layout(modifier = modifier.padding(contentPadding), content = { scope.content() }) {
+ measurables,
+ constraints ->
require(constraints.hasBoundedWidth) { "ButtonGroup width cannot be unbounded." }
- require(measurables.size == actualContent.items.size) {
- "ButtonGroup's items have to produce exactly one composable each."
- }
val width = constraints.maxWidth
val spacingPx = spacing.roundToPx()
+ val configs =
+ Array(measurables.size) {
+ measurables[it].parentData as? ButtonGroupParentData
+ ?: ButtonGroupParentData.DEFAULT
+ }
+
+ val animatedSizes = Array(measurables.size) { configs[it].pressedState.value }
+
// TODO: Cache this if it proves to be computationally intensive.
val widths =
- computeWidths(
- actualContent.items.fastMap { it.minWidth.toPx() to it.weight },
- spacingPx,
- width
- )
+ computeWidths(configs.map { it.minWidth.toPx() to it.weight }, spacingPx, width)
// Add animated grow/shrink
- if (actualContent.items.size > 1) {
- animatedSizes.forEachIndexed { index, value ->
+ if (measurables.size > 1) {
+ for (index in measurables.indices) {
+ val value = animatedSizes[index] * expandAmountPx
// How much we need to grow the pressed item.
val growth: Int
- if (index in 1 until animatedSizes.lastIndex) {
- // index is in the middle. Ensure we keep the size of the middle element with
+ if (index in 1 until measurables.lastIndex) {
+ // index is in the middle. Ensure we keep the size of the middle item with
// the same parity, so its content remains in place.
- growth = (value.value / 2).roundToInt() * 2
+ growth = (value / 2).roundToInt() * 2
widths[index - 1] -= growth / 2
widths[index + 1] -= growth / 2
} else {
- growth = value.value.roundToInt()
+ growth = value.roundToInt()
if (index == 0) {
// index == 0, and we know there are at least 2 items.
widths[1] -= growth
} else {
- // index == animatedSizes.lastIndex, and we know there are at least 2 items.
+ // index == measurables.lastIndex, and we know there are at least 2 items.
widths[index - 1] -= growth
}
}
@@ -218,6 +184,37 @@
}
}
+public interface ButtonGroupScope {
+ /**
+ * [ButtonGroup] uses a ratio of all sibling item [weight]s to assign a width to each item. The
+ * horizontal space is distributed using [weight] first, and this will only be changed if any
+ * item would be smaller than its [minWidth]. See also [Modifier.minWidth].
+ *
+ * @param weight The main way of distributing available space. This is a relative measure, and
+ * items with no weight specified will have a default of 1f.
+ */
+ public fun Modifier.weight(
+ @FloatRange(from = 0.0, fromInclusive = false) weight: Float
+ ): Modifier
+
+ /**
+ * Specifies the minimum width this item can be, in Dp. This will only be used if distributing
+ * the available space results in a item falling below its minimum width. Note that this is only
+ * used before animations, pressing a button may result on neighbor button(s) going below their
+ * minWidth. See also [Modifier.weight]
+ *
+ * @param minWidth the minimum width. If none is specified, minimumInteractiveComponentSize is
+ * used.
+ */
+ public fun Modifier.minWidth(minWidth: Dp = minimumInteractiveComponentSize): Modifier
+
+ /**
+ * Specifies the interaction source to use with this item. This is used to listen to events and
+ * grow/shrink the buttons in reaction.
+ */
+ public fun Modifier.enlargeOnPress(interactionSource: MutableInteractionSource): Modifier
+}
+
/** Contains the default values used by [ButtonGroup] */
public object ButtonGroupDefaults {
/**
@@ -246,22 +243,160 @@
/**
* Data class to configure one item in a [ButtonGroup]
*
- * @param interactionSource the interactionSource used to detect press/release events. Should be the
- * same one used in the content in this slot, which is typically a [Button].
- * @param minWidth the minimum width this item can be. This will only be used if distributing the
- * available space results on a item falling below it's minimum width.
* @param weight the main way of distributing available space. In most cases, items will have a
- * width assigned proportional to their width (and available space). The exception is if that will
- * make some item(s) width fall below it's minWidth.
- * @param content the content to use for this item. Usually, this will be one of the [Button]
- * variants.
+ * width assigned proportional to their weight (and available space). The exception is if that
+ * will make some item(s) width fall below its minWidth.
+ * @param minWidth the minimum width this item can be. This will only be used if distributing the
+ * available space results on a item falling below its minimum width.
+ * @param pressedState an animated float between 0f and 1f that captures an animated, continuous
+ * version of the item's interaction source pressed state.
*/
-internal data class ButtonGroupItem(
- val interactionSource: InteractionSource,
- val minWidth: Dp = minimumInteractiveComponentSize,
- val weight: Float = 1f,
- val content: @Composable () -> Unit
-)
+internal data class ButtonGroupParentData(
+ val weight: Float,
+ val minWidth: Dp,
+ val pressedState: Animatable<Float, AnimationVector1D>,
+) {
+ companion object {
+ val DEFAULT = ButtonGroupParentData(1f, minimumInteractiveComponentSize, Animatable(0f))
+ }
+}
+
+internal class ButtonGroupElement(
+ val weight: Float = Float.NaN,
+ val minWidth: Dp = Dp.Unspecified,
+) : ModifierNodeElement<ButtonGroupNode>() {
+
+ override fun create(): ButtonGroupNode {
+ return ButtonGroupNode(weight, minWidth)
+ }
+
+ override fun update(node: ButtonGroupNode) {
+ node.weight = weight
+ node.minWidth = minWidth
+ }
+
+ override fun InspectorInfo.inspectableProperties() {
+ name = "ButtonGroupElement"
+ properties["weight"] = weight
+ properties["minWidth"] = minWidth
+ }
+
+ override fun hashCode() = weight.hashCode() * 31 + minWidth.hashCode()
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ val otherModifier = other as? ButtonGroupNode ?: return false
+ return weight == otherModifier.weight && minWidth == otherModifier.minWidth
+ }
+}
+
+internal class ButtonGroupNode(var weight: Float, var minWidth: Dp) :
+ ParentDataModifierNode, Modifier.Node() {
+ override fun Density.modifyParentData(parentData: Any?) =
+ (parentData as? ButtonGroupParentData ?: ButtonGroupParentData.DEFAULT).let { prev ->
+ ButtonGroupParentData(
+ if (weight.fastIsFinite()) weight else prev.weight,
+ minWidth.takeOrElse { prev.minWidth },
+ prev.pressedState
+ )
+ }
+}
+
+internal class EnlargeOnPressElement(
+ val interactionSource: MutableInteractionSource,
+ val downAnimSpec: AnimationSpec<Float>,
+ val upAnimSpec: AnimationSpec<Float>,
+) : ModifierNodeElement<EnlargeOnPressNode>() {
+
+ override fun create(): EnlargeOnPressNode {
+ return EnlargeOnPressNode(interactionSource, downAnimSpec, upAnimSpec)
+ }
+
+ override fun update(node: EnlargeOnPressNode) {
+ if (node.interactionSource != interactionSource) {
+ node.interactionSource = interactionSource
+ node.launchCollectionJob()
+ }
+ node.downAnimSpec = downAnimSpec
+ node.upAnimSpec = upAnimSpec
+ }
+
+ override fun InspectorInfo.inspectableProperties() {
+ name = "EnlargeOnPressElement"
+ properties["interactionSource"] = interactionSource
+ properties["downAnimSpec"] = downAnimSpec
+ properties["upAnimSpec"] = upAnimSpec
+ }
+
+ override fun hashCode() =
+ (interactionSource.hashCode() * 31 + downAnimSpec.hashCode()) * 31 + upAnimSpec.hashCode()
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ val otherModifier = other as? EnlargeOnPressNode ?: return false
+ return interactionSource == otherModifier.interactionSource &&
+ downAnimSpec == otherModifier.downAnimSpec &&
+ upAnimSpec == otherModifier.upAnimSpec
+ }
+}
+
+internal class EnlargeOnPressNode(
+ var interactionSource: MutableInteractionSource,
+ var downAnimSpec: AnimationSpec<Float>,
+ var upAnimSpec: AnimationSpec<Float>,
+) : ParentDataModifierNode, Modifier.Node() {
+ private val pressedAnimatable: Animatable<Float, AnimationVector1D> = Animatable(0f)
+
+ private var collectionJob: Job? = null
+
+ override fun onAttach() {
+ super.onAttach()
+
+ launchCollectionJob()
+ }
+
+ override fun onDetach() {
+ super.onDetach()
+ collectionJob = null
+ }
+
+ internal fun launchCollectionJob() {
+ collectionJob?.cancel()
+ collectionJob =
+ coroutineScope.launch {
+ val pressInteractions = mutableListOf<PressInteraction.Press>()
+
+ launch {
+ // Use collect here to ensure we don't lose any events.
+ interactionSource.interactions
+ .map { interaction ->
+ when (interaction) {
+ is PressInteraction.Press -> pressInteractions.add(interaction)
+ is PressInteraction.Release ->
+ pressInteractions.remove(interaction.press)
+ is PressInteraction.Cancel ->
+ pressInteractions.remove(interaction.press)
+ }
+ pressInteractions.isNotEmpty()
+ }
+ .distinctUntilChanged()
+ .collectLatest { pressed ->
+ if (pressed) {
+ launch { pressedAnimatable.animateTo(1f, downAnimSpec) }
+ } else {
+ waitUntil { pressedAnimatable.value > 0.75f }
+ pressedAnimatable.animateTo(0f, upAnimSpec)
+ }
+ }
+ }
+ }
+ }
+
+ override fun Density.modifyParentData(parentData: Any?) =
+ (parentData as? ButtonGroupParentData ?: ButtonGroupParentData.DEFAULT).let { prev ->
+ ButtonGroupParentData(prev.weight, prev.minWidth, pressedAnimatable)
+ }
+}
// TODO: Does it make sense to unify these 2 classes?
private data class ComputeHelper(
@@ -295,8 +430,8 @@
val totalWeight = helper.map { it.weight }.sum()
val extraSpace = availableWidth - minSpaceNeeded
- // TODO: should we really handle the totalWeight <= 0 case? If so, we need to leave items at
- // their minWidth and center the whole thing?
+ // TODO: should we really handle the totalWeight <= 0 case? If so, we need to leave items
+ // at their minWidth and center the whole thing?
if (totalWeight > 0) {
for (ix in helper.indices) {
// Initial distribution ignores minWidth.
@@ -312,9 +447,11 @@
// Sort them. We will have:
// * Items with weight == 0 and less width required (usually 0)
// * Items with weight > 0 and less width required
- // * Items with weight > 0, sorted for the order in which they may get below their minimum width
+ // * Items with weight > 0, sorted for the order in which they may get below their
+ // minimum width
// as we take away space.
- // * Items with weight == 0 and enough width (This can only happen if totalWeight == 0)
+ // * Items with weight == 0 and enough width (This can only happen if totalWeight
+ // == 0)
helper.sortBy {
if (it.weight == 0f) {
if (it.width < it.minWidth) Float.MIN_VALUE else Float.MAX_VALUE
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/Container.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/Container.kt
index 74c6450..826394a 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/Container.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/Container.kt
@@ -20,15 +20,25 @@
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
-import androidx.compose.ui.draw.paint
+import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.graphics.drawscope.ContentDrawScope
+import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.graphics.painter.ColorPainter
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.layout.times
+import androidx.compose.ui.node.DrawModifierNode
+import androidx.compose.ui.node.ModifierNodeElement
+import androidx.compose.ui.platform.InspectorInfo
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.util.fastIsFinite
+import androidx.compose.ui.util.fastRoundToInt
import androidx.wear.compose.foundation.LocalReduceMotion
import androidx.wear.compose.foundation.lazy.LocalTransformingLazyColumnItemScope
import androidx.wear.compose.material3.lazy.scrollTransform
@@ -65,7 +75,7 @@
return itemScope?.let { tlcScope -> scrollTransform(tlcScope, shape, painter, border) }
?: borderModifier
.clip(shape = shape)
- .paint(painter = painter, contentScale = ContentScale.Crop)
+ .paintBackground(painter = painter, contentScale = ContentScale.Crop)
}
/**
@@ -101,3 +111,97 @@
scrollTransform(tlcScope, shape, ColorPainter(color), border)
} ?: borderModifier.clip(shape = shape).background(color = color)
}
+
+/**
+ * Paint the background using [Painter].
+ *
+ * This modifier simply paints the background, without modifying the size.
+ *
+ * @param painter [Painter] to be drawn by this [Modifier]
+ * @param alignment specifies alignment of the [painter] relative to content
+ * @param contentScale strategy for scaling [painter] if its size does not match the content size
+ */
+internal fun Modifier.paintBackground(
+ painter: Painter,
+ alignment: Alignment = Alignment.Center,
+ contentScale: ContentScale = ContentScale.Inside,
+) = this then PainterElement(painter = painter, alignment = alignment, contentScale = contentScale)
+
+private data class PainterElement(
+ val painter: Painter,
+ val contentScale: ContentScale,
+ var alignment: Alignment = Alignment.Center,
+) : ModifierNodeElement<PainterNode>() {
+ override fun create(): PainterNode {
+ return PainterNode(painter = painter, alignment = alignment, contentScale = contentScale)
+ }
+
+ override fun update(node: PainterNode) {
+ node.painter = painter
+ }
+
+ override fun InspectorInfo.inspectableProperties() {
+ name = "paint"
+ properties["painter"] = painter
+ properties["alignment"] = alignment
+ properties["contentScale"] = contentScale
+ }
+}
+
+private class PainterNode(
+ var painter: Painter,
+ var alignment: Alignment = Alignment.Center,
+ val contentScale: ContentScale,
+) : Modifier.Node(), DrawModifierNode {
+
+ override fun ContentDrawScope.draw() {
+ val intrinsicSize = painter.intrinsicSize
+ val srcWidth =
+ if (intrinsicSize.hasSpecifiedAndFiniteWidth()) {
+ intrinsicSize.width
+ } else {
+ size.width
+ }
+
+ val srcHeight =
+ if (intrinsicSize.hasSpecifiedAndFiniteHeight()) {
+ intrinsicSize.height
+ } else {
+ size.height
+ }
+
+ val srcSize = Size(srcWidth, srcHeight)
+ val scaledSize =
+ if (size.width != 0f && size.height != 0f) {
+ srcSize * contentScale.computeScaleFactor(srcSize, size)
+ } else {
+ Size.Zero
+ }
+
+ val alignedPosition =
+ alignment.align(
+ IntSize(scaledSize.width.fastRoundToInt(), scaledSize.height.fastRoundToInt()),
+ IntSize(size.width.fastRoundToInt(), size.height.fastRoundToInt()),
+ layoutDirection
+ )
+
+ val dx = alignedPosition.x.toFloat()
+ val dy = alignedPosition.y.toFloat()
+
+ translate(dx, dy) { with(painter) { draw(size = scaledSize) } }
+
+ // Maintain the same pattern as Modifier.drawBehind to allow chaining of DrawModifiers
+ drawContent()
+ }
+
+ private fun Size.hasSpecifiedAndFiniteWidth() = this != Size.Unspecified && width.fastIsFinite()
+
+ private fun Size.hasSpecifiedAndFiniteHeight() =
+ this != Size.Unspecified && height.fastIsFinite()
+
+ override fun toString(): String =
+ "PainterModifier(" +
+ "painter=$painter, " +
+ "alignment=$alignment, " +
+ "contentScale=$contentScale)"
+}
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/DatePicker.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/DatePicker.kt
index 2879d30..6d981e3 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/DatePicker.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/DatePicker.kt
@@ -16,7 +16,9 @@
package androidx.wear.compose.material3
+import android.os.Build
import android.text.format.DateFormat
+import androidx.annotation.RequiresApi
import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
@@ -96,6 +98,7 @@
* @param datePickerType The different [DatePickerType] supported by this [DatePicker].
* @param colors [DatePickerColors] to be applied to the DatePicker.
*/
+@RequiresApi(Build.VERSION_CODES.O)
@Composable
public fun DatePicker(
initialDate: LocalDate,
@@ -696,6 +699,7 @@
else -> arrayOf(DatePickerOption.Day, DatePickerOption.Month, DatePickerOption.Year)
}
+@RequiresApi(Build.VERSION_CODES.O)
private fun verifyDates(
date: LocalDate,
minDate: LocalDate,
@@ -705,6 +709,7 @@
require(date in minDate..maxDate) { "date should lie between minDate and maxDate" }
}
+@RequiresApi(Build.VERSION_CODES.O)
private fun getMonthNames(pattern: String): List<String> {
val monthFormatter = DateTimeFormatter.ofPattern(pattern)
val months = 1..12
@@ -730,6 +735,7 @@
}
}
+@RequiresApi(Build.VERSION_CODES.O)
private class DatePickerState(
initialDate: LocalDate,
initialDateMinYear: LocalDate?,
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/PickerGroup.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/PickerGroup.kt
index ece1094..78277fc 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/PickerGroup.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/PickerGroup.kt
@@ -24,6 +24,7 @@
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/RoundButton.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/RoundButton.kt
index 92016f0..13726ac 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/RoundButton.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/RoundButton.kt
@@ -65,7 +65,6 @@
content: @Composable BoxScope.() -> Unit,
) {
val borderStroke = border(enabled)
-
Box(
contentAlignment = Alignment.Center,
modifier =
@@ -158,11 +157,10 @@
onPressAnimationSpec = onPressAnimationSpec,
onReleaseAnimationSpec = onReleaseAnimationSpec
)
-
- Pair(finalShape, finalInteractionSource)
+ finalShape to finalInteractionSource
} else {
// Fallback to static uncheckedShape if no other shapes, or not animatable
- Pair(defaultShape, interactionSource)
+ defaultShape to interactionSource
}
@Composable
@@ -195,22 +193,19 @@
val finalInteractionSource = interactionSource ?: remember { MutableInteractionSource() }
- val finalShape =
- rememberAnimatedToggleRoundedCornerShape(
- interactionSource = finalInteractionSource,
- uncheckedCornerSize = uncheckedShape.topEnd,
- checkedCornerSize = checkedShape.topEnd,
- uncheckedPressedCornerSize = uncheckedPressedShape.topEnd,
- checkedPressedCornerSize = checkedPressedShape.topEnd,
- checked = checked,
- onPressAnimationSpec = onPressAnimationSpec,
- onReleaseAnimationSpec = onReleaseAnimationSpec,
- )
-
- Pair(finalShape, finalInteractionSource)
+ rememberAnimatedToggleRoundedCornerShape(
+ interactionSource = finalInteractionSource,
+ uncheckedCornerSize = uncheckedShape.topEnd,
+ checkedCornerSize = checkedShape.topEnd,
+ uncheckedPressedCornerSize = uncheckedPressedShape.topEnd,
+ checkedPressedCornerSize = checkedPressedShape.topEnd,
+ checked = checked,
+ onPressAnimationSpec = onPressAnimationSpec,
+ onReleaseAnimationSpec = onReleaseAnimationSpec,
+ ) to finalInteractionSource
} else {
// Fallback to static uncheckedShape if no other shapes, or not animatable
- Pair(uncheckedShape, interactionSource)
+ uncheckedShape to interactionSource
}
}
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/TextToggleButton.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/TextToggleButton.kt
index 2ae6941..acd3223 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/TextToggleButton.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/TextToggleButton.kt
@@ -39,6 +39,7 @@
import androidx.wear.compose.material3.tokens.MotionTokens
import androidx.wear.compose.material3.tokens.ShapeTokens
import androidx.wear.compose.material3.tokens.TextToggleButtonTokens
+import androidx.wear.compose.materialcore.ToggleButton
import androidx.wear.compose.materialcore.animateSelectionColor
/**
@@ -110,7 +111,7 @@
interactionSource = interactionSource
)
- androidx.wear.compose.materialcore.ToggleButton(
+ ToggleButton(
checked = checked,
onCheckedChange = onCheckedChange,
modifier = modifier.minimumInteractiveComponentSize(),
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/TimePicker.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/TimePicker.kt
index 01b7923..4f2571d 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/TimePicker.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/TimePicker.kt
@@ -16,6 +16,8 @@
package androidx.wear.compose.material3
+import android.os.Build
+import androidx.annotation.RequiresApi
import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
@@ -95,6 +97,7 @@
* whether to show seconds or AM/PM selector as well as hours and minutes.
* @param colors [TimePickerColors] be applied to the TimePicker.
*/
+@RequiresApi(Build.VERSION_CODES.O)
@Composable
public fun TimePicker(
initialTime: LocalTime,
@@ -593,6 +596,7 @@
}
/* Returns the picker data for the third column (AM/PM or seconds) based on the time picker type. */
+@RequiresApi(Build.VERSION_CODES.O)
@Composable
private fun getOptionalThirdPicker(
timePickerType: TimePickerType,
diff --git a/wear/compose/integration-tests/demos/build.gradle b/wear/compose/integration-tests/demos/build.gradle
index 3298f37..efb8ff6 100644
--- a/wear/compose/integration-tests/demos/build.gradle
+++ b/wear/compose/integration-tests/demos/build.gradle
@@ -25,9 +25,9 @@
compileSdk = 35
defaultConfig {
applicationId = "androidx.wear.compose.integration.demos"
- minSdk = 30
- versionCode = 63
- versionName = "1.63"
+ minSdk = 25
+ versionCode = 64
+ versionName = "1.64"
}
buildTypes {
diff --git a/wear/compose/integration-tests/demos/src/main/java/androidx/wear/compose/integration/demos/DemoActivity.kt b/wear/compose/integration-tests/demos/src/main/java/androidx/wear/compose/integration/demos/DemoActivity.kt
index 883f184..938f23f 100644
--- a/wear/compose/integration-tests/demos/src/main/java/androidx/wear/compose/integration/demos/DemoActivity.kt
+++ b/wear/compose/integration-tests/demos/src/main/java/androidx/wear/compose/integration/demos/DemoActivity.kt
@@ -19,11 +19,13 @@
import android.app.Activity
import android.content.Context
import android.content.Intent
+import android.os.Build
import android.os.Bundle
import android.view.View
import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedCallback
import androidx.activity.OnBackPressedDispatcher
+import androidx.annotation.RequiresApi
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
@@ -53,6 +55,7 @@
lateinit var hostView: View
lateinit var focusManager: FocusManager
+ @RequiresApi(Build.VERSION_CODES.O)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
diff --git a/wear/compose/integration-tests/demos/src/main/java/androidx/wear/compose/integration/demos/MaterialDemos.kt b/wear/compose/integration-tests/demos/src/main/java/androidx/wear/compose/integration/demos/MaterialDemos.kt
index 169ba5bc..1aa5314 100644
--- a/wear/compose/integration-tests/demos/src/main/java/androidx/wear/compose/integration/demos/MaterialDemos.kt
+++ b/wear/compose/integration-tests/demos/src/main/java/androidx/wear/compose/integration/demos/MaterialDemos.kt
@@ -16,6 +16,7 @@
package androidx.wear.compose.integration.demos
+import android.os.Build
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -146,65 +147,71 @@
),
DemoCategory(
"Picker",
- listOf(
- ComposableDemo("Time HH:MM:SS") { params ->
- var timePickerTime by remember { mutableStateOf(LocalTime.now()) }
- TimePicker(
- onTimeConfirm = {
- timePickerTime = it
- params.navigateBack()
- },
- time = timePickerTime,
- )
- },
- ComposableDemo("Time 12 Hour") { params ->
- var timePickerTime by remember { mutableStateOf(LocalTime.now()) }
- TimePickerWith12HourClock(
- onTimeConfirm = {
- timePickerTime = it
- params.navigateBack()
- },
- time = timePickerTime,
- )
- },
- ComposableDemo("Date Picker") { params ->
- var datePickerDate by remember { mutableStateOf(LocalDate.now()) }
- DatePicker(
- onDateConfirm = {
- datePickerDate = it
- params.navigateBack()
- },
- date = datePickerDate
- )
- },
- ComposableDemo("From Date Picker") { params ->
- var datePickerDate by remember { mutableStateOf(LocalDate.now()) }
- DatePicker(
- onDateConfirm = {
- datePickerDate = it
- params.navigateBack()
- },
- date = datePickerDate,
- fromDate = datePickerDate
- )
- },
- ComposableDemo("To Date Picker") { params ->
- var datePickerDate by remember { mutableStateOf(LocalDate.now()) }
- DatePicker(
- onDateConfirm = {
- datePickerDate = it
- params.navigateBack()
- },
- date = datePickerDate,
- toDate = datePickerDate
- )
- },
- ComposableDemo("Simple Picker") { SimplePicker() },
- ComposableDemo("No gradient") { PickerWithoutGradient() },
- ComposableDemo("Animate picker change") { AnimateOptionChangePicker() },
- ComposableDemo("Sample Picker Group") { PickerGroup24Hours() },
- ComposableDemo("Autocentering Picker Group") { AutoCenteringPickerGroup() }
- )
+ if (Build.VERSION.SDK_INT > 25) {
+ listOf(
+ ComposableDemo("Time HH:MM:SS") { params ->
+ var timePickerTime by remember { mutableStateOf(LocalTime.now()) }
+ TimePicker(
+ onTimeConfirm = {
+ timePickerTime = it
+ params.navigateBack()
+ },
+ time = timePickerTime,
+ )
+ },
+ ComposableDemo("Time 12 Hour") { params ->
+ var timePickerTime by remember { mutableStateOf(LocalTime.now()) }
+ TimePickerWith12HourClock(
+ onTimeConfirm = {
+ timePickerTime = it
+ params.navigateBack()
+ },
+ time = timePickerTime,
+ )
+ },
+ ComposableDemo("Date Picker") { params ->
+ var datePickerDate by remember { mutableStateOf(LocalDate.now()) }
+ DatePicker(
+ onDateConfirm = {
+ datePickerDate = it
+ params.navigateBack()
+ },
+ date = datePickerDate
+ )
+ },
+ ComposableDemo("From Date Picker") { params ->
+ var datePickerDate by remember { mutableStateOf(LocalDate.now()) }
+ DatePicker(
+ onDateConfirm = {
+ datePickerDate = it
+ params.navigateBack()
+ },
+ date = datePickerDate,
+ fromDate = datePickerDate
+ )
+ },
+ ComposableDemo("To Date Picker") { params ->
+ var datePickerDate by remember { mutableStateOf(LocalDate.now()) }
+ DatePicker(
+ onDateConfirm = {
+ datePickerDate = it
+ params.navigateBack()
+ },
+ date = datePickerDate,
+ toDate = datePickerDate
+ )
+ },
+ ComposableDemo("Simple Picker") { SimplePicker() },
+ ComposableDemo("No gradient") { PickerWithoutGradient() },
+ ComposableDemo("Animate picker change") { AnimateOptionChangePicker() },
+ ComposableDemo("Sample Picker Group") { PickerGroup24Hours() },
+ ComposableDemo("Autocentering Picker Group") { AutoCenteringPickerGroup() }
+ )
+ } else {
+ listOf(
+ ComposableDemo("Simple Picker") { SimplePicker() },
+ )
+ }
),
DemoCategory(
"Slider",
diff --git a/wear/compose/integration-tests/demos/src/main/java/androidx/wear/compose/integration/demos/PickerDemo.kt b/wear/compose/integration-tests/demos/src/main/java/androidx/wear/compose/integration/demos/PickerDemo.kt
index 310a4b7..223bcbe 100644
--- a/wear/compose/integration-tests/demos/src/main/java/androidx/wear/compose/integration/demos/PickerDemo.kt
+++ b/wear/compose/integration-tests/demos/src/main/java/androidx/wear/compose/integration/demos/PickerDemo.kt
@@ -17,9 +17,11 @@
package androidx.wear.compose.integration.demos
import android.content.Context
+import android.os.Build
import android.view.accessibility.AccessibilityManager
import android.view.accessibility.AccessibilityManager.AccessibilityStateChangeListener
import android.view.accessibility.AccessibilityManager.TouchExplorationStateChangeListener
+import androidx.annotation.RequiresApi
import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
@@ -93,6 +95,7 @@
* @param modifier the modifiers for the `Box` containing the UI elements.
* @param time the initial value to seed the picker with.
*/
+@RequiresApi(Build.VERSION_CODES.O)
@Composable
public fun TimePicker(
onTimeConfirm: (LocalTime) -> Unit,
@@ -265,6 +268,7 @@
* @param modifier the modifiers for the `Column` containing the UI elements.
* @param time the initial value to seed the picker with.
*/
+@RequiresApi(Build.VERSION_CODES.O)
@Composable
public fun TimePickerWith12HourClock(
onTimeConfirm: (LocalTime) -> Unit,
@@ -464,6 +468,7 @@
* @param fromDate the minimum date to be selected in picker
* @param toDate the maximum date to be selected in picker
*/
+@RequiresApi(Build.VERSION_CODES.O)
@Composable
public fun DatePicker(
onDateConfirm: (LocalDate) -> Unit,
@@ -856,17 +861,20 @@
}
}
+@RequiresApi(Build.VERSION_CODES.O)
private fun verifyDates(date: LocalDate, fromDate: LocalDate, toDate: LocalDate) {
require(toDate >= fromDate) { "toDate should be greater than or equal to fromDate" }
require(date in fromDate..toDate) { "date should lie between fromDate and toDate" }
}
+@RequiresApi(Build.VERSION_CODES.O)
private fun getMonthNames(pattern: String): List<String> {
val monthFormatter = DateTimeFormatter.ofPattern(pattern)
val months = 1..12
return months.map { LocalDate.of(2022, it, 1).format(monthFormatter) }
}
+@RequiresApi(Build.VERSION_CODES.O)
internal class DatePickerState
constructor(
private val date: LocalDate,
diff --git a/wear/compose/integration-tests/macrobenchmark-target/build.gradle b/wear/compose/integration-tests/macrobenchmark-target/build.gradle
index d10859a..dbbe105 100644
--- a/wear/compose/integration-tests/macrobenchmark-target/build.gradle
+++ b/wear/compose/integration-tests/macrobenchmark-target/build.gradle
@@ -53,4 +53,4 @@
implementation(project(":tracing:tracing-perfetto-binary"))
}
-android.defaultConfig.minSdk = 30
+android.defaultConfig.minSdk = 25
diff --git a/wear/compose/integration-tests/macrobenchmark/build.gradle b/wear/compose/integration-tests/macrobenchmark/build.gradle
index e124928..6526e1b 100644
--- a/wear/compose/integration-tests/macrobenchmark/build.gradle
+++ b/wear/compose/integration-tests/macrobenchmark/build.gradle
@@ -23,7 +23,7 @@
android {
compileSdk = 35
defaultConfig {
- minSdk = 30
+ minSdk = 29
}
namespace = "androidx.wear.compose.integration.macrobenchmark"
targetProjectPath = ":wear:compose:integration-tests:macrobenchmark-target"
diff --git a/wear/protolayout/protolayout-material3/api/current.txt b/wear/protolayout/protolayout-material3/api/current.txt
index 8942afc..eae2d58 100644
--- a/wear/protolayout/protolayout-material3/api/current.txt
+++ b/wear/protolayout/protolayout-material3/api/current.txt
@@ -204,17 +204,6 @@
method public static androidx.wear.protolayout.LayoutElementBuilders.LayoutElement textEdgeButton(androidx.wear.protolayout.material3.MaterialScope, androidx.wear.protolayout.ModifiersBuilders.Clickable onClick, optional androidx.wear.protolayout.modifiers.LayoutModifier modifier, optional androidx.wear.protolayout.material3.ButtonColors colors, kotlin.jvm.functions.Function1<? super androidx.wear.protolayout.material3.MaterialScope,? extends androidx.wear.protolayout.LayoutElementBuilders.LayoutElement> labelContent);
}
- public final class EdgeButtonStyle {
- field public static final androidx.wear.protolayout.material3.EdgeButtonStyle.Companion Companion;
- field public static final androidx.wear.protolayout.material3.EdgeButtonStyle DEFAULT;
- field public static final androidx.wear.protolayout.material3.EdgeButtonStyle TOP_ALIGN;
- }
-
- public static final class EdgeButtonStyle.Companion {
- property public final androidx.wear.protolayout.material3.EdgeButtonStyle DEFAULT;
- property public final androidx.wear.protolayout.material3.EdgeButtonStyle TOP_ALIGN;
- }
-
public final class GraphicDataCardStyle {
field public static final androidx.wear.protolayout.material3.GraphicDataCardStyle.Companion Companion;
}
diff --git a/wear/protolayout/protolayout-material3/api/restricted_current.txt b/wear/protolayout/protolayout-material3/api/restricted_current.txt
index 8942afc..eae2d58 100644
--- a/wear/protolayout/protolayout-material3/api/restricted_current.txt
+++ b/wear/protolayout/protolayout-material3/api/restricted_current.txt
@@ -204,17 +204,6 @@
method public static androidx.wear.protolayout.LayoutElementBuilders.LayoutElement textEdgeButton(androidx.wear.protolayout.material3.MaterialScope, androidx.wear.protolayout.ModifiersBuilders.Clickable onClick, optional androidx.wear.protolayout.modifiers.LayoutModifier modifier, optional androidx.wear.protolayout.material3.ButtonColors colors, kotlin.jvm.functions.Function1<? super androidx.wear.protolayout.material3.MaterialScope,? extends androidx.wear.protolayout.LayoutElementBuilders.LayoutElement> labelContent);
}
- public final class EdgeButtonStyle {
- field public static final androidx.wear.protolayout.material3.EdgeButtonStyle.Companion Companion;
- field public static final androidx.wear.protolayout.material3.EdgeButtonStyle DEFAULT;
- field public static final androidx.wear.protolayout.material3.EdgeButtonStyle TOP_ALIGN;
- }
-
- public static final class EdgeButtonStyle.Companion {
- property public final androidx.wear.protolayout.material3.EdgeButtonStyle DEFAULT;
- property public final androidx.wear.protolayout.material3.EdgeButtonStyle TOP_ALIGN;
- }
-
public final class GraphicDataCardStyle {
field public static final androidx.wear.protolayout.material3.GraphicDataCardStyle.Companion Companion;
}
diff --git a/wear/protolayout/protolayout-material3/src/androidTest/java/androidx/wear/protolayout/material3/MaterialGoldenTest.kt b/wear/protolayout/protolayout-material3/src/androidTest/java/androidx/wear/protolayout/material3/MaterialGoldenTest.kt
index 12a274b..f05c1b6 100644
--- a/wear/protolayout/protolayout-material3/src/androidTest/java/androidx/wear/protolayout/material3/MaterialGoldenTest.kt
+++ b/wear/protolayout/protolayout-material3/src/androidTest/java/androidx/wear/protolayout/material3/MaterialGoldenTest.kt
@@ -33,8 +33,27 @@
AndroidXScreenshotTestRule("wear/protolayout/protolayout-material3")
@Test
- fun test() {
- RunnerUtils.runSingleScreenshotTest(mScreenshotRule, testCase, expected)
+ fun testLtr() {
+ // Skip test if it's not meant for LTR
+ if (!testCase.isForLtr) return
+ RunnerUtils.runSingleScreenshotTest(
+ rule = mScreenshotRule,
+ layout = testCase.layout,
+ expected = expected,
+ isRtlDirection = false
+ )
+ }
+
+ @Test
+ fun testRtl() {
+ // Skip test if it's not meant for RTL
+ if (!testCase.isForRtl) return
+ RunnerUtils.runSingleScreenshotTest(
+ rule = mScreenshotRule,
+ layout = testCase.layout,
+ expected = expected + "_rtl",
+ isRtlDirection = true
+ )
}
companion object {
diff --git a/wear/protolayout/protolayout-material3/src/androidTest/java/androidx/wear/protolayout/material3/RunnerUtils.kt b/wear/protolayout/protolayout-material3/src/androidTest/java/androidx/wear/protolayout/material3/RunnerUtils.kt
index 6bf8296..5ee111f 100644
--- a/wear/protolayout/protolayout-material3/src/androidTest/java/androidx/wear/protolayout/material3/RunnerUtils.kt
+++ b/wear/protolayout/protolayout-material3/src/androidTest/java/androidx/wear/protolayout/material3/RunnerUtils.kt
@@ -33,27 +33,9 @@
// watch dimensions here.
const val SCREEN_SIZE_SMALL: Int = 525 // ~199dp
- fun runSingleScreenshotTest(
- rule: AndroidXScreenshotTestRule,
- testCase: TestCase,
- expected: String
- ) {
- if (testCase.isForLtr) {
- runSingleScreenshotTest(rule, testCase.mLayout, expected, /* isRtlDirection= */ false)
- }
- if (testCase.isForRtl) {
- runSingleScreenshotTest(
- rule,
- testCase.mLayout,
- expected + "_rtl",
- /* isRtlDirection= */ true
- )
- }
- }
-
@SuppressLint("BanThreadSleep")
// TODO: b/355417923 - Avoid calling sleep.
- private fun runSingleScreenshotTest(
+ fun runSingleScreenshotTest(
rule: AndroidXScreenshotTestRule,
layout: LayoutElementBuilders.Layout,
expected: String,
@@ -61,7 +43,7 @@
) {
val layoutPayload = layout.toByteArray()
- val startIntent: Intent =
+ val startIntent =
Intent(
androidx.test.platform.app.InstrumentationRegistry.getInstrumentation()
.targetContext,
@@ -134,7 +116,7 @@
/** Holds testcase parameters. */
class TestCase(
- val mLayout: LayoutElementBuilders.Layout,
+ val layout: LayoutElementBuilders.Layout,
val isForRtl: Boolean,
val isForLtr: Boolean
)
diff --git a/wear/protolayout/protolayout-material3/src/androidTest/java/androidx/wear/protolayout/material3/TestCasesGenerator.kt b/wear/protolayout/protolayout-material3/src/androidTest/java/androidx/wear/protolayout/material3/TestCasesGenerator.kt
index 1826c42..4537676 100644
--- a/wear/protolayout/protolayout-material3/src/androidTest/java/androidx/wear/protolayout/material3/TestCasesGenerator.kt
+++ b/wear/protolayout/protolayout-material3/src/androidTest/java/androidx/wear/protolayout/material3/TestCasesGenerator.kt
@@ -23,11 +23,7 @@
import androidx.wear.protolayout.DimensionBuilders.dp
import androidx.wear.protolayout.DimensionBuilders.expand
import androidx.wear.protolayout.LayoutElementBuilders
-import androidx.wear.protolayout.LayoutElementBuilders.Box
import androidx.wear.protolayout.LayoutElementBuilders.Column
-import androidx.wear.protolayout.ModifiersBuilders.Background
-import androidx.wear.protolayout.ModifiersBuilders.Corner
-import androidx.wear.protolayout.ModifiersBuilders.Modifiers
import androidx.wear.protolayout.expression.VersionBuilders.VersionInfo
import androidx.wear.protolayout.material3.AppCardStyle.Companion.largeAppCardStyle
import androidx.wear.protolayout.material3.ButtonDefaults.filledButtonColors
@@ -48,7 +44,6 @@
import androidx.wear.protolayout.modifiers.clickable
import androidx.wear.protolayout.modifiers.clip
import androidx.wear.protolayout.modifiers.contentDescription
-import androidx.wear.protolayout.types.LayoutColor
import androidx.wear.protolayout.types.layoutString
import com.google.common.collect.ImmutableMap
@@ -88,14 +83,17 @@
testCases["primarylayout_edgebuttonfilled_buttongroup_iconoverride_golden$goldenSuffix"] =
materialScope(
ApplicationProvider.getApplicationContext(),
- deviceParameters,
+ // renderer version 1.302 has no asymmetrical corner support, so edgebutton will use
+ // its fallback style
+ deviceParameters.copy(VersionInfo.Builder().setMajor(1).setMinor(302).build()),
allowDynamicTheme = false
) {
primaryLayoutWithOverrideIcon(
mainSlot = {
text(
- text = "Text in the main slot that overflows".layoutString,
- color = colorScheme.secondary
+ text = "Overflow main text and fallback edge button".layoutString,
+ color = colorScheme.secondary,
+ maxLines = 3
)
},
bottomSlot = {
@@ -481,29 +479,28 @@
testCases["primarylayout_circularprogressindicators_fallback__golden$NORMAL_SCALE_SUFFIX"] =
materialScope(
ApplicationProvider.getApplicationContext(),
+ // renderer with version 1.302 has no DashedArcLine or asymmetrical corners support
deviceConfiguration =
- deviceParameters.copy(VersionInfo.Builder().setMajor(1).setMinor(402).build()),
+ deviceParameters.copy(VersionInfo.Builder().setMajor(1).setMinor(302).build()),
allowDynamicTheme = false
) {
- primaryLayout(mainSlot = { progressIndicatorGroup() })
+ primaryLayout(
+ mainSlot = { progressIndicatorGroup() },
+ bottomSlot = {
+ iconEdgeButton(
+ onClick = clickable,
+ iconContent = { icon(ICON_ID) },
+ modifier =
+ LayoutModifier.contentDescription(CONTENT_DESCRIPTION_PLACEHOLDER),
+ colors = filledTonalButtonColors()
+ )
+ }
+ )
}
return collectTestCases(testCases)
}
- private fun coloredBox(color: LayoutColor, shape: Corner) =
- Box.Builder()
- .setWidth(expand())
- .setHeight(expand())
- .setModifiers(
- Modifiers.Builder()
- .setBackground(
- Background.Builder().setColor(color.prop).setCorner(shape).build()
- )
- .build()
- )
- .build()
-
private fun collectTestCases(
testCases: Map<String, LayoutElementBuilders.LayoutElement>
): ImmutableMap<String, LayoutElementBuilders.Layout> {
diff --git a/wear/protolayout/protolayout-material3/src/main/java/androidx/wear/protolayout/material3/EdgeButton.kt b/wear/protolayout/protolayout-material3/src/main/java/androidx/wear/protolayout/material3/EdgeButton.kt
index e503797..5c438b7 100644
--- a/wear/protolayout/protolayout-material3/src/main/java/androidx/wear/protolayout/material3/EdgeButton.kt
+++ b/wear/protolayout/protolayout-material3/src/main/java/androidx/wear/protolayout/material3/EdgeButton.kt
@@ -27,8 +27,10 @@
import androidx.wear.protolayout.ModifiersBuilders.Clickable
import androidx.wear.protolayout.ModifiersBuilders.Padding
import androidx.wear.protolayout.ModifiersBuilders.SEMANTICS_ROLE_BUTTON
+import androidx.wear.protolayout.expression.VersionBuilders.VersionInfo
import androidx.wear.protolayout.material3.ButtonDefaults.filledButtonColors
import androidx.wear.protolayout.material3.EdgeButtonDefaults.BOTTOM_MARGIN_DP
+import androidx.wear.protolayout.material3.EdgeButtonDefaults.CONTAINER_HEIGHT_DP
import androidx.wear.protolayout.material3.EdgeButtonDefaults.EDGE_BUTTON_HEIGHT_DP
import androidx.wear.protolayout.material3.EdgeButtonDefaults.HORIZONTAL_MARGIN_PERCENT_LARGE
import androidx.wear.protolayout.material3.EdgeButtonDefaults.HORIZONTAL_MARGIN_PERCENT_SMALL
@@ -37,8 +39,16 @@
import androidx.wear.protolayout.material3.EdgeButtonDefaults.TEXT_SIDE_PADDING_DP
import androidx.wear.protolayout.material3.EdgeButtonDefaults.TEXT_TOP_PADDING_DP
import androidx.wear.protolayout.material3.EdgeButtonDefaults.TOP_CORNER_RADIUS
-import androidx.wear.protolayout.material3.EdgeButtonStyle.Companion.DEFAULT
-import androidx.wear.protolayout.material3.EdgeButtonStyle.Companion.TOP_ALIGN
+import androidx.wear.protolayout.material3.EdgeButtonFallbackDefaults.BOTTOM_MARGIN_FALLBACK_DP
+import androidx.wear.protolayout.material3.EdgeButtonFallbackDefaults.CORNER_RADIUS_FALLBACK_DP
+import androidx.wear.protolayout.material3.EdgeButtonFallbackDefaults.EDGE_BUTTON_HEIGHT_FALLBACK_DP
+import androidx.wear.protolayout.material3.EdgeButtonFallbackDefaults.ICON_SIDE_PADDING_FALLBACK_DP
+import androidx.wear.protolayout.material3.EdgeButtonFallbackDefaults.ICON_SIZE_FALLBACK_DP
+import androidx.wear.protolayout.material3.EdgeButtonFallbackDefaults.TEXT_SIDE_PADDING_FALLBACK_DP
+import androidx.wear.protolayout.material3.EdgeButtonStyle.Companion.ICON
+import androidx.wear.protolayout.material3.EdgeButtonStyle.Companion.ICON_FALLBACK
+import androidx.wear.protolayout.material3.EdgeButtonStyle.Companion.TEXT
+import androidx.wear.protolayout.material3.EdgeButtonStyle.Companion.TEXT_FALLBACK
import androidx.wear.protolayout.modifiers.LayoutModifier
import androidx.wear.protolayout.modifiers.background
import androidx.wear.protolayout.modifiers.clickable
@@ -50,6 +60,7 @@
import androidx.wear.protolayout.modifiers.semanticsRole
import androidx.wear.protolayout.modifiers.tag
import androidx.wear.protolayout.modifiers.toProtoLayoutModifiers
+import androidx.wear.protolayout.types.dp
/**
* ProtoLayout Material3 component edge button that offers a single slot to take an icon or similar
@@ -82,14 +93,22 @@
modifier: LayoutModifier = LayoutModifier,
colors: ButtonColors = filledButtonColors(),
iconContent: (MaterialScope.() -> LayoutElement)
-): LayoutElement =
- edgeButton(onClick = onClick, modifier = modifier, colors = colors, style = DEFAULT) {
+): LayoutElement {
+ val style =
+ if (deviceConfiguration.rendererSchemaVersion.hasAsymmetricalCornersSupport()) {
+ ICON
+ } else {
+ ICON_FALLBACK
+ }
+
+ return edgeButton(onClick = onClick, modifier = modifier, colors = colors, style = style) {
withStyle(
defaultIconStyle =
- IconStyle(size = ICON_SIZE_DP.toDp(), tintColor = colors.iconColor)
+ IconStyle(size = style.iconSizeDp.dp, tintColor = colors.iconColor)
)
.iconContent()
}
+}
/**
* ProtoLayout Material3 component edge button that offers a single slot to take a text or similar
@@ -123,7 +142,17 @@
colors: ButtonColors = filledButtonColors(),
labelContent: (MaterialScope.() -> LayoutElement)
): LayoutElement =
- edgeButton(onClick = onClick, modifier = modifier, colors = colors, style = TOP_ALIGN) {
+ edgeButton(
+ onClick = onClick,
+ modifier = modifier,
+ colors = colors,
+ style =
+ if (deviceConfiguration.rendererSchemaVersion.hasAsymmetricalCornersSupport()) {
+ TEXT
+ } else {
+ TEXT_FALLBACK
+ }
+ ) {
withStyle(
defaultTextElementStyle =
TextElementStyle(
@@ -156,8 +185,8 @@
* @param modifier Modifiers to set to this element. It's highly recommended to set a content
* description using [contentDescription].
* @param style The style used for the inner content, specifying how the content should be aligned.
- * It is recommended to use [EdgeButtonStyle.TOP_ALIGN] for long, wide content. If not set,
- * defaults to [EdgeButtonStyle.DEFAULT] which center-aligns the content.
+ * It is recommended to use [EdgeButtonStyle.TEXT] for long, wide content. If not set, defaults to
+ * [EdgeButtonStyle.ICON] which center-aligns the content.
* @param content The inner content to be put inside of this edge button.
* @sample androidx.wear.protolayout.material3.samples.edgeButtonSampleIcon
*/
@@ -166,7 +195,7 @@
onClick: Clickable,
colors: ButtonColors,
modifier: LayoutModifier = LayoutModifier,
- style: EdgeButtonStyle = DEFAULT,
+ style: EdgeButtonStyle = ICON,
content: MaterialScope.() -> LayoutElement
): LayoutElement {
val containerWidth = deviceConfiguration.screenWidthDp.toDp()
@@ -175,50 +204,83 @@
else HORIZONTAL_MARGIN_PERCENT_SMALL
val edgeButtonWidth: Float =
(100f - 2f * horizontalMarginPercent) * deviceConfiguration.screenWidthDp / 100f
- val bottomCornerRadiusX = edgeButtonWidth / 2f
- val bottomCornerRadiusY = EDGE_BUTTON_HEIGHT_DP - TOP_CORNER_RADIUS
var mod =
(LayoutModifier.semanticsRole(SEMANTICS_ROLE_BUTTON) then modifier)
.clickable(onClick)
.background(colors.containerColor)
- .clip(TOP_CORNER_RADIUS)
- .clipBottomLeft(bottomCornerRadiusX, bottomCornerRadiusY)
- .clipBottomRight(bottomCornerRadiusX, bottomCornerRadiusY)
+ .clip(style.topCornerRadiusDp)
+
+ if (deviceConfiguration.rendererSchemaVersion.hasAsymmetricalCornersSupport()) {
+ val bottomCornerRadiusX = edgeButtonWidth / 2f
+ val bottomCornerRadiusY = style.buttonHeightDp - style.topCornerRadiusDp
+ mod =
+ mod.clipBottomLeft(bottomCornerRadiusX, bottomCornerRadiusY)
+ .clipBottomRight(bottomCornerRadiusX, bottomCornerRadiusY)
+ }
style.padding?.let { mod = mod.padding(it) }
- val button = Box.Builder().setHeight(EDGE_BUTTON_HEIGHT_DP.toDp()).setWidth(dp(edgeButtonWidth))
- button
- .setVerticalAlignment(style.verticalAlignment)
- .setHorizontalAlignment(LayoutElementBuilders.HORIZONTAL_ALIGN_CENTER)
- .addContent(content())
+ val button =
+ Box.Builder()
+ .setHeight(style.buttonHeightDp.dp)
+ .setWidth(dp(edgeButtonWidth))
+ .setVerticalAlignment(style.verticalAlignment)
+ .setHorizontalAlignment(LayoutElementBuilders.HORIZONTAL_ALIGN_CENTER)
+ .addContent(content())
+ .setModifiers(mod.toProtoLayoutModifiers())
+ .build()
return Box.Builder()
- .setHeight((EDGE_BUTTON_HEIGHT_DP + BOTTOM_MARGIN_DP).toDp())
+ .setHeight(CONTAINER_HEIGHT_DP.dp)
.setWidth(containerWidth)
- .setVerticalAlignment(LayoutElementBuilders.VERTICAL_ALIGN_TOP)
+ .setVerticalAlignment(LayoutElementBuilders.VERTICAL_ALIGN_BOTTOM)
.setHorizontalAlignment(LayoutElementBuilders.HORIZONTAL_ALIGN_CENTER)
- .addContent(button.setModifiers(mod.toProtoLayoutModifiers()).build())
- .setModifiers(LayoutModifier.tag(METADATA_TAG).toProtoLayoutModifiers())
+ .addContent(button)
+ .setModifiers(
+ LayoutModifier.tag(METADATA_TAG)
+ .padding(padding(bottom = style.bottomMarginDp.toFloat()))
+ .toProtoLayoutModifiers()
+ )
.build()
}
-/** Provides style values for edge button component. */
-public class EdgeButtonStyle
-private constructor(
+/**
+ * Provides style values for edge button component.
+ *
+ * An [edgeButton] has a wrapper container with the screen width, and fixed height of
+ * [CONTAINER_HEIGHT_DP].
+ *
+ * The visible button box has height of [buttonHeightDp], it is centered horizontally in side the
+ * wrapper container with [HORIZONTAL_MARGIN_PERCENT_SMALL] or [HORIZONTAL_MARGIN_PERCENT_LARGE]
+ * depending on the screen size. It is then horizontally aligned to the bottom with bottom margin of
+ * [bottomMarginDp].
+ *
+ * The visible button box has its top two corners clipped with the [topCornerRadiusDp], while its
+ * bottom two corners will be clipped fully both horizontally and vertically to achieving the edge
+ * hugging shape. In the fallback implementation, without asymmetrical corners support, the
+ * [topCornerRadiusDp] is applied to all four corners.
+ *
+ * The content (icon or text) of the button is located inside the visible button box with [padding],
+ * horizontally centered and vertically aligned with the given [verticalAlignment].
+ */
+internal class EdgeButtonStyle
+internal constructor(
@VerticalAlignment internal val verticalAlignment: Int = VERTICAL_ALIGN_CENTER,
- internal val padding: Padding? = null
+ internal val padding: Padding? = null,
+ @Dimension(DP) internal val buttonHeightDp: Float = EDGE_BUTTON_HEIGHT_DP,
+ @Dimension(DP) internal val bottomMarginDp: Float = BOTTOM_MARGIN_DP,
+ @Dimension(DP) internal val iconSizeDp: Float = ICON_SIZE_DP,
+ @Dimension(DP) internal val topCornerRadiusDp: Float = TOP_CORNER_RADIUS
) {
- public companion object {
+ internal companion object {
/**
- * Style variation for having content of the edge button anchored to the top.
+ * Style variation for having text content with edge hugging shape.
*
- * This should be used for text-like content, or the content that is wide, to accommodate
- * for more space.
+ * The text is vertically aligned to top with [TEXT_TOP_PADDING_DP] to to accommodate for
+ * more horizontal space.
*/
- @JvmField
- public val TOP_ALIGN: EdgeButtonStyle =
+ internal val TEXT: EdgeButtonStyle =
EdgeButtonStyle(
verticalAlignment = LayoutElementBuilders.VERTICAL_ALIGN_TOP,
padding =
@@ -230,28 +292,101 @@
)
/**
- * Default style variation for having content of the edge button center aligned.
+ * Style variation for having icon content with edge hugging shape.
*
- * This should be used for icon-like or small, round content that doesn't occupy a lot of
- * space.
+ * The icon is centered in the visible button box with the size of [ICON_SIZE_DP].
*/
- @JvmField public val DEFAULT: EdgeButtonStyle = EdgeButtonStyle()
+ internal val ICON: EdgeButtonStyle = EdgeButtonStyle()
+
+ /**
+ * Style variation for fallback implementation with text content, when there is no
+ * asymmetrical corners support.
+ *
+ * Without the edge hugging shape, the [topCornerRadius] value is a full cornered value and
+ * is applied to all four corners. To avoid being clipped by the screen edge, the visible
+ * button box is pushed upwards with a bigger bottom margin of [BOTTOM_MARGIN_FALLBACK_DP].
+ * Also the box height shrinks to [EDGE_BUTTON_HEIGHT_FALLBACK_DP].
+ *
+ * Its text content is center placed with a increased horizontal padding of
+ * [TEXT_SIDE_PADDING_FALLBACK_DP].
+ */
+ internal val TEXT_FALLBACK: EdgeButtonStyle =
+ EdgeButtonStyle(
+ verticalAlignment = VERTICAL_ALIGN_CENTER,
+ padding =
+ padding(
+ start = TEXT_SIDE_PADDING_FALLBACK_DP,
+ end = TEXT_SIDE_PADDING_FALLBACK_DP,
+ ),
+ buttonHeightDp = EDGE_BUTTON_HEIGHT_FALLBACK_DP,
+ topCornerRadiusDp = CORNER_RADIUS_FALLBACK_DP,
+ bottomMarginDp = BOTTOM_MARGIN_FALLBACK_DP
+ )
+
+ /**
+ * Style variation for fallback implementation with icon content, when there is no
+ * asymmetrical corners support.
+ *
+ * Without the edge hugging shape, the [topCornerRadius] value is a full cornered value and
+ * is applied to all four corners. To avoid being clipped by the screen, the visible button
+ * box is pushed upwards with a bigger bottom margin of [BOTTOM_MARGIN_FALLBACK_DP]. Also
+ * the box height shrinks to [EDGE_BUTTON_HEIGHT_FALLBACK_DP]
+ *
+ * Its icon content center placed with increased horizontal padding
+ * [ICON_SIDE_PADDING_FALLBACK_DP]. Also, the icon size is also increased to
+ * [ICON_SIZE_FALLBACK_DP].
+ */
+ internal val ICON_FALLBACK: EdgeButtonStyle =
+ EdgeButtonStyle(
+ verticalAlignment = VERTICAL_ALIGN_CENTER,
+ padding =
+ padding(
+ start = ICON_SIDE_PADDING_FALLBACK_DP,
+ end = ICON_SIDE_PADDING_FALLBACK_DP,
+ ),
+ buttonHeightDp = EDGE_BUTTON_HEIGHT_FALLBACK_DP,
+ topCornerRadiusDp = CORNER_RADIUS_FALLBACK_DP,
+ bottomMarginDp = BOTTOM_MARGIN_FALLBACK_DP,
+ iconSizeDp = ICON_SIZE_FALLBACK_DP
+ )
}
}
internal object EdgeButtonDefaults {
- @Dimension(DP) internal const val TOP_CORNER_RADIUS: Float = 17f
+ @Dimension(DP) internal const val TOP_CORNER_RADIUS = 17f
/** The horizontal margin used for width of the EdgeButton, below the 225dp breakpoint. */
- internal const val HORIZONTAL_MARGIN_PERCENT_SMALL: Float = 24f
+ internal const val HORIZONTAL_MARGIN_PERCENT_SMALL = 24f
/** The horizontal margin used for width of the EdgeButton, above the 225dp breakpoint. */
- internal const val HORIZONTAL_MARGIN_PERCENT_LARGE: Float = 26f
- internal const val BOTTOM_MARGIN_DP: Int = 3
- internal const val EDGE_BUTTON_HEIGHT_DP: Int = 46
- internal const val METADATA_TAG: String = "EB"
- internal const val ICON_SIZE_DP = 24
- internal const val TEXT_TOP_PADDING_DP = 12f
- internal const val TEXT_SIDE_PADDING_DP = 8f
+ internal const val HORIZONTAL_MARGIN_PERCENT_LARGE = 26f
+ @Dimension(DP) internal const val BOTTOM_MARGIN_DP = 3f
+ @Dimension(DP) internal const val EDGE_BUTTON_HEIGHT_DP = 46f
+ @Dimension(DP) internal const val CONTAINER_HEIGHT_DP = EDGE_BUTTON_HEIGHT_DP + BOTTOM_MARGIN_DP
+ internal const val METADATA_TAG = "EB"
+ @Dimension(DP) internal const val ICON_SIZE_DP = 24f
+ @Dimension(DP) internal const val TEXT_TOP_PADDING_DP = 12f
+ @Dimension(DP) internal const val TEXT_SIDE_PADDING_DP = 8f
+}
+
+/**
+ * This object provides constants and styles of the fallback layout for [iconEdgeButton] and
+ * [textEdgeButton] when the renderer version is lower than 1.3.3 where asymmetrical corners support
+ * is not available.
+ */
+internal object EdgeButtonFallbackDefaults {
+ @Dimension(DP) internal const val ICON_SIZE_FALLBACK_DP = 26f
+ @Dimension(DP) internal const val EDGE_BUTTON_HEIGHT_FALLBACK_DP = 40f
+ @Dimension(DP) internal const val BOTTOM_MARGIN_FALLBACK_DP = 7f
+ @Dimension(DP)
+ internal const val CORNER_RADIUS_FALLBACK_DP = EDGE_BUTTON_HEIGHT_FALLBACK_DP / 2f
+ @Dimension(DP) internal const val TEXT_SIDE_PADDING_FALLBACK_DP = 14f
+ @Dimension(DP) internal const val ICON_SIDE_PADDING_FALLBACK_DP = 20f
}
internal fun LayoutElement.isSlotEdgeButton(): Boolean =
this is Box && METADATA_TAG == this.modifiers?.metadata?.toTagName()
+
+/**
+ * Checks whether the renderer has support for asymmetrical corners, which is added in version
+ * 1.303.
+ */
+private fun VersionInfo.hasAsymmetricalCornersSupport() = major > 1 || (major == 1 && minor >= 303)
diff --git a/wear/protolayout/protolayout-material3/src/test/java/androidx/wear/protolayout/material3/EdgeButtonTest.kt b/wear/protolayout/protolayout-material3/src/test/java/androidx/wear/protolayout/material3/EdgeButtonTest.kt
index c8c40e5..15636ae 100644
--- a/wear/protolayout/protolayout-material3/src/test/java/androidx/wear/protolayout/material3/EdgeButtonTest.kt
+++ b/wear/protolayout/protolayout-material3/src/test/java/androidx/wear/protolayout/material3/EdgeButtonTest.kt
@@ -25,8 +25,11 @@
import androidx.wear.protolayout.LayoutElementBuilders.Image
import androidx.wear.protolayout.expression.AppDataKey
import androidx.wear.protolayout.expression.DynamicBuilders.DynamicInt32
-import androidx.wear.protolayout.material3.EdgeButtonDefaults.BOTTOM_MARGIN_DP
+import androidx.wear.protolayout.expression.VersionBuilders.VersionInfo
+import androidx.wear.protolayout.material3.EdgeButtonDefaults.CONTAINER_HEIGHT_DP
import androidx.wear.protolayout.material3.EdgeButtonDefaults.EDGE_BUTTON_HEIGHT_DP
+import androidx.wear.protolayout.material3.EdgeButtonFallbackDefaults.EDGE_BUTTON_HEIGHT_FALLBACK_DP
+import androidx.wear.protolayout.material3.EdgeButtonFallbackDefaults.ICON_SIZE_FALLBACK_DP
import androidx.wear.protolayout.modifiers.LayoutModifier
import androidx.wear.protolayout.modifiers.clickable
import androidx.wear.protolayout.modifiers.contentDescription
@@ -41,6 +44,7 @@
import androidx.wear.protolayout.testing.isClickable
import androidx.wear.protolayout.types.LayoutString
import androidx.wear.protolayout.types.asLayoutConstraint
+import androidx.wear.protolayout.types.dp
import androidx.wear.protolayout.types.layoutString
import org.junit.Test
import org.junit.runner.RunWith
@@ -54,14 +58,14 @@
LayoutElementAssertionsProvider(ICON_EDGE_BUTTON)
.onRoot()
.assert(hasWidth(DEVICE_CONFIGURATION.screenWidthDp.toDp()))
- .assert(hasHeight((EDGE_BUTTON_HEIGHT_DP + BOTTOM_MARGIN_DP).toDp()))
+ .assert(hasHeight(CONTAINER_HEIGHT_DP.dp))
}
@Test
fun visibleHeight() {
LayoutElementAssertionsProvider(ICON_EDGE_BUTTON)
.onElement(isClickable())
- .assert(hasHeight(EDGE_BUTTON_HEIGHT_DP.toDp()))
+ .assert(hasHeight(EDGE_BUTTON_HEIGHT_DP.dp))
}
@Test
@@ -163,6 +167,29 @@
.assert(hasColor(COLOR_SCHEME.onPrimary.staticArgb))
}
+ @Test
+ fun containerFallbackSize() {
+ LayoutElementAssertionsProvider(ICON_EDGE_BUTTON_FALLBACK)
+ .onRoot()
+ .assert(hasWidth(DEVICE_CONFIGURATION.screenWidthDp.toDp()))
+ .assert(hasHeight(CONTAINER_HEIGHT_DP.dp))
+ }
+
+ @Test
+ fun visibleFallbackHeight() {
+ LayoutElementAssertionsProvider(ICON_EDGE_BUTTON_FALLBACK)
+ .onElement(isClickable())
+ .assert(hasHeight(EDGE_BUTTON_HEIGHT_FALLBACK_DP.dp))
+ }
+
+ @Test
+ fun iconFallbackSize() {
+ LayoutElementAssertionsProvider(ICON_EDGE_BUTTON_FALLBACK)
+ .onElement(hasImage(RES_ID))
+ .assert(hasWidth(ICON_SIZE_FALLBACK_DP.dp))
+ .assert(hasHeight(ICON_SIZE_FALLBACK_DP.dp))
+ }
+
companion object {
private val CONTEXT = getApplicationContext() as Context
private val COLOR_SCHEME = ColorScheme()
@@ -171,6 +198,14 @@
DeviceParametersBuilders.DeviceParameters.Builder()
.setScreenWidthDp(192)
.setScreenHeightDp(192)
+ .setRendererSchemaVersion(VersionInfo.Builder().setMajor(99).setMinor(999).build())
+ .build()
+
+ private val DEVICE_CONFIGURATION_WITH_OLD_RENDERER =
+ DeviceParametersBuilders.DeviceParameters.Builder()
+ .setScreenWidthDp(192)
+ .setScreenHeightDp(192)
+ .setRendererSchemaVersion(VersionInfo.Builder().setMajor(1).setMinor(302).build())
.build()
private val CLICKABLE =
@@ -188,5 +223,18 @@
icon(RES_ID)
}
}
+ private val ICON_EDGE_BUTTON_FALLBACK =
+ materialScope(
+ CONTEXT,
+ DEVICE_CONFIGURATION_WITH_OLD_RENDERER,
+ allowDynamicTheme = false
+ ) {
+ iconEdgeButton(
+ onClick = CLICKABLE,
+ modifier = LayoutModifier.contentDescription(CONTENT_DESCRIPTION)
+ ) {
+ icon(RES_ID)
+ }
+ }
}
}
diff --git a/wear/protolayout/protolayout-proto/src/main/proto/resources.proto b/wear/protolayout/protolayout-proto/src/main/proto/resources.proto
index 7e04e8a..edfbff0 100644
--- a/wear/protolayout/protolayout-proto/src/main/proto/resources.proto
+++ b/wear/protolayout/protolayout-proto/src/main/proto/resources.proto
@@ -155,6 +155,9 @@
//
// If not set, the animation will play on load.</setter>
androidx.wear.protolayout.expression.proto.DynamicFloat progress = 2;
+
+ // The trigger to start the animation.
+ Trigger start_trigger = 3;
}
// An image resource, which can be used by layouts. This holds multiple
diff --git a/wear/protolayout/protolayout/api/current.txt b/wear/protolayout/protolayout/api/current.txt
index 21886f5..da4e766 100644
--- a/wear/protolayout/protolayout/api/current.txt
+++ b/wear/protolayout/protolayout/api/current.txt
@@ -1309,12 +1309,14 @@
@androidx.wear.protolayout.expression.RequiresSchemaVersion(major=1, minor=500) public static final class ResourceBuilders.AndroidLottieResourceByResId {
method public androidx.wear.protolayout.expression.DynamicBuilders.DynamicFloat? getProgress();
method @RawRes public int getRawResourceId();
+ method public androidx.wear.protolayout.TriggerBuilders.Trigger? getStartTrigger();
}
public static final class ResourceBuilders.AndroidLottieResourceByResId.Builder {
ctor @androidx.wear.protolayout.expression.RequiresSchemaVersion(major=1, minor=500) public ResourceBuilders.AndroidLottieResourceByResId.Builder(@RawRes int);
method public androidx.wear.protolayout.ResourceBuilders.AndroidLottieResourceByResId build();
method @androidx.wear.protolayout.expression.RequiresSchemaVersion(major=1, minor=500) public androidx.wear.protolayout.ResourceBuilders.AndroidLottieResourceByResId.Builder setProgress(androidx.wear.protolayout.expression.DynamicBuilders.DynamicFloat);
+ method @androidx.wear.protolayout.expression.RequiresSchemaVersion(major=1, minor=500) public androidx.wear.protolayout.ResourceBuilders.AndroidLottieResourceByResId.Builder setStartTrigger(androidx.wear.protolayout.TriggerBuilders.Trigger);
}
@SuppressCompatibility @androidx.wear.protolayout.expression.ProtoLayoutExperimental @androidx.wear.protolayout.expression.RequiresSchemaVersion(major=1, minor=200) public static final class ResourceBuilders.AndroidSeekableAnimatedImageResourceByResId {
@@ -1434,6 +1436,24 @@
public final class TriggerBuilders {
method @androidx.wear.protolayout.expression.RequiresSchemaVersion(major=1, minor=200) public static androidx.wear.protolayout.TriggerBuilders.Trigger createOnConditionMetTrigger(androidx.wear.protolayout.expression.DynamicBuilders.DynamicBool);
method @androidx.wear.protolayout.expression.RequiresSchemaVersion(major=1, minor=200) public static androidx.wear.protolayout.TriggerBuilders.Trigger createOnLoadTrigger();
+ method @androidx.wear.protolayout.expression.RequiresSchemaVersion(major=1, minor=200) public static androidx.wear.protolayout.TriggerBuilders.Trigger createOnVisibleOnceTrigger();
+ method @androidx.wear.protolayout.expression.RequiresSchemaVersion(major=1, minor=200) public static androidx.wear.protolayout.TriggerBuilders.Trigger createOnVisibleTrigger();
+ }
+
+ @androidx.wear.protolayout.expression.RequiresSchemaVersion(major=1, minor=200) public static final class TriggerBuilders.OnVisibleOnceTrigger implements androidx.wear.protolayout.TriggerBuilders.Trigger {
+ }
+
+ public static final class TriggerBuilders.OnVisibleOnceTrigger.Builder {
+ ctor @androidx.wear.protolayout.expression.RequiresSchemaVersion(major=1, minor=200) public TriggerBuilders.OnVisibleOnceTrigger.Builder();
+ method public androidx.wear.protolayout.TriggerBuilders.OnVisibleOnceTrigger build();
+ }
+
+ @androidx.wear.protolayout.expression.RequiresSchemaVersion(major=1, minor=200) public static final class TriggerBuilders.OnVisibleTrigger implements androidx.wear.protolayout.TriggerBuilders.Trigger {
+ }
+
+ public static final class TriggerBuilders.OnVisibleTrigger.Builder {
+ ctor @androidx.wear.protolayout.expression.RequiresSchemaVersion(major=1, minor=200) public TriggerBuilders.OnVisibleTrigger.Builder();
+ method public androidx.wear.protolayout.TriggerBuilders.OnVisibleTrigger build();
}
@androidx.wear.protolayout.expression.RequiresSchemaVersion(major=1, minor=200) public static interface TriggerBuilders.Trigger {
@@ -1527,6 +1547,10 @@
method @androidx.wear.protolayout.expression.RequiresSchemaVersion(major=1, minor=400) public static androidx.wear.protolayout.modifiers.LayoutModifier clipTopRight(androidx.wear.protolayout.modifiers.LayoutModifier, @Dimension(unit=androidx.annotation.Dimension.Companion.DP) float x, optional @Dimension(unit=androidx.annotation.Dimension.Companion.DP) float y);
}
+ public final class BorderKt {
+ method public static androidx.wear.protolayout.modifiers.LayoutModifier border(androidx.wear.protolayout.modifiers.LayoutModifier, @Dimension(unit=androidx.annotation.Dimension.Companion.DP) float width, androidx.wear.protolayout.types.LayoutColor color);
+ }
+
public final class ClickableKt {
method public static androidx.wear.protolayout.ModifiersBuilders.Clickable clickable();
method public static androidx.wear.protolayout.ModifiersBuilders.Clickable clickable(optional androidx.wear.protolayout.ActionBuilders.Action action);
@@ -1557,6 +1581,10 @@
method public static androidx.wear.protolayout.ModifiersBuilders.Modifiers toProtoLayoutModifiers(androidx.wear.protolayout.modifiers.LayoutModifier);
}
+ public final class OpacityKt {
+ method @androidx.wear.protolayout.expression.RequiresSchemaVersion(major=1, minor=400) public static androidx.wear.protolayout.modifiers.LayoutModifier opacity(androidx.wear.protolayout.modifiers.LayoutModifier, @FloatRange(from=0.0, to=1.0) float staticValue, optional androidx.wear.protolayout.expression.DynamicBuilders.DynamicFloat? dynamicValue);
+ }
+
public final class PaddingKt {
method public static androidx.wear.protolayout.modifiers.LayoutModifier padding(androidx.wear.protolayout.modifiers.LayoutModifier, androidx.wear.protolayout.ModifiersBuilders.Padding padding);
method public static androidx.wear.protolayout.modifiers.LayoutModifier padding(androidx.wear.protolayout.modifiers.LayoutModifier, @Dimension(unit=androidx.annotation.Dimension.Companion.DP) float all);
@@ -1572,6 +1600,10 @@
method public static androidx.wear.protolayout.modifiers.LayoutModifier semanticsRole(androidx.wear.protolayout.modifiers.LayoutModifier, int semanticsRole);
}
+ public final class VisibilityKt {
+ method @SuppressCompatibility @androidx.wear.protolayout.expression.ProtoLayoutExperimental @androidx.wear.protolayout.expression.RequiresSchemaVersion(major=1, minor=300) public static androidx.wear.protolayout.modifiers.LayoutModifier visibility(androidx.wear.protolayout.modifiers.LayoutModifier, boolean staticVisibility, optional androidx.wear.protolayout.expression.DynamicBuilders.DynamicBool? dynamicVisibility);
+ }
+
}
package androidx.wear.protolayout.types {
diff --git a/wear/protolayout/protolayout/api/restricted_current.txt b/wear/protolayout/protolayout/api/restricted_current.txt
index 21886f5..da4e766 100644
--- a/wear/protolayout/protolayout/api/restricted_current.txt
+++ b/wear/protolayout/protolayout/api/restricted_current.txt
@@ -1309,12 +1309,14 @@
@androidx.wear.protolayout.expression.RequiresSchemaVersion(major=1, minor=500) public static final class ResourceBuilders.AndroidLottieResourceByResId {
method public androidx.wear.protolayout.expression.DynamicBuilders.DynamicFloat? getProgress();
method @RawRes public int getRawResourceId();
+ method public androidx.wear.protolayout.TriggerBuilders.Trigger? getStartTrigger();
}
public static final class ResourceBuilders.AndroidLottieResourceByResId.Builder {
ctor @androidx.wear.protolayout.expression.RequiresSchemaVersion(major=1, minor=500) public ResourceBuilders.AndroidLottieResourceByResId.Builder(@RawRes int);
method public androidx.wear.protolayout.ResourceBuilders.AndroidLottieResourceByResId build();
method @androidx.wear.protolayout.expression.RequiresSchemaVersion(major=1, minor=500) public androidx.wear.protolayout.ResourceBuilders.AndroidLottieResourceByResId.Builder setProgress(androidx.wear.protolayout.expression.DynamicBuilders.DynamicFloat);
+ method @androidx.wear.protolayout.expression.RequiresSchemaVersion(major=1, minor=500) public androidx.wear.protolayout.ResourceBuilders.AndroidLottieResourceByResId.Builder setStartTrigger(androidx.wear.protolayout.TriggerBuilders.Trigger);
}
@SuppressCompatibility @androidx.wear.protolayout.expression.ProtoLayoutExperimental @androidx.wear.protolayout.expression.RequiresSchemaVersion(major=1, minor=200) public static final class ResourceBuilders.AndroidSeekableAnimatedImageResourceByResId {
@@ -1434,6 +1436,24 @@
public final class TriggerBuilders {
method @androidx.wear.protolayout.expression.RequiresSchemaVersion(major=1, minor=200) public static androidx.wear.protolayout.TriggerBuilders.Trigger createOnConditionMetTrigger(androidx.wear.protolayout.expression.DynamicBuilders.DynamicBool);
method @androidx.wear.protolayout.expression.RequiresSchemaVersion(major=1, minor=200) public static androidx.wear.protolayout.TriggerBuilders.Trigger createOnLoadTrigger();
+ method @androidx.wear.protolayout.expression.RequiresSchemaVersion(major=1, minor=200) public static androidx.wear.protolayout.TriggerBuilders.Trigger createOnVisibleOnceTrigger();
+ method @androidx.wear.protolayout.expression.RequiresSchemaVersion(major=1, minor=200) public static androidx.wear.protolayout.TriggerBuilders.Trigger createOnVisibleTrigger();
+ }
+
+ @androidx.wear.protolayout.expression.RequiresSchemaVersion(major=1, minor=200) public static final class TriggerBuilders.OnVisibleOnceTrigger implements androidx.wear.protolayout.TriggerBuilders.Trigger {
+ }
+
+ public static final class TriggerBuilders.OnVisibleOnceTrigger.Builder {
+ ctor @androidx.wear.protolayout.expression.RequiresSchemaVersion(major=1, minor=200) public TriggerBuilders.OnVisibleOnceTrigger.Builder();
+ method public androidx.wear.protolayout.TriggerBuilders.OnVisibleOnceTrigger build();
+ }
+
+ @androidx.wear.protolayout.expression.RequiresSchemaVersion(major=1, minor=200) public static final class TriggerBuilders.OnVisibleTrigger implements androidx.wear.protolayout.TriggerBuilders.Trigger {
+ }
+
+ public static final class TriggerBuilders.OnVisibleTrigger.Builder {
+ ctor @androidx.wear.protolayout.expression.RequiresSchemaVersion(major=1, minor=200) public TriggerBuilders.OnVisibleTrigger.Builder();
+ method public androidx.wear.protolayout.TriggerBuilders.OnVisibleTrigger build();
}
@androidx.wear.protolayout.expression.RequiresSchemaVersion(major=1, minor=200) public static interface TriggerBuilders.Trigger {
@@ -1527,6 +1547,10 @@
method @androidx.wear.protolayout.expression.RequiresSchemaVersion(major=1, minor=400) public static androidx.wear.protolayout.modifiers.LayoutModifier clipTopRight(androidx.wear.protolayout.modifiers.LayoutModifier, @Dimension(unit=androidx.annotation.Dimension.Companion.DP) float x, optional @Dimension(unit=androidx.annotation.Dimension.Companion.DP) float y);
}
+ public final class BorderKt {
+ method public static androidx.wear.protolayout.modifiers.LayoutModifier border(androidx.wear.protolayout.modifiers.LayoutModifier, @Dimension(unit=androidx.annotation.Dimension.Companion.DP) float width, androidx.wear.protolayout.types.LayoutColor color);
+ }
+
public final class ClickableKt {
method public static androidx.wear.protolayout.ModifiersBuilders.Clickable clickable();
method public static androidx.wear.protolayout.ModifiersBuilders.Clickable clickable(optional androidx.wear.protolayout.ActionBuilders.Action action);
@@ -1557,6 +1581,10 @@
method public static androidx.wear.protolayout.ModifiersBuilders.Modifiers toProtoLayoutModifiers(androidx.wear.protolayout.modifiers.LayoutModifier);
}
+ public final class OpacityKt {
+ method @androidx.wear.protolayout.expression.RequiresSchemaVersion(major=1, minor=400) public static androidx.wear.protolayout.modifiers.LayoutModifier opacity(androidx.wear.protolayout.modifiers.LayoutModifier, @FloatRange(from=0.0, to=1.0) float staticValue, optional androidx.wear.protolayout.expression.DynamicBuilders.DynamicFloat? dynamicValue);
+ }
+
public final class PaddingKt {
method public static androidx.wear.protolayout.modifiers.LayoutModifier padding(androidx.wear.protolayout.modifiers.LayoutModifier, androidx.wear.protolayout.ModifiersBuilders.Padding padding);
method public static androidx.wear.protolayout.modifiers.LayoutModifier padding(androidx.wear.protolayout.modifiers.LayoutModifier, @Dimension(unit=androidx.annotation.Dimension.Companion.DP) float all);
@@ -1572,6 +1600,10 @@
method public static androidx.wear.protolayout.modifiers.LayoutModifier semanticsRole(androidx.wear.protolayout.modifiers.LayoutModifier, int semanticsRole);
}
+ public final class VisibilityKt {
+ method @SuppressCompatibility @androidx.wear.protolayout.expression.ProtoLayoutExperimental @androidx.wear.protolayout.expression.RequiresSchemaVersion(major=1, minor=300) public static androidx.wear.protolayout.modifiers.LayoutModifier visibility(androidx.wear.protolayout.modifiers.LayoutModifier, boolean staticVisibility, optional androidx.wear.protolayout.expression.DynamicBuilders.DynamicBool? dynamicVisibility);
+ }
+
}
package androidx.wear.protolayout.types {
diff --git a/wear/protolayout/protolayout/src/main/java/androidx/wear/protolayout/ResourceBuilders.java b/wear/protolayout/protolayout/src/main/java/androidx/wear/protolayout/ResourceBuilders.java
index 0fff0a2..a51c996 100644
--- a/wear/protolayout/protolayout/src/main/java/androidx/wear/protolayout/ResourceBuilders.java
+++ b/wear/protolayout/protolayout/src/main/java/androidx/wear/protolayout/ResourceBuilders.java
@@ -520,6 +520,15 @@
}
}
+ /** Gets the trigger to start the animation. */
+ public @Nullable Trigger getStartTrigger() {
+ if (mImpl.hasStartTrigger()) {
+ return TriggerBuilders.triggerFromProto(mImpl.getStartTrigger());
+ } else {
+ return null;
+ }
+ }
+
/** Creates a new wrapper instance from the proto. */
@RestrictTo(Scope.LIBRARY_GROUP)
public static @NonNull AndroidLottieResourceByResId fromProto(
@@ -540,6 +549,8 @@
+ getRawResourceId()
+ ", progress="
+ getProgress()
+ + ", startTrigger="
+ + getStartTrigger()
+ "}";
}
@@ -590,6 +601,13 @@
return this;
}
+ /** Sets the trigger to start the animation. */
+ @RequiresSchemaVersion(major = 1, minor = 500)
+ public @NonNull Builder setStartTrigger(@NonNull Trigger startTrigger) {
+ mImpl.setStartTrigger(startTrigger.toTriggerProto());
+ return this;
+ }
+
/** Builds an instance from accumulated values. */
public @NonNull AndroidLottieResourceByResId build() {
return AndroidLottieResourceByResId.fromProto(mImpl.build());
diff --git a/wear/protolayout/protolayout/src/main/java/androidx/wear/protolayout/TriggerBuilders.java b/wear/protolayout/protolayout/src/main/java/androidx/wear/protolayout/TriggerBuilders.java
index 1a96d0e..d05a53c 100644
--- a/wear/protolayout/protolayout/src/main/java/androidx/wear/protolayout/TriggerBuilders.java
+++ b/wear/protolayout/protolayout/src/main/java/androidx/wear/protolayout/TriggerBuilders.java
@@ -48,6 +48,160 @@
return new OnConditionMetTrigger.Builder().setCondition(dynamicBool).build();
}
+ /**
+ * Creates a {@link Trigger} that fires *every time* the layout becomes visible.
+ *
+ * <p>As opposed to {@link #createOnLoadTrigger()}, this will wait until layout is fully visible
+ * before firing a trigger.
+ */
+ @RequiresSchemaVersion(major = 1, minor = 200)
+ public static @NonNull Trigger createOnVisibleTrigger() {
+ return new OnVisibleTrigger.Builder().build();
+ }
+
+ /**
+ * Creates a {@link Trigger} that fires the first time that layout becomes visible.
+ *
+ * <p>As opposed to {@link #createOnVisibleTrigger()}, this will only be fired the first time
+ * that the layout becomes visible.
+ */
+ @RequiresSchemaVersion(major = 1, minor = 200)
+ public static @NonNull Trigger createOnVisibleOnceTrigger() {
+ return new OnVisibleOnceTrigger.Builder().build();
+ }
+
+ /** Triggers when the layout visibility state turns from invisible to fully visible. */
+ @RequiresSchemaVersion(major = 1, minor = 200)
+ public static final class OnVisibleTrigger implements Trigger {
+ private final TriggerProto.OnVisibleTrigger mImpl;
+ private final @Nullable Fingerprint mFingerprint;
+
+ OnVisibleTrigger(TriggerProto.OnVisibleTrigger impl, @Nullable Fingerprint fingerprint) {
+ this.mImpl = impl;
+ this.mFingerprint = fingerprint;
+ }
+
+ @Override
+ @RestrictTo(Scope.LIBRARY_GROUP)
+ public @Nullable Fingerprint getFingerprint() {
+ return mFingerprint;
+ }
+
+ /** Creates a new wrapper instance from the proto. */
+ @RestrictTo(Scope.LIBRARY_GROUP)
+ public static @NonNull OnVisibleTrigger fromProto(
+ TriggerProto.@NonNull OnVisibleTrigger proto, @Nullable Fingerprint fingerprint) {
+ return new OnVisibleTrigger(proto, fingerprint);
+ }
+
+ static @NonNull OnVisibleTrigger fromProto(TriggerProto.@NonNull OnVisibleTrigger proto) {
+ return fromProto(proto, null);
+ }
+
+ /** Returns the internal proto instance. */
+ TriggerProto.@NonNull OnVisibleTrigger toProto() {
+ return mImpl;
+ }
+
+ @Override
+ @RestrictTo(Scope.LIBRARY_GROUP)
+ public TriggerProto.@NonNull Trigger toTriggerProto() {
+ return TriggerProto.Trigger.newBuilder().setOnVisibleTrigger(mImpl).build();
+ }
+
+ @Override
+ public @NonNull String toString() {
+ return "OnVisibleTrigger";
+ }
+
+ /** Builder for {@link OnVisibleTrigger}. */
+ @SuppressWarnings("HiddenSuperclass")
+ public static final class Builder implements Trigger.Builder {
+ private final TriggerProto.OnVisibleTrigger.Builder mImpl =
+ TriggerProto.OnVisibleTrigger.newBuilder();
+ private final Fingerprint mFingerprint = new Fingerprint(1416366796);
+
+ /** Creates an instance of {@link Builder}. */
+ @RequiresSchemaVersion(major = 1, minor = 200)
+ public Builder() {}
+
+ /** Builds an instance from accumulated values. */
+ @Override
+ public @NonNull OnVisibleTrigger build() {
+ return new OnVisibleTrigger(mImpl.build(), mFingerprint);
+ }
+ }
+ }
+
+ /**
+ * Triggers only once when the layout visibility state turns from invisible to fully visible for
+ * the first time.
+ */
+ @RequiresSchemaVersion(major = 1, minor = 200)
+ public static final class OnVisibleOnceTrigger implements Trigger {
+ private final TriggerProto.OnVisibleOnceTrigger mImpl;
+ private final @Nullable Fingerprint mFingerprint;
+
+ OnVisibleOnceTrigger(
+ TriggerProto.OnVisibleOnceTrigger impl, @Nullable Fingerprint fingerprint) {
+ this.mImpl = impl;
+ this.mFingerprint = fingerprint;
+ }
+
+ @Override
+ @RestrictTo(Scope.LIBRARY_GROUP)
+ public @Nullable Fingerprint getFingerprint() {
+ return mFingerprint;
+ }
+
+ /** Creates a new wrapper instance from the proto. */
+ @RestrictTo(Scope.LIBRARY_GROUP)
+ public static @NonNull OnVisibleOnceTrigger fromProto(
+ TriggerProto.@NonNull OnVisibleOnceTrigger proto,
+ @Nullable Fingerprint fingerprint) {
+ return new OnVisibleOnceTrigger(proto, fingerprint);
+ }
+
+ static @NonNull OnVisibleOnceTrigger fromProto(
+ TriggerProto.@NonNull OnVisibleOnceTrigger proto) {
+ return fromProto(proto, null);
+ }
+
+ /** Returns the internal proto instance. */
+ TriggerProto.@NonNull OnVisibleOnceTrigger toProto() {
+ return mImpl;
+ }
+
+ @Override
+ @RestrictTo(Scope.LIBRARY_GROUP)
+ public TriggerProto.@NonNull Trigger toTriggerProto() {
+ return TriggerProto.Trigger.newBuilder().setOnVisibleOnceTrigger(mImpl).build();
+ }
+
+ @Override
+ public @NonNull String toString() {
+ return "OnVisibleOnceTrigger";
+ }
+
+ /** Builder for {@link OnVisibleOnceTrigger}. */
+ @SuppressWarnings("HiddenSuperclass")
+ public static final class Builder implements Trigger.Builder {
+ private final TriggerProto.OnVisibleOnceTrigger.Builder mImpl =
+ TriggerProto.OnVisibleOnceTrigger.newBuilder();
+ private final Fingerprint mFingerprint = new Fingerprint(-1661457257);
+
+ /** Creates an instance of {@link Builder}. */
+ @RequiresSchemaVersion(major = 1, minor = 200)
+ public Builder() {}
+
+ /** Builds an instance from accumulated values. */
+ @Override
+ public @NonNull OnVisibleOnceTrigger build() {
+ return new OnVisibleOnceTrigger(mImpl.build(), mFingerprint);
+ }
+ }
+ }
+
/** Triggers immediately when the layout is loaded / reloaded. */
@RequiresSchemaVersion(major = 1, minor = 200)
static final class OnLoadTrigger implements Trigger {
@@ -225,6 +379,12 @@
@RestrictTo(Scope.LIBRARY_GROUP)
public static @NonNull Trigger triggerFromProto(
TriggerProto.@NonNull Trigger proto, @Nullable Fingerprint fingerprint) {
+ if (proto.hasOnVisibleTrigger()) {
+ return OnVisibleTrigger.fromProto(proto.getOnVisibleTrigger(), fingerprint);
+ }
+ if (proto.hasOnVisibleOnceTrigger()) {
+ return OnVisibleOnceTrigger.fromProto(proto.getOnVisibleOnceTrigger(), fingerprint);
+ }
if (proto.hasOnLoadTrigger()) {
return OnLoadTrigger.fromProto(proto.getOnLoadTrigger(), fingerprint);
}
diff --git a/wear/protolayout/protolayout/src/main/java/androidx/wear/protolayout/modifiers/Border.kt b/wear/protolayout/protolayout/src/main/java/androidx/wear/protolayout/modifiers/Border.kt
new file mode 100644
index 0000000..d3fca75
--- /dev/null
+++ b/wear/protolayout/protolayout/src/main/java/androidx/wear/protolayout/modifiers/Border.kt
@@ -0,0 +1,38 @@
+/*
+ * Copyright 2025 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.wear.protolayout.modifiers
+
+import androidx.annotation.Dimension
+import androidx.annotation.Dimension.Companion.DP
+import androidx.wear.protolayout.ModifiersBuilders.Border
+import androidx.wear.protolayout.types.LayoutColor
+import androidx.wear.protolayout.types.dp
+
+/**
+ * Adds a modifier to apply a border around an element.
+ *
+ * @param width The width of the border, in `DP`.
+ * @param color The color of the border.
+ */
+fun LayoutModifier.border(@Dimension(DP) width: Float, color: LayoutColor): LayoutModifier =
+ this then BaseBorderElement(width, color)
+
+internal class BaseBorderElement(@Dimension(DP) val width: Float, val color: LayoutColor) :
+ LayoutModifier.Element {
+ fun foldIn(initial: Border.Builder?): Border.Builder =
+ (initial ?: Border.Builder()).setWidth(width.dp).setColor(color.prop)
+}
diff --git a/wear/protolayout/protolayout/src/main/java/androidx/wear/protolayout/modifiers/ModifierAppliers.kt b/wear/protolayout/protolayout/src/main/java/androidx/wear/protolayout/modifiers/ModifierAppliers.kt
index 0b5531a..aee3599 100644
--- a/wear/protolayout/protolayout/src/main/java/androidx/wear/protolayout/modifiers/ModifierAppliers.kt
+++ b/wear/protolayout/protolayout/src/main/java/androidx/wear/protolayout/modifiers/ModifierAppliers.kt
@@ -16,15 +16,23 @@
package androidx.wear.protolayout.modifiers
+import android.annotation.SuppressLint
+import androidx.annotation.OptIn
import androidx.wear.protolayout.ModifiersBuilders
import androidx.wear.protolayout.ModifiersBuilders.Background
+import androidx.wear.protolayout.ModifiersBuilders.Border
import androidx.wear.protolayout.ModifiersBuilders.Clickable
import androidx.wear.protolayout.ModifiersBuilders.Corner
import androidx.wear.protolayout.ModifiersBuilders.ElementMetadata
import androidx.wear.protolayout.ModifiersBuilders.Padding
import androidx.wear.protolayout.ModifiersBuilders.Semantics
+import androidx.wear.protolayout.TypeBuilders.BoolProp
+import androidx.wear.protolayout.TypeBuilders.FloatProp
+import androidx.wear.protolayout.expression.ProtoLayoutExperimental
/** Creates a [ModifiersBuilders.Modifiers] from a [LayoutModifier]. */
+@SuppressLint("ProtoLayoutMinSchema")
+@OptIn(ProtoLayoutExperimental::class)
fun LayoutModifier.toProtoLayoutModifiers(): ModifiersBuilders.Modifiers {
var semantics: Semantics.Builder? = null
var background: Background.Builder? = null
@@ -32,6 +40,9 @@
var clickable: Clickable.Builder? = null
var padding: Padding.Builder? = null
var metadata: ElementMetadata.Builder? = null
+ var border: Border.Builder? = null
+ var visible: BoolProp.Builder? = null
+ var opacity: FloatProp.Builder? = null
this.foldIn(Unit) { _, e ->
when (e) {
@@ -41,6 +52,9 @@
is BaseClickableElement -> clickable = e.foldIn(clickable)
is BasePaddingElement -> padding = e.foldIn(padding)
is BaseMetadataElement -> metadata = e.foldIn(metadata)
+ is BaseBorderElement -> border = e.foldIn(border)
+ is BaseVisibilityElement -> visible = e.foldIn(visible)
+ is BaseOpacityElement -> opacity = e.foldIn(opacity)
}
}
@@ -53,6 +67,9 @@
clickable?.let { setClickable(it.build()) }
padding?.let { setPadding(it.build()) }
metadata?.let { setMetadata(it.build()) }
+ border?.let { setBorder(it.build()) }
+ visible?.let { setVisible(it.build()) }
+ opacity?.let { setOpacity(it.build()) }
}
.build()
}
diff --git a/wear/protolayout/protolayout/src/main/java/androidx/wear/protolayout/modifiers/Opacity.kt b/wear/protolayout/protolayout/src/main/java/androidx/wear/protolayout/modifiers/Opacity.kt
new file mode 100644
index 0000000..eeafb8f9
--- /dev/null
+++ b/wear/protolayout/protolayout/src/main/java/androidx/wear/protolayout/modifiers/Opacity.kt
@@ -0,0 +1,49 @@
+/*
+ * Copyright 2025 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.wear.protolayout.modifiers
+
+import android.annotation.SuppressLint
+import androidx.annotation.FloatRange
+import androidx.wear.protolayout.TypeBuilders.FloatProp
+import androidx.wear.protolayout.expression.DynamicBuilders.DynamicFloat
+import androidx.wear.protolayout.expression.RequiresSchemaVersion
+
+/**
+ * Adds a modifier to specify the opacity of the element with a value from 0 to 1, where 0 means the
+ * element is completely transparent and 1 means the element is completely opaque.
+ *
+ * @param staticValue The static value for opacity. This value will be used if [dynamicValue] is
+ * null, or if can't be resolved.
+ * @param dynamicValue The dynamic value for opacity. This can be used to change the opacity of the
+ * element dynamically (without changing the layout definition). To create a smooth transition for
+ * the dynamic change, you can use one of [DynamicFloat.animate] methods.
+ */
+@RequiresSchemaVersion(major = 1, minor = 400)
+fun LayoutModifier.opacity(
+ @FloatRange(from = 0.0, to = 1.0) staticValue: Float,
+ dynamicValue: DynamicFloat? = null
+): LayoutModifier = this then BaseOpacityElement(staticValue, dynamicValue)
+
+@RequiresSchemaVersion(major = 1, minor = 400)
+internal class BaseOpacityElement(val staticValue: Float, val dynamicValue: DynamicFloat? = null) :
+ LayoutModifier.Element {
+ @SuppressLint("ProtoLayoutMinSchema")
+ fun foldIn(initial: FloatProp.Builder?): FloatProp.Builder =
+ (initial ?: FloatProp.Builder(staticValue)).apply {
+ dynamicValue?.let { setDynamicValue(it) }
+ }
+}
diff --git a/wear/protolayout/protolayout/src/main/java/androidx/wear/protolayout/modifiers/Visibility.kt b/wear/protolayout/protolayout/src/main/java/androidx/wear/protolayout/modifiers/Visibility.kt
new file mode 100644
index 0000000..b7eabd7
--- /dev/null
+++ b/wear/protolayout/protolayout/src/main/java/androidx/wear/protolayout/modifiers/Visibility.kt
@@ -0,0 +1,53 @@
+/*
+ * Copyright 2025 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.wear.protolayout.modifiers
+
+import android.annotation.SuppressLint
+import androidx.wear.protolayout.TypeBuilders.BoolProp
+import androidx.wear.protolayout.expression.DynamicBuilders.DynamicBool
+import androidx.wear.protolayout.expression.ProtoLayoutExperimental
+import androidx.wear.protolayout.expression.RequiresSchemaVersion
+
+/**
+ * Adds a modifier to specify the visibility of the element. A hidden element still consume space in
+ * the layout, but will not render any contents, nor will any of its children render any contents.
+ *
+ * Note that hidden elements won't receive input events.
+ *
+ * @param staticVisibility The static value for visibility. This value will be used if
+ * [dynamicVisibility] is null, or if can't be resolved.
+ * @param dynamicVisibility The dynamic value for visibility. This can be used to change the
+ * visibility of the element dynamically (without changing the layout definition).
+ */
+@RequiresSchemaVersion(major = 1, minor = 300)
+@ProtoLayoutExperimental
+fun LayoutModifier.visibility(
+ staticVisibility: Boolean,
+ dynamicVisibility: DynamicBool? = null
+): LayoutModifier = this then BaseVisibilityElement(staticVisibility, dynamicVisibility)
+
+@RequiresSchemaVersion(major = 1, minor = 300)
+internal class BaseVisibilityElement(
+ val visibility: Boolean,
+ val dynamicVisibility: DynamicBool? = null
+) : LayoutModifier.Element {
+ @SuppressLint("ProtoLayoutMinSchema")
+ fun foldIn(initial: BoolProp.Builder?): BoolProp.Builder =
+ (initial ?: BoolProp.Builder(visibility)).apply {
+ dynamicVisibility?.let { setDynamicValue(it) }
+ }
+}
diff --git a/wear/protolayout/protolayout/src/test/java/androidx/wear/protolayout/ResourceBuildersTest.java b/wear/protolayout/protolayout/src/test/java/androidx/wear/protolayout/ResourceBuildersTest.java
index 3591875..c0256c3 100644
--- a/wear/protolayout/protolayout/src/test/java/androidx/wear/protolayout/ResourceBuildersTest.java
+++ b/wear/protolayout/protolayout/src/test/java/androidx/wear/protolayout/ResourceBuildersTest.java
@@ -74,9 +74,25 @@
.setProgress(DynamicBuilders.DynamicFloat.from(new AppDataKey<>(stateKey)))
.build();
- ResourceProto.AndroidLottieResourceByResId avdProto = lottieResource.toProto();
+ ResourceProto.AndroidLottieResourceByResId lottieProto = lottieResource.toProto();
- assertThat(avdProto.getRawResourceId()).isEqualTo(RESOURCE_ID);
- assertThat(avdProto.getProgress().getStateSource().getSourceKey()).isEqualTo(stateKey);
+ assertThat(lottieProto.getRawResourceId()).isEqualTo(RESOURCE_ID);
+ assertThat(lottieProto.getProgress().getStateSource().getSourceKey()).isEqualTo(stateKey);
+ }
+
+ @Test
+ public void lottieAnimation_hasTrigger() {
+ ResourceBuilders.AndroidLottieResourceByResId lottieResource =
+ new ResourceBuilders.AndroidLottieResourceByResId.Builder(RESOURCE_ID)
+ .setStartTrigger(TriggerBuilders.createOnVisibleTrigger())
+ .build();
+
+ ResourceProto.AndroidLottieResourceByResId lottieProto = lottieResource.toProto();
+
+ assertThat(lottieProto.getRawResourceId()).isEqualTo(RESOURCE_ID);
+ assertThat(lottieProto.hasStartTrigger()).isTrue();
+ assertThat(lottieProto.getStartTrigger().hasOnVisibleTrigger()).isTrue();
+ assertThat(lottieProto.getStartTrigger().hasOnVisibleOnceTrigger()).isFalse();
+ assertThat(lottieProto.getStartTrigger().hasOnLoadTrigger()).isFalse();
}
}
diff --git a/wear/protolayout/protolayout/src/test/java/androidx/wear/protolayout/modifiers/ModifiersTest.kt b/wear/protolayout/protolayout/src/test/java/androidx/wear/protolayout/modifiers/ModifiersTest.kt
index 80f8b5e..ca402dc 100644
--- a/wear/protolayout/protolayout/src/test/java/androidx/wear/protolayout/modifiers/ModifiersTest.kt
+++ b/wear/protolayout/protolayout/src/test/java/androidx/wear/protolayout/modifiers/ModifiersTest.kt
@@ -24,6 +24,7 @@
import androidx.wear.protolayout.ModifiersBuilders.SEMANTICS_ROLE_BUTTON
import androidx.wear.protolayout.ModifiersBuilders.SEMANTICS_ROLE_NONE
import androidx.wear.protolayout.expression.AppDataKey
+import androidx.wear.protolayout.expression.DynamicBuilders.DynamicBool
import androidx.wear.protolayout.expression.DynamicBuilders.DynamicInt32
import androidx.wear.protolayout.expression.DynamicBuilders.DynamicString
import androidx.wear.protolayout.expression.DynamicDataBuilders.DynamicDataValue
@@ -241,6 +242,26 @@
assertThat(modifiers.metadata?.tagData).isEqualTo(METADATA_BYTE_ARRAY)
}
+ @Test
+ fun border_toModifier() {
+ val modifier =
+ LayoutModifier.border(width = WIDTH_DP, color = COLOR).toProtoLayoutModifiers()
+
+ assertThat(modifier.border?.width?.value).isEqualTo(WIDTH_DP)
+ assertThat(modifier.border?.color?.argb).isEqualTo(COLOR.prop.argb)
+ }
+
+ @Test
+ fun visibility_toModifier() {
+ val modifier =
+ LayoutModifier.visibility(staticVisibility = false, dynamicVisibility = DYNAMIC_BOOL)
+ .toProtoLayoutModifiers()
+
+ assertThat(modifier.isVisible.value).isEqualTo(false)
+ assertThat(modifier.isVisible.dynamicValue?.toDynamicBoolProto())
+ .isEqualTo(DYNAMIC_BOOL.toDynamicBoolProto())
+ }
+
companion object {
const val STATIC_CONTENT_DESCRIPTION = "content desc"
val DYNAMIC_CONTENT_DESCRIPTION = DynamicString.constant("dynamic content")
@@ -255,5 +276,7 @@
const val PADDING_ALL = 5f
const val METADATA = "metadata"
val METADATA_BYTE_ARRAY = METADATA.toByteArray()
+ const val WIDTH_DP = 5f
+ val DYNAMIC_BOOL = DynamicBool.constant(true)
}
}
diff --git a/wear/wear-phone-interactions/api/1.1.0-beta01.txt b/wear/wear-phone-interactions/api/1.1.0-beta01.txt
new file mode 100644
index 0000000..22d6f21
--- /dev/null
+++ b/wear/wear-phone-interactions/api/1.1.0-beta01.txt
@@ -0,0 +1,170 @@
+// Signature format: 4.0
+package androidx.wear.phone.interactions {
+
+ public final class PhoneTypeHelper {
+ method public static int getPhoneDeviceType(android.content.Context context);
+ field public static final androidx.wear.phone.interactions.PhoneTypeHelper.Companion Companion;
+ field public static final int DEVICE_TYPE_ANDROID = 1; // 0x1
+ field public static final int DEVICE_TYPE_ERROR = 0; // 0x0
+ field public static final int DEVICE_TYPE_IOS = 2; // 0x2
+ field public static final int DEVICE_TYPE_NONE = 4; // 0x4
+ field public static final int DEVICE_TYPE_UNKNOWN = 3; // 0x3
+ }
+
+ public static final class PhoneTypeHelper.Companion {
+ method public int getPhoneDeviceType(android.content.Context context);
+ property public static final int DEVICE_TYPE_ANDROID;
+ property public static final int DEVICE_TYPE_ERROR;
+ property public static final int DEVICE_TYPE_IOS;
+ property public static final int DEVICE_TYPE_NONE;
+ property public static final int DEVICE_TYPE_UNKNOWN;
+ }
+
+}
+
+package androidx.wear.phone.interactions.authentication {
+
+ @RequiresApi(android.os.Build.VERSION_CODES.O) public final class CodeChallenge {
+ ctor public CodeChallenge(androidx.wear.phone.interactions.authentication.CodeVerifier codeVerifier);
+ method public String getValue();
+ property public final String value;
+ }
+
+ @RequiresApi(android.os.Build.VERSION_CODES.O) public final class CodeVerifier {
+ ctor public CodeVerifier();
+ ctor public CodeVerifier(optional int byteLength);
+ ctor public CodeVerifier(String value);
+ method public String getValue();
+ property public final String value;
+ }
+
+ public final class OAuthRequest {
+ method public String getPackageName();
+ method public String getRedirectUrl();
+ method public android.net.Uri getRequestUrl();
+ property public final String packageName;
+ property public final String redirectUrl;
+ property public final android.net.Uri requestUrl;
+ field public static final androidx.wear.phone.interactions.authentication.OAuthRequest.Companion Companion;
+ field public static final String WEAR_REDIRECT_URL_PREFIX = "https://wear.googleapis.com/3p_auth/";
+ field public static final String WEAR_REDIRECT_URL_PREFIX_CN = "https://wear.googleapis-cn.com/3p_auth/";
+ }
+
+ public static final class OAuthRequest.Builder {
+ ctor public OAuthRequest.Builder(android.content.Context context);
+ method @RequiresApi(android.os.Build.VERSION_CODES.O) public androidx.wear.phone.interactions.authentication.OAuthRequest build();
+ method public androidx.wear.phone.interactions.authentication.OAuthRequest.Builder setAuthProviderUrl(android.net.Uri authProviderUrl);
+ method public androidx.wear.phone.interactions.authentication.OAuthRequest.Builder setClientId(String clientId);
+ method public androidx.wear.phone.interactions.authentication.OAuthRequest.Builder setCodeChallenge(androidx.wear.phone.interactions.authentication.CodeChallenge codeChallenge);
+ method public androidx.wear.phone.interactions.authentication.OAuthRequest.Builder setRedirectUrl(android.net.Uri redirectUrl);
+ }
+
+ public static final class OAuthRequest.Companion {
+ property public static final String WEAR_REDIRECT_URL_PREFIX;
+ property public static final String WEAR_REDIRECT_URL_PREFIX_CN;
+ }
+
+ public final class OAuthResponse {
+ method public int getErrorCode();
+ method public android.net.Uri? getResponseUrl();
+ property public final int errorCode;
+ property public final android.net.Uri? responseUrl;
+ }
+
+ public static final class OAuthResponse.Builder {
+ ctor public OAuthResponse.Builder();
+ method public androidx.wear.phone.interactions.authentication.OAuthResponse build();
+ method public androidx.wear.phone.interactions.authentication.OAuthResponse.Builder setErrorCode(int errorCode);
+ method public androidx.wear.phone.interactions.authentication.OAuthResponse.Builder setResponseUrl(android.net.Uri responseUrl);
+ }
+
+ public final class RemoteAuthClient implements java.lang.AutoCloseable {
+ method @UiThread public void close();
+ method public static androidx.wear.phone.interactions.authentication.RemoteAuthClient create(android.content.Context context);
+ method protected void finalize();
+ method public kotlinx.coroutines.flow.Flow<java.lang.Integer> getAvailabilityStatus();
+ method @UiThread public void sendAuthorizationRequest(androidx.wear.phone.interactions.authentication.OAuthRequest request, java.util.concurrent.Executor executor, androidx.wear.phone.interactions.authentication.RemoteAuthClient.Callback clientCallback);
+ property public final kotlinx.coroutines.flow.Flow<java.lang.Integer> availabilityStatus;
+ field public static final androidx.wear.phone.interactions.authentication.RemoteAuthClient.Companion Companion;
+ field public static final int ERROR_PHONE_UNAVAILABLE = 1; // 0x1
+ field public static final int ERROR_UNSUPPORTED = 0; // 0x0
+ field public static final int NO_ERROR = -1; // 0xffffffff
+ field public static final int STATUS_AVAILABLE = 3; // 0x3
+ field public static final int STATUS_TEMPORARILY_UNAVAILABLE = 2; // 0x2
+ field public static final int STATUS_UNAVAILABLE = 1; // 0x1
+ field public static final int STATUS_UNKNOWN = 0; // 0x0
+ }
+
+ public abstract static class RemoteAuthClient.Callback {
+ ctor public RemoteAuthClient.Callback();
+ method @UiThread public abstract void onAuthorizationError(androidx.wear.phone.interactions.authentication.OAuthRequest request, int errorCode);
+ method @UiThread public abstract void onAuthorizationResponse(androidx.wear.phone.interactions.authentication.OAuthRequest request, androidx.wear.phone.interactions.authentication.OAuthResponse response);
+ }
+
+ public static final class RemoteAuthClient.Companion {
+ method public androidx.wear.phone.interactions.authentication.RemoteAuthClient create(android.content.Context context);
+ property public static final int ERROR_PHONE_UNAVAILABLE;
+ property public static final int ERROR_UNSUPPORTED;
+ property public static final int NO_ERROR;
+ property public static final int STATUS_AVAILABLE;
+ property public static final int STATUS_TEMPORARILY_UNAVAILABLE;
+ property public static final int STATUS_UNAVAILABLE;
+ property public static final int STATUS_UNKNOWN;
+ }
+
+ public interface RemoteAuthRequestHandler {
+ method public boolean isAuthSupported();
+ method public void sendAuthRequest(androidx.wear.phone.interactions.authentication.OAuthRequest request, kotlin.Pair<java.lang.String,java.lang.Integer> packageNameAndRequestId);
+ }
+
+ public abstract class RemoteAuthService extends android.app.Service {
+ ctor public RemoteAuthService();
+ method protected final android.os.IBinder onBind(android.content.Intent intent, androidx.wear.phone.interactions.authentication.RemoteAuthRequestHandler remoteAuthRequestHandler);
+ method public static final void sendResponseToCallback(androidx.wear.phone.interactions.authentication.OAuthResponse response, kotlin.Pair<java.lang.String,java.lang.Integer> packageNameAndRequestId);
+ method protected boolean verifyPackageName(android.content.Context context, String? requestPackageName);
+ field public static final androidx.wear.phone.interactions.authentication.RemoteAuthService.Companion Companion;
+ }
+
+ public static final class RemoteAuthService.Companion {
+ method public void sendResponseToCallback(androidx.wear.phone.interactions.authentication.OAuthResponse response, kotlin.Pair<java.lang.String,java.lang.Integer> packageNameAndRequestId);
+ }
+
+}
+
+package androidx.wear.phone.interactions.notifications {
+
+ public final class BridgingConfig {
+ method public java.util.Set<java.lang.String>? getExcludedTags();
+ method public boolean isBridgingEnabled();
+ property public final java.util.Set<java.lang.String>? excludedTags;
+ property public final boolean isBridgingEnabled;
+ }
+
+ public static final class BridgingConfig.Builder {
+ ctor public BridgingConfig.Builder(android.content.Context context, boolean isBridgingEnabled);
+ method public androidx.wear.phone.interactions.notifications.BridgingConfig.Builder addExcludedTag(String tag);
+ method public androidx.wear.phone.interactions.notifications.BridgingConfig.Builder addExcludedTags(java.util.Collection<java.lang.String> tags);
+ method public androidx.wear.phone.interactions.notifications.BridgingConfig build();
+ }
+
+ public fun interface BridgingConfigurationHandler {
+ method public void applyBridgingConfiguration(androidx.wear.phone.interactions.notifications.BridgingConfig bridgingConfig);
+ }
+
+ public final class BridgingManager {
+ method public static androidx.wear.phone.interactions.notifications.BridgingManager fromContext(android.content.Context context);
+ method public void setConfig(androidx.wear.phone.interactions.notifications.BridgingConfig bridgingConfig);
+ field public static final androidx.wear.phone.interactions.notifications.BridgingManager.Companion Companion;
+ }
+
+ public static final class BridgingManager.Companion {
+ method public androidx.wear.phone.interactions.notifications.BridgingManager fromContext(android.content.Context context);
+ }
+
+ public final class BridgingManagerService extends android.app.Service {
+ ctor public BridgingManagerService(android.content.Context context, androidx.wear.phone.interactions.notifications.BridgingConfigurationHandler bridgingConfigurationHandler);
+ method public android.os.IBinder? onBind(android.content.Intent? intent);
+ }
+
+}
+
diff --git a/wear/wear-phone-interactions/api/current.txt b/wear/wear-phone-interactions/api/current.txt
index 4659ee8..22d6f21 100644
--- a/wear/wear-phone-interactions/api/current.txt
+++ b/wear/wear-phone-interactions/api/current.txt
@@ -7,6 +7,7 @@
field public static final int DEVICE_TYPE_ANDROID = 1; // 0x1
field public static final int DEVICE_TYPE_ERROR = 0; // 0x0
field public static final int DEVICE_TYPE_IOS = 2; // 0x2
+ field public static final int DEVICE_TYPE_NONE = 4; // 0x4
field public static final int DEVICE_TYPE_UNKNOWN = 3; // 0x3
}
@@ -15,6 +16,7 @@
property public static final int DEVICE_TYPE_ANDROID;
property public static final int DEVICE_TYPE_ERROR;
property public static final int DEVICE_TYPE_IOS;
+ property public static final int DEVICE_TYPE_NONE;
property public static final int DEVICE_TYPE_UNKNOWN;
}
diff --git a/wear/wear-phone-interactions/api/res-1.1.0-beta01.txt b/wear/wear-phone-interactions/api/res-1.1.0-beta01.txt
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/wear/wear-phone-interactions/api/res-1.1.0-beta01.txt
diff --git a/wear/wear-phone-interactions/api/restricted_1.1.0-beta01.txt b/wear/wear-phone-interactions/api/restricted_1.1.0-beta01.txt
new file mode 100644
index 0000000..0798d72
--- /dev/null
+++ b/wear/wear-phone-interactions/api/restricted_1.1.0-beta01.txt
@@ -0,0 +1,173 @@
+// Signature format: 4.0
+package androidx.wear.phone.interactions {
+
+ public final class PhoneTypeHelper {
+ method public static int getPhoneDeviceType(android.content.Context context);
+ field public static final androidx.wear.phone.interactions.PhoneTypeHelper.Companion Companion;
+ field public static final int DEVICE_TYPE_ANDROID = 1; // 0x1
+ field public static final int DEVICE_TYPE_ERROR = 0; // 0x0
+ field public static final int DEVICE_TYPE_IOS = 2; // 0x2
+ field public static final int DEVICE_TYPE_NONE = 4; // 0x4
+ field public static final int DEVICE_TYPE_UNKNOWN = 3; // 0x3
+ }
+
+ public static final class PhoneTypeHelper.Companion {
+ method public int getPhoneDeviceType(android.content.Context context);
+ property public static final int DEVICE_TYPE_ANDROID;
+ property public static final int DEVICE_TYPE_ERROR;
+ property public static final int DEVICE_TYPE_IOS;
+ property public static final int DEVICE_TYPE_NONE;
+ property public static final int DEVICE_TYPE_UNKNOWN;
+ }
+
+}
+
+package androidx.wear.phone.interactions.authentication {
+
+ @RequiresApi(android.os.Build.VERSION_CODES.O) public final class CodeChallenge {
+ ctor public CodeChallenge(androidx.wear.phone.interactions.authentication.CodeVerifier codeVerifier);
+ method public String getValue();
+ property public final String value;
+ }
+
+ @RequiresApi(android.os.Build.VERSION_CODES.O) public final class CodeVerifier {
+ ctor public CodeVerifier();
+ ctor public CodeVerifier(optional int byteLength);
+ ctor public CodeVerifier(String value);
+ method public String getValue();
+ property public final String value;
+ }
+
+ public final class OAuthRequest {
+ method public String getPackageName();
+ method public String getRedirectUrl();
+ method public android.net.Uri getRequestUrl();
+ property public final String packageName;
+ property public final String redirectUrl;
+ property public final android.net.Uri requestUrl;
+ field public static final androidx.wear.phone.interactions.authentication.OAuthRequest.Companion Companion;
+ field public static final String WEAR_REDIRECT_URL_PREFIX = "https://wear.googleapis.com/3p_auth/";
+ field public static final String WEAR_REDIRECT_URL_PREFIX_CN = "https://wear.googleapis-cn.com/3p_auth/";
+ }
+
+ public static final class OAuthRequest.Builder {
+ ctor public OAuthRequest.Builder(android.content.Context context);
+ method @RequiresApi(android.os.Build.VERSION_CODES.O) public androidx.wear.phone.interactions.authentication.OAuthRequest build();
+ method public androidx.wear.phone.interactions.authentication.OAuthRequest.Builder setAuthProviderUrl(android.net.Uri authProviderUrl);
+ method public androidx.wear.phone.interactions.authentication.OAuthRequest.Builder setClientId(String clientId);
+ method public androidx.wear.phone.interactions.authentication.OAuthRequest.Builder setCodeChallenge(androidx.wear.phone.interactions.authentication.CodeChallenge codeChallenge);
+ method public androidx.wear.phone.interactions.authentication.OAuthRequest.Builder setRedirectUrl(android.net.Uri redirectUrl);
+ }
+
+ public static final class OAuthRequest.Companion {
+ property public static final String WEAR_REDIRECT_URL_PREFIX;
+ property public static final String WEAR_REDIRECT_URL_PREFIX_CN;
+ }
+
+ public final class OAuthResponse {
+ method public int getErrorCode();
+ method public android.net.Uri? getResponseUrl();
+ property @androidx.wear.phone.interactions.authentication.RemoteAuthClient.Companion.ErrorCode public final int errorCode;
+ property public final android.net.Uri? responseUrl;
+ }
+
+ public static final class OAuthResponse.Builder {
+ ctor public OAuthResponse.Builder();
+ method public androidx.wear.phone.interactions.authentication.OAuthResponse build();
+ method public androidx.wear.phone.interactions.authentication.OAuthResponse.Builder setErrorCode(@androidx.wear.phone.interactions.authentication.RemoteAuthClient.Companion.ErrorCode int errorCode);
+ method public androidx.wear.phone.interactions.authentication.OAuthResponse.Builder setResponseUrl(android.net.Uri responseUrl);
+ }
+
+ public final class RemoteAuthClient implements java.lang.AutoCloseable {
+ method @UiThread public void close();
+ method public static androidx.wear.phone.interactions.authentication.RemoteAuthClient create(android.content.Context context);
+ method protected void finalize();
+ method public kotlinx.coroutines.flow.Flow<java.lang.Integer> getAvailabilityStatus();
+ method @UiThread public void sendAuthorizationRequest(androidx.wear.phone.interactions.authentication.OAuthRequest request, java.util.concurrent.Executor executor, androidx.wear.phone.interactions.authentication.RemoteAuthClient.Callback clientCallback);
+ property public final kotlinx.coroutines.flow.Flow<java.lang.Integer> availabilityStatus;
+ field public static final androidx.wear.phone.interactions.authentication.RemoteAuthClient.Companion Companion;
+ field public static final int ERROR_PHONE_UNAVAILABLE = 1; // 0x1
+ field public static final int ERROR_UNSUPPORTED = 0; // 0x0
+ field public static final int NO_ERROR = -1; // 0xffffffff
+ field public static final int STATUS_AVAILABLE = 3; // 0x3
+ field public static final int STATUS_TEMPORARILY_UNAVAILABLE = 2; // 0x2
+ field public static final int STATUS_UNAVAILABLE = 1; // 0x1
+ field public static final int STATUS_UNKNOWN = 0; // 0x0
+ }
+
+ public abstract static class RemoteAuthClient.Callback {
+ ctor public RemoteAuthClient.Callback();
+ method @UiThread public abstract void onAuthorizationError(androidx.wear.phone.interactions.authentication.OAuthRequest request, @androidx.wear.phone.interactions.authentication.RemoteAuthClient.Companion.ErrorCode int errorCode);
+ method @UiThread public abstract void onAuthorizationResponse(androidx.wear.phone.interactions.authentication.OAuthRequest request, androidx.wear.phone.interactions.authentication.OAuthResponse response);
+ }
+
+ public static final class RemoteAuthClient.Companion {
+ method public androidx.wear.phone.interactions.authentication.RemoteAuthClient create(android.content.Context context);
+ property public static final int ERROR_PHONE_UNAVAILABLE;
+ property public static final int ERROR_UNSUPPORTED;
+ property public static final int NO_ERROR;
+ property public static final int STATUS_AVAILABLE;
+ property public static final int STATUS_TEMPORARILY_UNAVAILABLE;
+ property public static final int STATUS_UNAVAILABLE;
+ property public static final int STATUS_UNKNOWN;
+ }
+
+ @IntDef({androidx.wear.phone.interactions.authentication.RemoteAuthClient.NO_ERROR, androidx.wear.phone.interactions.authentication.RemoteAuthClient.ERROR_UNSUPPORTED, androidx.wear.phone.interactions.authentication.RemoteAuthClient.ERROR_PHONE_UNAVAILABLE}) @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP) @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.SOURCE) public static @interface RemoteAuthClient.Companion.ErrorCode {
+ }
+
+ public interface RemoteAuthRequestHandler {
+ method public boolean isAuthSupported();
+ method public void sendAuthRequest(androidx.wear.phone.interactions.authentication.OAuthRequest request, kotlin.Pair<java.lang.String,java.lang.Integer> packageNameAndRequestId);
+ }
+
+ public abstract class RemoteAuthService extends android.app.Service {
+ ctor public RemoteAuthService();
+ method protected final android.os.IBinder onBind(android.content.Intent intent, androidx.wear.phone.interactions.authentication.RemoteAuthRequestHandler remoteAuthRequestHandler);
+ method public static final void sendResponseToCallback(androidx.wear.phone.interactions.authentication.OAuthResponse response, kotlin.Pair<java.lang.String,java.lang.Integer> packageNameAndRequestId);
+ method protected boolean verifyPackageName(android.content.Context context, String? requestPackageName);
+ field public static final androidx.wear.phone.interactions.authentication.RemoteAuthService.Companion Companion;
+ }
+
+ public static final class RemoteAuthService.Companion {
+ method public void sendResponseToCallback(androidx.wear.phone.interactions.authentication.OAuthResponse response, kotlin.Pair<java.lang.String,java.lang.Integer> packageNameAndRequestId);
+ }
+
+}
+
+package androidx.wear.phone.interactions.notifications {
+
+ public final class BridgingConfig {
+ method public java.util.Set<java.lang.String>? getExcludedTags();
+ method public boolean isBridgingEnabled();
+ property public final java.util.Set<java.lang.String>? excludedTags;
+ property public final boolean isBridgingEnabled;
+ }
+
+ public static final class BridgingConfig.Builder {
+ ctor public BridgingConfig.Builder(android.content.Context context, boolean isBridgingEnabled);
+ method public androidx.wear.phone.interactions.notifications.BridgingConfig.Builder addExcludedTag(String tag);
+ method public androidx.wear.phone.interactions.notifications.BridgingConfig.Builder addExcludedTags(java.util.Collection<java.lang.String> tags);
+ method public androidx.wear.phone.interactions.notifications.BridgingConfig build();
+ }
+
+ public fun interface BridgingConfigurationHandler {
+ method public void applyBridgingConfiguration(androidx.wear.phone.interactions.notifications.BridgingConfig bridgingConfig);
+ }
+
+ public final class BridgingManager {
+ method public static androidx.wear.phone.interactions.notifications.BridgingManager fromContext(android.content.Context context);
+ method public void setConfig(androidx.wear.phone.interactions.notifications.BridgingConfig bridgingConfig);
+ field public static final androidx.wear.phone.interactions.notifications.BridgingManager.Companion Companion;
+ }
+
+ public static final class BridgingManager.Companion {
+ method public androidx.wear.phone.interactions.notifications.BridgingManager fromContext(android.content.Context context);
+ }
+
+ public final class BridgingManagerService extends android.app.Service {
+ ctor public BridgingManagerService(android.content.Context context, androidx.wear.phone.interactions.notifications.BridgingConfigurationHandler bridgingConfigurationHandler);
+ method public android.os.IBinder? onBind(android.content.Intent? intent);
+ }
+
+}
+
diff --git a/wear/wear-phone-interactions/api/restricted_current.txt b/wear/wear-phone-interactions/api/restricted_current.txt
index 8b63c78..0798d72 100644
--- a/wear/wear-phone-interactions/api/restricted_current.txt
+++ b/wear/wear-phone-interactions/api/restricted_current.txt
@@ -7,6 +7,7 @@
field public static final int DEVICE_TYPE_ANDROID = 1; // 0x1
field public static final int DEVICE_TYPE_ERROR = 0; // 0x0
field public static final int DEVICE_TYPE_IOS = 2; // 0x2
+ field public static final int DEVICE_TYPE_NONE = 4; // 0x4
field public static final int DEVICE_TYPE_UNKNOWN = 3; // 0x3
}
@@ -15,6 +16,7 @@
property public static final int DEVICE_TYPE_ANDROID;
property public static final int DEVICE_TYPE_ERROR;
property public static final int DEVICE_TYPE_IOS;
+ property public static final int DEVICE_TYPE_NONE;
property public static final int DEVICE_TYPE_UNKNOWN;
}
diff --git a/wear/wear-phone-interactions/src/main/java/androidx/wear/phone/interactions/PhoneTypeHelper.kt b/wear/wear-phone-interactions/src/main/java/androidx/wear/phone/interactions/PhoneTypeHelper.kt
index 4be01cd..676a0ca 100644
--- a/wear/wear-phone-interactions/src/main/java/androidx/wear/phone/interactions/PhoneTypeHelper.kt
+++ b/wear/wear-phone-interactions/src/main/java/androidx/wear/phone/interactions/PhoneTypeHelper.kt
@@ -34,9 +34,14 @@
.path(BLUETOOTH_MODE)
.build()
+ /**
+ * These values follow the values of platform constants defined in
+ * [Settings.Global.Wearable.PAIRED_DEVICE_OS_TYPE].
+ */
internal const val UNKNOWN_MODE = 0
internal const val ANDROID_MODE = 1
internal const val IOS_MODE = 2
+ internal const val NONE_PAIRED_MODE = 3
/** Indicates an error returned retrieving the type of phone we are paired to. */
public const val DEVICE_TYPE_ERROR: Int = 0
@@ -50,18 +55,22 @@
/** Indicates unknown type of phone we are paired to. */
public const val DEVICE_TYPE_UNKNOWN: Int = 3
+ /** Indicates that device is not paired to phone. */
+ public const val DEVICE_TYPE_NONE: Int = 4
+
/**
* Returns the type of phone handset this Wear OS device has been paired with.
*
- * @return one of `DEVICE_TYPE_ERROR`, `DEVICE_TYPE_ANDROID`, `DEVICE_TYPE_IOS` or
- * `DEVICE_TYPE_UNKNOWN` indicating we had an error while determining the phone type, we
- * are paired to an Android phone, we are paired to an iOS phone or we could not determine
- * the phone type respectively.
+ * @return one of `DEVICE_TYPE_ERROR`, `DEVICE_TYPE_ANDROID`, `DEVICE_TYPE_IOS`,
+ * `DEVICE_TYPE_UNKNOWN` or `DEVICE_TYPE_NONE` indicating we had an error while
+ * determining the phone type, we are paired to an Android phone, we are paired to an iOS
+ * phone, we could not determine the phone type respectively, or no phone is paired
+ * respectively.
*/
@DeviceFamily
@JvmStatic
public fun getPhoneDeviceType(context: Context): Int {
- var bluetoothMode = UNKNOWN_MODE
+ var pairedDeviceType = UNKNOWN_MODE
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
val cursor =
context.contentResolver.query(BLUETOOTH_MODE_URI, null, null, null, null)
@@ -69,34 +78,41 @@
cursor.use {
while (it.moveToNext()) {
if (BLUETOOTH_MODE == it.getString(0)) {
- bluetoothMode = it.getInt(1)
+ pairedDeviceType = it.getInt(1)
break
}
}
}
+ return when (pairedDeviceType) {
+ ANDROID_MODE -> DEVICE_TYPE_ANDROID
+ IOS_MODE -> DEVICE_TYPE_IOS
+ else -> DEVICE_TYPE_UNKNOWN
+ }
} else if (
Build.VERSION.SDK_INT == Build.VERSION_CODES.UPSIDE_DOWN_CAKE &&
context.applicationInfo.targetSdkVersion > Build.VERSION_CODES.UPSIDE_DOWN_CAKE
) {
return DEVICE_TYPE_ANDROID
- } else {
- bluetoothMode =
- Settings.Global.getInt(
- context.contentResolver,
- PAIRED_DEVICE_OS_TYPE,
- UNKNOWN_MODE
- )
}
- return when (bluetoothMode) {
+ pairedDeviceType =
+ Settings.Global.getInt(context.contentResolver, PAIRED_DEVICE_OS_TYPE, UNKNOWN_MODE)
+ return when (pairedDeviceType) {
ANDROID_MODE -> DEVICE_TYPE_ANDROID
IOS_MODE -> DEVICE_TYPE_IOS
+ NONE_PAIRED_MODE -> DEVICE_TYPE_NONE
else -> DEVICE_TYPE_UNKNOWN
}
}
/** Annotates a value of DeviceType. */
@Retention(AnnotationRetention.SOURCE)
- @IntDef(DEVICE_TYPE_ERROR, DEVICE_TYPE_ANDROID, DEVICE_TYPE_IOS, DEVICE_TYPE_UNKNOWN)
+ @IntDef(
+ DEVICE_TYPE_ERROR,
+ DEVICE_TYPE_ANDROID,
+ DEVICE_TYPE_IOS,
+ DEVICE_TYPE_UNKNOWN,
+ DEVICE_TYPE_NONE
+ )
internal annotation class DeviceFamily
}
}
diff --git a/wear/wear-phone-interactions/src/test/java/androidx/wear/phone/interactions/PhoneTypeHelperTest.kt b/wear/wear-phone-interactions/src/test/java/androidx/wear/phone/interactions/PhoneTypeHelperTest.kt
index afcf1bb..57cc0e9 100644
--- a/wear/wear-phone-interactions/src/test/java/androidx/wear/phone/interactions/PhoneTypeHelperTest.kt
+++ b/wear/wear-phone-interactions/src/test/java/androidx/wear/phone/interactions/PhoneTypeHelperTest.kt
@@ -175,6 +175,18 @@
.isEqualTo(PhoneTypeHelper.DEVICE_TYPE_IOS)
}
+ @Test
+ @Config(minSdk = 35)
+ fun testGetDeviceType_returnsNone() {
+ Settings.Global.putInt(
+ contentResolver,
+ PhoneTypeHelper.PAIRED_DEVICE_OS_TYPE,
+ PhoneTypeHelper.NONE_PAIRED_MODE
+ )
+ assertThat(getPhoneDeviceType(ApplicationProvider.getApplicationContext()))
+ .isEqualTo(PhoneTypeHelper.DEVICE_TYPE_NONE)
+ }
+
companion object {
private fun createFakeBluetoothModeCursor(bluetoothMode: Int): Cursor {
val cursor = MatrixCursor(arrayOf("key", "value"))
diff --git a/webkit/integration-tests/instrumentation/src/androidTest/java/androidx/webkit/PrefetchTest.java b/webkit/integration-tests/instrumentation/src/androidTest/java/androidx/webkit/PrefetchTest.java
new file mode 100644
index 0000000..5f09ac3
--- /dev/null
+++ b/webkit/integration-tests/instrumentation/src/androidTest/java/androidx/webkit/PrefetchTest.java
@@ -0,0 +1,103 @@
+/*
+ * Copyright 2025 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.webkit;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertThrows;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SmallTest;
+import androidx.webkit.test.common.WebkitUtils;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+public class PrefetchTest {
+
+ /**
+ * Test setting valid values for
+ * {@link SpeculativeLoadingConfig.Builder#setPrefetchTtlSeconds(int)}
+ */
+ @Test
+ public void testTTLValidValues() {
+ WebkitUtils.checkFeature(WebViewFeature.SPECULATIVE_LOADING_CONFIG);
+ SpeculativeLoadingConfig.Builder builder = new SpeculativeLoadingConfig.Builder();
+ // lower values
+ builder.setPrefetchTtlSeconds(1);
+ assertEquals(1, builder.build().getPrefetchTtlSeconds());
+
+ builder.setPrefetchTtlSeconds(Integer.MAX_VALUE - 1);
+ assertEquals(Integer.MAX_VALUE - 1, builder.build().getMaxPrefetches());
+
+ builder.setPrefetchTtlSeconds(5685);
+ assertEquals(5685, builder.build().getMaxPrefetches());
+ }
+
+ /**
+ * Test setting valid values for {@link SpeculativeLoadingConfig.Builder#setMaxPrefetches(int)}
+ */
+ @Test
+ public void testMaxPrefetchesValidValues() {
+ WebkitUtils.checkFeature(WebViewFeature.SPECULATIVE_LOADING_CONFIG);
+ SpeculativeLoadingConfig.Builder builder = new SpeculativeLoadingConfig.Builder();
+ builder.setMaxPrefetches(1);
+ assertEquals(1, builder.build().getMaxPrefetches());
+
+ builder.setPrefetchTtlSeconds(SpeculativeLoadingConfig.ABSOLUTE_MAX_PREFETCHES);
+ assertEquals(SpeculativeLoadingConfig.ABSOLUTE_MAX_PREFETCHES,
+ builder.build().getMaxPrefetches());
+ }
+
+ /**
+ * Test setting out-of-range values for
+ * {@link SpeculativeLoadingConfig.Builder#setPrefetchTtlSeconds(int)}
+ */
+ @Test
+ public void testTTLLimit() {
+ WebkitUtils.checkFeature(WebViewFeature.SPECULATIVE_LOADING_CONFIG);
+ SpeculativeLoadingConfig.Builder builder = new SpeculativeLoadingConfig.Builder();
+
+ IllegalArgumentException expectedException = assertThrows(IllegalArgumentException.class,
+ () -> builder.setPrefetchTtlSeconds(0));
+ assertEquals("Prefetch TTL must be greater than 0", expectedException.getMessage());
+ }
+
+ /**
+ * Test setting out-of-range values for
+ * {@link SpeculativeLoadingConfig.Builder#setMaxPrefetches(int)}
+ */
+ @Test
+ public void testMaxPrefetchesLimit() {
+ WebkitUtils.checkFeature(WebViewFeature.SPECULATIVE_LOADING_CONFIG);
+ SpeculativeLoadingConfig.Builder builder = new SpeculativeLoadingConfig.Builder();
+
+ // lower bound
+ IllegalArgumentException expectedException = assertThrows(IllegalArgumentException.class,
+ () -> builder.setMaxPrefetches(0));
+ assertEquals("Max prefetches must be greater than 0", expectedException.getMessage());
+
+ // upper bound
+ expectedException = assertThrows(IllegalArgumentException.class,
+ () -> builder.setMaxPrefetches(
+ SpeculativeLoadingConfig.ABSOLUTE_MAX_PREFETCHES + 1));
+ assertEquals("Max prefetches cannot exceed"
+ + SpeculativeLoadingConfig.ABSOLUTE_MAX_PREFETCHES, expectedException.getMessage());
+ }
+
+}
diff --git a/webkit/webkit/api/current.txt b/webkit/webkit/api/current.txt
index d147a89..4a0366f 100644
--- a/webkit/webkit/api/current.txt
+++ b/webkit/webkit/api/current.txt
@@ -80,6 +80,7 @@
method @AnyThread @RequiresFeature(name=androidx.webkit.WebViewFeature.MULTI_PROFILE, enforcement="androidx.webkit.WebViewFeature#isFeatureSupported") public android.webkit.WebStorage getWebStorage();
method @SuppressCompatibility @RequiresFeature(name=androidx.webkit.WebViewFeature.PROFILE_URL_PREFETCH, enforcement="androidx.webkit.WebViewFeature#isFeatureSupported") @UiThread @androidx.webkit.Profile.ExperimentalUrlPrefetch public void prefetchUrlAsync(String, android.os.CancellationSignal?, java.util.concurrent.Executor, androidx.webkit.OutcomeReceiverCompat<java.lang.Void!,androidx.webkit.PrefetchException!>);
method @SuppressCompatibility @RequiresFeature(name=androidx.webkit.WebViewFeature.PROFILE_URL_PREFETCH, enforcement="androidx.webkit.WebViewFeature#isFeatureSupported") @UiThread @androidx.webkit.Profile.ExperimentalUrlPrefetch public void prefetchUrlAsync(String, android.os.CancellationSignal?, java.util.concurrent.Executor, androidx.webkit.SpeculativeLoadingParameters, androidx.webkit.OutcomeReceiverCompat<java.lang.Void!,androidx.webkit.PrefetchException!>);
+ method @SuppressCompatibility @RequiresFeature(name=androidx.webkit.WebViewFeature.SPECULATIVE_LOADING_CONFIG, enforcement="androidx.webkit.WebViewFeature#isFeatureSupported") @UiThread @androidx.webkit.Profile.ExperimentalUrlPrefetch public void setSpeculativeLoadingConfig(androidx.webkit.SpeculativeLoadingConfig);
field public static final String DEFAULT_PROFILE_NAME = "Default";
}
@@ -162,6 +163,21 @@
method @RequiresFeature(name="REQUESTED_WITH_HEADER_ALLOW_LIST", enforcement="androidx.webkit.WebViewFeature#isFeatureSupported") public abstract void setRequestedWithHeaderOriginAllowList(java.util.Set<java.lang.String!>);
}
+ @SuppressCompatibility @androidx.webkit.Profile.ExperimentalUrlPrefetch public class SpeculativeLoadingConfig {
+ method @IntRange(from=1, to=androidx.webkit.SpeculativeLoadingConfig.ABSOLUTE_MAX_PREFETCHES) public int getMaxPrefetches();
+ method @IntRange(from=1, to=java.lang.Integer.MAX_VALUE) public int getPrefetchTtlSeconds();
+ field public static final int ABSOLUTE_MAX_PREFETCHES = 20; // 0x14
+ field public static final int DEFAULT_MAX_PREFETCHES = 10; // 0xa
+ field public static final int DEFAULT_TTL_SECS = 60; // 0x3c
+ }
+
+ @SuppressCompatibility @androidx.webkit.Profile.ExperimentalUrlPrefetch public static final class SpeculativeLoadingConfig.Builder {
+ ctor public SpeculativeLoadingConfig.Builder();
+ method @SuppressCompatibility @androidx.webkit.Profile.ExperimentalUrlPrefetch public androidx.webkit.SpeculativeLoadingConfig build();
+ method public androidx.webkit.SpeculativeLoadingConfig.Builder setMaxPrefetches(@IntRange(from=1, to=androidx.webkit.SpeculativeLoadingConfig.ABSOLUTE_MAX_PREFETCHES) int);
+ method public androidx.webkit.SpeculativeLoadingConfig.Builder setPrefetchTtlSeconds(@IntRange(from=1, to=java.lang.Integer.MAX_VALUE) int);
+ }
+
@SuppressCompatibility @RequiresFeature(name=androidx.webkit.WebViewFeature.PROFILE_URL_PREFETCH, enforcement="androidx.webkit.WebViewFeature#isFeatureSupported") @androidx.webkit.Profile.ExperimentalUrlPrefetch public final class SpeculativeLoadingParameters {
method public java.util.Map<java.lang.String!,java.lang.String!> getAdditionalHeaders();
method public androidx.webkit.NoVarySearchHeader? getExpectedNoVarySearchData();
@@ -484,6 +500,7 @@
field public static final String SERVICE_WORKER_SHOULD_INTERCEPT_REQUEST = "SERVICE_WORKER_SHOULD_INTERCEPT_REQUEST";
field public static final String SHOULD_OVERRIDE_WITH_REDIRECTS = "SHOULD_OVERRIDE_WITH_REDIRECTS";
field public static final String SPECULATIVE_LOADING = "SPECULATIVE_LOADING_STATUS";
+ field @SuppressCompatibility @androidx.webkit.Profile.ExperimentalUrlPrefetch public static final String SPECULATIVE_LOADING_CONFIG = "SPECULATIVE_LOADING_CONFIG";
field public static final String STARTUP_FEATURE_CONFIGURE_PARTITIONED_COOKIES = "STARTUP_FEATURE_CONFIGURE_PARTITIONED_COOKIES";
field public static final String STARTUP_FEATURE_SET_DATA_DIRECTORY_SUFFIX = "STARTUP_FEATURE_SET_DATA_DIRECTORY_SUFFIX";
field public static final String STARTUP_FEATURE_SET_DIRECTORY_BASE_PATHS = "STARTUP_FEATURE_SET_DIRECTORY_BASE_PATHS";
diff --git a/webkit/webkit/api/restricted_current.txt b/webkit/webkit/api/restricted_current.txt
index d147a89..4a0366f 100644
--- a/webkit/webkit/api/restricted_current.txt
+++ b/webkit/webkit/api/restricted_current.txt
@@ -80,6 +80,7 @@
method @AnyThread @RequiresFeature(name=androidx.webkit.WebViewFeature.MULTI_PROFILE, enforcement="androidx.webkit.WebViewFeature#isFeatureSupported") public android.webkit.WebStorage getWebStorage();
method @SuppressCompatibility @RequiresFeature(name=androidx.webkit.WebViewFeature.PROFILE_URL_PREFETCH, enforcement="androidx.webkit.WebViewFeature#isFeatureSupported") @UiThread @androidx.webkit.Profile.ExperimentalUrlPrefetch public void prefetchUrlAsync(String, android.os.CancellationSignal?, java.util.concurrent.Executor, androidx.webkit.OutcomeReceiverCompat<java.lang.Void!,androidx.webkit.PrefetchException!>);
method @SuppressCompatibility @RequiresFeature(name=androidx.webkit.WebViewFeature.PROFILE_URL_PREFETCH, enforcement="androidx.webkit.WebViewFeature#isFeatureSupported") @UiThread @androidx.webkit.Profile.ExperimentalUrlPrefetch public void prefetchUrlAsync(String, android.os.CancellationSignal?, java.util.concurrent.Executor, androidx.webkit.SpeculativeLoadingParameters, androidx.webkit.OutcomeReceiverCompat<java.lang.Void!,androidx.webkit.PrefetchException!>);
+ method @SuppressCompatibility @RequiresFeature(name=androidx.webkit.WebViewFeature.SPECULATIVE_LOADING_CONFIG, enforcement="androidx.webkit.WebViewFeature#isFeatureSupported") @UiThread @androidx.webkit.Profile.ExperimentalUrlPrefetch public void setSpeculativeLoadingConfig(androidx.webkit.SpeculativeLoadingConfig);
field public static final String DEFAULT_PROFILE_NAME = "Default";
}
@@ -162,6 +163,21 @@
method @RequiresFeature(name="REQUESTED_WITH_HEADER_ALLOW_LIST", enforcement="androidx.webkit.WebViewFeature#isFeatureSupported") public abstract void setRequestedWithHeaderOriginAllowList(java.util.Set<java.lang.String!>);
}
+ @SuppressCompatibility @androidx.webkit.Profile.ExperimentalUrlPrefetch public class SpeculativeLoadingConfig {
+ method @IntRange(from=1, to=androidx.webkit.SpeculativeLoadingConfig.ABSOLUTE_MAX_PREFETCHES) public int getMaxPrefetches();
+ method @IntRange(from=1, to=java.lang.Integer.MAX_VALUE) public int getPrefetchTtlSeconds();
+ field public static final int ABSOLUTE_MAX_PREFETCHES = 20; // 0x14
+ field public static final int DEFAULT_MAX_PREFETCHES = 10; // 0xa
+ field public static final int DEFAULT_TTL_SECS = 60; // 0x3c
+ }
+
+ @SuppressCompatibility @androidx.webkit.Profile.ExperimentalUrlPrefetch public static final class SpeculativeLoadingConfig.Builder {
+ ctor public SpeculativeLoadingConfig.Builder();
+ method @SuppressCompatibility @androidx.webkit.Profile.ExperimentalUrlPrefetch public androidx.webkit.SpeculativeLoadingConfig build();
+ method public androidx.webkit.SpeculativeLoadingConfig.Builder setMaxPrefetches(@IntRange(from=1, to=androidx.webkit.SpeculativeLoadingConfig.ABSOLUTE_MAX_PREFETCHES) int);
+ method public androidx.webkit.SpeculativeLoadingConfig.Builder setPrefetchTtlSeconds(@IntRange(from=1, to=java.lang.Integer.MAX_VALUE) int);
+ }
+
@SuppressCompatibility @RequiresFeature(name=androidx.webkit.WebViewFeature.PROFILE_URL_PREFETCH, enforcement="androidx.webkit.WebViewFeature#isFeatureSupported") @androidx.webkit.Profile.ExperimentalUrlPrefetch public final class SpeculativeLoadingParameters {
method public java.util.Map<java.lang.String!,java.lang.String!> getAdditionalHeaders();
method public androidx.webkit.NoVarySearchHeader? getExpectedNoVarySearchData();
@@ -484,6 +500,7 @@
field public static final String SERVICE_WORKER_SHOULD_INTERCEPT_REQUEST = "SERVICE_WORKER_SHOULD_INTERCEPT_REQUEST";
field public static final String SHOULD_OVERRIDE_WITH_REDIRECTS = "SHOULD_OVERRIDE_WITH_REDIRECTS";
field public static final String SPECULATIVE_LOADING = "SPECULATIVE_LOADING_STATUS";
+ field @SuppressCompatibility @androidx.webkit.Profile.ExperimentalUrlPrefetch public static final String SPECULATIVE_LOADING_CONFIG = "SPECULATIVE_LOADING_CONFIG";
field public static final String STARTUP_FEATURE_CONFIGURE_PARTITIONED_COOKIES = "STARTUP_FEATURE_CONFIGURE_PARTITIONED_COOKIES";
field public static final String STARTUP_FEATURE_SET_DATA_DIRECTORY_SUFFIX = "STARTUP_FEATURE_SET_DATA_DIRECTORY_SUFFIX";
field public static final String STARTUP_FEATURE_SET_DIRECTORY_BASE_PATHS = "STARTUP_FEATURE_SET_DIRECTORY_BASE_PATHS";
diff --git a/webkit/webkit/src/main/java/androidx/webkit/Profile.java b/webkit/webkit/src/main/java/androidx/webkit/Profile.java
index ec6fcb1..370e930 100644
--- a/webkit/webkit/src/main/java/androidx/webkit/Profile.java
+++ b/webkit/webkit/src/main/java/androidx/webkit/Profile.java
@@ -236,4 +236,22 @@
@NonNull Executor callbackExecutor,
@NonNull OutcomeReceiverCompat<Void, PrefetchException> operationCallback);
+ /**
+ * Sets the {@link SpeculativeLoadingConfig} for the current profile session.
+ * These configurations will be applied to any Prefetch requests made after they are set;
+ * they will not be applied to in-flight requests.
+ * <p>
+ * These configurations will be applied to any prefetch requests initiated by
+ * a prerender request. This applies specifically to WebViews that are
+ * associated with this Profile.
+ * <p>
+ * @param speculativeLoadingConfig the config to set for this profile session.
+ */
+ @RequiresFeature(name = WebViewFeature.SPECULATIVE_LOADING_CONFIG,
+ enforcement = "androidx.webkit.WebViewFeature#isFeatureSupported")
+ @UiThread
+ @ExperimentalUrlPrefetch
+ void setSpeculativeLoadingConfig(@NonNull SpeculativeLoadingConfig
+ speculativeLoadingConfig);
+
}
diff --git a/webkit/webkit/src/main/java/androidx/webkit/SpeculativeLoadingConfig.java b/webkit/webkit/src/main/java/androidx/webkit/SpeculativeLoadingConfig.java
new file mode 100644
index 0000000..51ef8bd
--- /dev/null
+++ b/webkit/webkit/src/main/java/androidx/webkit/SpeculativeLoadingConfig.java
@@ -0,0 +1,151 @@
+/*
+ * Copyright 2025 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.webkit;
+
+import androidx.annotation.IntRange;
+
+import org.jspecify.annotations.NonNull;
+
+/**
+ * Represents a configuration for speculative loading in a {@link Profile} instance. This should
+ * be set using {@link Profile#setSpeculativeLoadingConfig(SpeculativeLoadingConfig)}
+ */
[email protected]
+public class SpeculativeLoadingConfig {
+
+ /**
+ * The absolute maximum number of prefetches allowed in cache.
+ */
+ public static final int ABSOLUTE_MAX_PREFETCHES = 20;
+
+ /**
+ * The default Time-to-Live (TTL) in seconds for prefetched data.
+ */
+ public static final int DEFAULT_TTL_SECS = 60;
+
+ /**
+ * The default number of prefetches allowed in cache.
+ */
+ public static final int DEFAULT_MAX_PREFETCHES = 10;
+
+ private final int mPrefetchTTLSeconds;
+
+ private final int mMaxPrefetches;
+
+ /**
+ * Private constructors, the application will need to use
+ * {@link Builder} for constructing instances of
+ * this class.
+ */
+ private SpeculativeLoadingConfig(int ttlSecs, int max) {
+ mPrefetchTTLSeconds = ttlSecs;
+ mMaxPrefetches = max;
+ }
+
+ /**
+ * The "time to live" for a prefetch inside of the prefetch cache.
+ * This is representative of the maximum time that a prefetch is considered
+ * valid and can be served to a navigation. This value is in seconds and
+ * defaults to {@link SpeculativeLoadingConfig#DEFAULT_TTL_SECS}.
+ */
+ @IntRange(from = 1, to = Integer.MAX_VALUE)
+ public int getPrefetchTtlSeconds() {
+ return mPrefetchTTLSeconds;
+ }
+
+ /**
+ * The max amount of prefetches that can live in the cache. Defaults to
+ * {@link SpeculativeLoadingConfig#DEFAULT_MAX_PREFETCHES}.
+ * <p>
+ * Cannot exceed {@link SpeculativeLoadingConfig#ABSOLUTE_MAX_PREFETCHES}.
+ */
+ @IntRange(from = 1, to = ABSOLUTE_MAX_PREFETCHES)
+ public int getMaxPrefetches() {
+ return mMaxPrefetches;
+ }
+
+ @Profile.ExperimentalUrlPrefetch
+ public static final class Builder {
+ private int mPrefetchTTLSeconds = DEFAULT_TTL_SECS;
+ private int mMaxPrefetches = DEFAULT_MAX_PREFETCHES;
+
+ public Builder() {
+ }
+
+ /**
+ * Sets the Time-to-Live (TTL) in seconds for prefetched data.
+ * <p>
+ * This value determines how long prefetched data will be considered valid before it is
+ * refreshed.
+ *
+ * @param ttlSeconds The TTL value in seconds. Must be a positive integer.
+ * @return This builder instance for method chaining.
+ * @throws IllegalArgumentException If {@code ttlSeconds} is less than 1.
+ * @see Builder#build()
+ */
+ @NonNull
+ public Builder setPrefetchTtlSeconds(
+ @IntRange(from = 1, to = Integer.MAX_VALUE) int ttlSeconds) {
+ if (ttlSeconds <= 0) {
+ throw new IllegalArgumentException("Prefetch TTL must be greater than 0");
+ }
+ mPrefetchTTLSeconds = ttlSeconds;
+ return this;
+ }
+
+ /**
+ * Sets the maximum number of allowed prefetches.
+ *
+ * <p>
+ * This value limits the number of prefetch data that can live in the cache.
+ *
+ * @param max The maximum number of prefetches. Must be a positive integer and not exceed
+ * {@link SpeculativeLoadingConfig#ABSOLUTE_MAX_PREFETCHES}.
+ * @return This builder instance for method chaining.
+ * @throws IllegalArgumentException If {@code max} is less than 1 or greater than
+ * {@link SpeculativeLoadingConfig#ABSOLUTE_MAX_PREFETCHES}.
+ * @see Builder#build()
+ */
+ @NonNull
+ public Builder setMaxPrefetches(@IntRange(from = 1, to = ABSOLUTE_MAX_PREFETCHES) int max) {
+ if (max > ABSOLUTE_MAX_PREFETCHES) {
+ String error = "Max prefetches cannot exceed" + ABSOLUTE_MAX_PREFETCHES;
+ throw new IllegalArgumentException(error);
+ }
+
+ if (max < 1) {
+ throw new IllegalArgumentException("Max prefetches must be greater than 0");
+ }
+ mMaxPrefetches = max;
+ return this;
+ }
+
+ /**
+ * Builds a new {@link SpeculativeLoadingConfig} instance.
+ * <p>
+ * This method creates a new {@link SpeculativeLoadingConfig} object using the parameters
+ * that have been set in this builder.
+ *
+ * @return A new {@link SpeculativeLoadingConfig} instance.
+ */
+ @Profile.ExperimentalUrlPrefetch
+ @NonNull
+ public SpeculativeLoadingConfig build() {
+ return new SpeculativeLoadingConfig(mPrefetchTTLSeconds, mMaxPrefetches);
+ }
+ }
+}
diff --git a/webkit/webkit/src/main/java/androidx/webkit/WebViewFeature.java b/webkit/webkit/src/main/java/androidx/webkit/WebViewFeature.java
index d532198..2411346 100644
--- a/webkit/webkit/src/main/java/androidx/webkit/WebViewFeature.java
+++ b/webkit/webkit/src/main/java/androidx/webkit/WebViewFeature.java
@@ -660,6 +660,14 @@
public static final String PRERENDER_WITH_URL = "PRERENDER_URL_V2";
/**
+ * Feature for {@link #isFeatureSupported(String)}.
+ * This feature covers
+ * {@link Profile#setSpeculativeLoadingConfig(SpeculativeLoadingConfig)}
+ */
+ @Profile.ExperimentalUrlPrefetch
+ public static final String SPECULATIVE_LOADING_CONFIG = "SPECULATIVE_LOADING_CONFIG";
+
+ /**
* Return whether a feature is supported at run-time. This will check whether a feature is
* supported, depending on the combination of the desired feature, the Android version of
* device, and the WebView APK on the device.
diff --git a/webkit/webkit/src/main/java/androidx/webkit/internal/ProfileImpl.java b/webkit/webkit/src/main/java/androidx/webkit/internal/ProfileImpl.java
index dd61055..47c026a 100644
--- a/webkit/webkit/src/main/java/androidx/webkit/internal/ProfileImpl.java
+++ b/webkit/webkit/src/main/java/androidx/webkit/internal/ProfileImpl.java
@@ -25,6 +25,7 @@
import androidx.webkit.OutcomeReceiverCompat;
import androidx.webkit.PrefetchException;
import androidx.webkit.Profile;
+import androidx.webkit.SpeculativeLoadingConfig;
import androidx.webkit.SpeculativeLoadingParameters;
import org.chromium.support_lib_boundary.ProfileBoundaryInterface;
@@ -152,4 +153,18 @@
}
}
+ @Override
+ public void setSpeculativeLoadingConfig(
+ @NonNull SpeculativeLoadingConfig speculativeLoadingConfig) {
+ ApiFeature.NoFramework feature = WebViewFeatureInternal.SPECULATIVE_LOADING_CONFIG;
+ if (feature.isSupportedByWebView()) {
+ InvocationHandler configInvocation =
+ BoundaryInterfaceReflectionUtil.createInvocationHandlerFor(
+ new SpeculativeLoadingConfigAdapter(speculativeLoadingConfig));
+ mProfileImpl.setSpeculativeLoadingConfig(configInvocation);
+ } else {
+ throw WebViewFeatureInternal.getUnsupportedOperationException();
+ }
+ }
+
}
diff --git a/webkit/webkit/src/main/java/androidx/webkit/internal/SpeculativeLoadingConfigAdapter.java b/webkit/webkit/src/main/java/androidx/webkit/internal/SpeculativeLoadingConfigAdapter.java
new file mode 100644
index 0000000..a2a0501
--- /dev/null
+++ b/webkit/webkit/src/main/java/androidx/webkit/internal/SpeculativeLoadingConfigAdapter.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2025 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.webkit.internal;
+
+import androidx.webkit.SpeculativeLoadingConfig;
+
+import org.chromium.support_lib_boundary.SpeculativeLoadingConfigBoundaryInterface;
+import org.jspecify.annotations.NonNull;
+
+public class SpeculativeLoadingConfigAdapter implements SpeculativeLoadingConfigBoundaryInterface {
+ private final SpeculativeLoadingConfig mSpeculativeLoadingConfig;
+
+ public SpeculativeLoadingConfigAdapter(@NonNull SpeculativeLoadingConfig config) {
+ mSpeculativeLoadingConfig = config;
+ }
+
+ @Override
+ public int getMaxPrefetches() {
+ return mSpeculativeLoadingConfig.getMaxPrefetches();
+ }
+
+ @Override
+ public int getPrefetchTTLSeconds() {
+ return mSpeculativeLoadingConfig.getPrefetchTtlSeconds();
+ }
+}
diff --git a/webkit/webkit/src/main/java/androidx/webkit/internal/WebViewFeatureInternal.java b/webkit/webkit/src/main/java/androidx/webkit/internal/WebViewFeatureInternal.java
index cfd2156..df7473b 100644
--- a/webkit/webkit/src/main/java/androidx/webkit/internal/WebViewFeatureInternal.java
+++ b/webkit/webkit/src/main/java/androidx/webkit/internal/WebViewFeatureInternal.java
@@ -38,6 +38,7 @@
import androidx.webkit.ProxyController;
import androidx.webkit.SafeBrowsingResponseCompat;
import androidx.webkit.ServiceWorkerClientCompat;
+import androidx.webkit.SpeculativeLoadingConfig;
import androidx.webkit.SpeculativeLoadingParameters;
import androidx.webkit.TracingConfig;
import androidx.webkit.TracingController;
@@ -697,6 +698,13 @@
new ApiFeature.NoFramework(WebViewFeature.PRERENDER_WITH_URL,
Features.PRERENDER_WITH_URL);
+ /**
+ * Feature for {@link WebViewFeature#isFeatureSupported(String)}.
+ * This feature covers {@link Profile#setSpeculativeLoadingConfig(SpeculativeLoadingConfig)}
+ */
+ public static final ApiFeature.NoFramework SPECULATIVE_LOADING_CONFIG =
+ new ApiFeature.NoFramework(WebViewFeature.SPECULATIVE_LOADING_CONFIG,
+ Features.SPECULATIVE_LOADING_CONFIG);
// --- Add new feature constants above this line ---
diff --git a/window/window/src/androidTest/java/androidx/window/WindowTestUtils.kt b/window/window/src/androidTest/java/androidx/window/WindowTestUtils.kt
index d8422e1..3b9f2f0 100644
--- a/window/window/src/androidTest/java/androidx/window/WindowTestUtils.kt
+++ b/window/window/src/androidTest/java/androidx/window/WindowTestUtils.kt
@@ -32,17 +32,17 @@
)
}
- fun assumeVendorApiLevel(level: Int) {
+ fun assumeWindowExtensionVersionEquals(level: Int) {
val apiLevel = WindowSdkExtensions.getInstance().extensionVersion
assumeTrue(apiLevel == level)
}
- fun assumeAtLeastVendorApiLevel(min: Int) {
+ fun assumeAtLeastWindowExtensionVersion(min: Int) {
val apiLevel = WindowSdkExtensions.getInstance().extensionVersion
assumeTrue(apiLevel >= min)
}
- fun assumeBeforeVendorApiLevel(max: Int) {
+ fun assumeBeforeWindowExtensionVersion(max: Int) {
val apiLevel = WindowSdkExtensions.getInstance().extensionVersion
assumeTrue(apiLevel < max)
assumeTrue(apiLevel > 0)
diff --git a/window/window/src/androidTest/java/androidx/window/area/WindowAreaControllerImplTest.kt b/window/window/src/androidTest/java/androidx/window/area/WindowAreaControllerImplTest.kt
index d17ea2d..7fabe0f 100644
--- a/window/window/src/androidTest/java/androidx/window/area/WindowAreaControllerImplTest.kt
+++ b/window/window/src/androidTest/java/androidx/window/area/WindowAreaControllerImplTest.kt
@@ -28,7 +28,7 @@
import androidx.annotation.RequiresApi
import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.window.TestActivity
-import androidx.window.WindowTestUtils.Companion.assumeAtLeastVendorApiLevel
+import androidx.window.WindowTestUtils.Companion.assumeAtLeastWindowExtensionVersion
import androidx.window.area.WindowAreaCapability.Operation.Companion.OPERATION_PRESENT_ON_AREA
import androidx.window.area.WindowAreaCapability.Operation.Companion.OPERATION_TRANSFER_ACTIVITY_TO_AREA
import androidx.window.area.WindowAreaCapability.Status.Companion.WINDOW_AREA_STATUS_AVAILABLE
@@ -82,7 +82,7 @@
fun testRearFacingWindowAreaInfoList(): Unit =
testScope.runTest {
assumeTrue(Build.VERSION.SDK_INT > Build.VERSION_CODES.Q)
- assumeAtLeastVendorApiLevel(minVendorApiLevel)
+ assumeAtLeastWindowExtensionVersion(minVendorApiLevel)
activityScenario.scenario.onActivity {
val extensionComponent = FakeWindowAreaComponent()
val controller = WindowAreaControllerImpl(windowAreaComponent = extensionComponent)
@@ -163,7 +163,7 @@
@Test
fun testTransferToRearFacingWindowArea(): Unit =
testScope.runTest {
- assumeAtLeastVendorApiLevel(minVendorApiLevel)
+ assumeAtLeastWindowExtensionVersion(minVendorApiLevel)
val extensions = FakeWindowAreaComponent()
val controller = WindowAreaControllerImpl(windowAreaComponent = extensions)
extensions.currentRearDisplayStatus = STATUS_AVAILABLE
@@ -234,7 +234,7 @@
initialState: @WindowAreaComponent.WindowAreaStatus Int
) =
testScope.runTest {
- assumeAtLeastVendorApiLevel(minVendorApiLevel)
+ assumeAtLeastWindowExtensionVersion(minVendorApiLevel)
val extensions = FakeWindowAreaComponent()
val controller = WindowAreaControllerImpl(windowAreaComponent = extensions)
extensions.currentRearDisplayStatus = initialState
@@ -278,7 +278,7 @@
@Test
fun testPresentRearDisplayArea(): Unit =
testScope.runTest {
- assumeAtLeastVendorApiLevel(minVendorApiLevel)
+ assumeAtLeastWindowExtensionVersion(minVendorApiLevel)
val extensions = FakeWindowAreaComponent()
val controller = WindowAreaControllerImpl(windowAreaComponent = extensions)
@@ -323,7 +323,7 @@
@Test
fun testRearDisplayPresentationModeSessionEndedError(): Unit =
testScope.runTest {
- assumeAtLeastVendorApiLevel(minVendorApiLevel)
+ assumeAtLeastWindowExtensionVersion(minVendorApiLevel)
val extensionComponent = FakeWindowAreaComponent()
val controller = WindowAreaControllerImpl(windowAreaComponent = extensionComponent)
@@ -361,7 +361,7 @@
@Test
fun testPresentContentWithNewControllerThrowsException(): Unit =
testScope.runTest {
- assumeAtLeastVendorApiLevel(minVendorApiLevel)
+ assumeAtLeastWindowExtensionVersion(minVendorApiLevel)
val extensions = FakeWindowAreaComponent()
val controller = WindowAreaControllerImpl(windowAreaComponent = extensions)
diff --git a/window/window/src/androidTest/java/androidx/window/embedding/EmbeddingAdapterTest.kt b/window/window/src/androidTest/java/androidx/window/embedding/EmbeddingAdapterTest.kt
index a44742b..afe4ef6 100644
--- a/window/window/src/androidTest/java/androidx/window/embedding/EmbeddingAdapterTest.kt
+++ b/window/window/src/androidTest/java/androidx/window/embedding/EmbeddingAdapterTest.kt
@@ -61,8 +61,8 @@
@Test
fun testTranslateSplitInfoWithDefaultAttrsWithApiLevel2() {
- WindowTestUtils.assumeAtLeastVendorApiLevel(2)
- WindowTestUtils.assumeBeforeVendorApiLevel(3)
+ WindowTestUtils.assumeAtLeastWindowExtensionVersion(2)
+ WindowTestUtils.assumeBeforeWindowExtensionVersion(3)
val oemSplitInfo = createTestOEMSplitInfo(OEMSplitAttributes.Builder().build())
val expectedSplitInfo =
@@ -80,8 +80,8 @@
@Suppress("DEPRECATION")
@Test
fun testTranslateSplitInfoWithDefaultAttrsWithApiLevel3() {
- WindowTestUtils.assumeAtLeastVendorApiLevel(3)
- WindowTestUtils.assumeBeforeVendorApiLevel(5)
+ WindowTestUtils.assumeAtLeastWindowExtensionVersion(3)
+ WindowTestUtils.assumeBeforeWindowExtensionVersion(5)
val oemSplitInfo =
createTestOEMSplitInfo(
@@ -103,7 +103,7 @@
@Test
fun testTranslateSplitInfoWithDefaultAttrsWithApiLevel5() {
- WindowTestUtils.assumeAtLeastVendorApiLevel(5)
+ WindowTestUtils.assumeAtLeastWindowExtensionVersion(5)
val oemSplitInfo =
createTestOEMSplitInfo(
@@ -135,8 +135,8 @@
@Test
fun testTranslateSplitInfoWithExpandingContainersWithApiLevel2() {
- WindowTestUtils.assumeAtLeastVendorApiLevel(2)
- WindowTestUtils.assumeBeforeVendorApiLevel(3)
+ WindowTestUtils.assumeAtLeastWindowExtensionVersion(2)
+ WindowTestUtils.assumeBeforeWindowExtensionVersion(3)
val oemSplitInfo =
createTestOEMSplitInfo(
@@ -159,8 +159,8 @@
@Suppress("DEPRECATION")
@Test
fun testTranslateSplitInfoWithExpandingContainersWithApiLevel3() {
- WindowTestUtils.assumeAtLeastVendorApiLevel(3)
- WindowTestUtils.assumeBeforeVendorApiLevel(5)
+ WindowTestUtils.assumeAtLeastWindowExtensionVersion(3)
+ WindowTestUtils.assumeBeforeWindowExtensionVersion(5)
val oemSplitInfo =
createTestOEMSplitInfo(
@@ -184,7 +184,7 @@
@Test
fun testTranslateSplitInfoWithExpandingContainersWithApiLevel5() {
- WindowTestUtils.assumeAtLeastVendorApiLevel(5)
+ WindowTestUtils.assumeAtLeastWindowExtensionVersion(5)
val oemSplitInfo =
createTestOEMSplitInfo(
@@ -219,7 +219,7 @@
@Suppress("DEPRECATION")
@Test
fun testTranslateSplitInfoWithApiLevel1() {
- WindowTestUtils.assumeBeforeVendorApiLevel(2)
+ WindowTestUtils.assumeBeforeWindowExtensionVersion(2)
val activityStack = createTestOEMActivityStack(ArrayList(), true)
val expectedSplitRatio = 0.3f
@@ -245,8 +245,8 @@
@Test
fun testTranslateSplitInfoWithApiLevel2() {
- WindowTestUtils.assumeAtLeastVendorApiLevel(2)
- WindowTestUtils.assumeBeforeVendorApiLevel(3)
+ WindowTestUtils.assumeAtLeastWindowExtensionVersion(2)
+ WindowTestUtils.assumeBeforeWindowExtensionVersion(3)
val oemSplitInfo =
createTestOEMSplitInfo(
@@ -270,8 +270,8 @@
@Suppress("DEPRECATION")
@Test
fun testTranslateSplitInfoWithApiLevel3() {
- WindowTestUtils.assumeAtLeastVendorApiLevel(3)
- WindowTestUtils.assumeBeforeVendorApiLevel(5)
+ WindowTestUtils.assumeAtLeastWindowExtensionVersion(3)
+ WindowTestUtils.assumeBeforeWindowExtensionVersion(5)
val oemSplitInfo =
createTestOEMSplitInfo(
@@ -296,7 +296,7 @@
@Test
fun testTranslateSplitInfoWithApiLevel5() {
- WindowTestUtils.assumeAtLeastVendorApiLevel(5)
+ WindowTestUtils.assumeAtLeastWindowExtensionVersion(5)
val oemSplitInfo =
createTestOEMSplitInfo(
@@ -331,7 +331,7 @@
@Test
fun testTranslateAnimationBackgroundWithApiLevel5() {
- WindowTestUtils.assumeAtLeastVendorApiLevel(5)
+ WindowTestUtils.assumeAtLeastWindowExtensionVersion(5)
val colorBackground = EmbeddingAnimationBackground.createColorBackground(Color.BLUE)
val splitAttributesWithColorBackground =
@@ -378,8 +378,8 @@
@Test
fun testTranslateAnimationBackgroundBeforeApiLevel5() {
- WindowTestUtils.assumeAtLeastVendorApiLevel(2)
- WindowTestUtils.assumeBeforeVendorApiLevel(5)
+ WindowTestUtils.assumeAtLeastWindowExtensionVersion(2)
+ WindowTestUtils.assumeBeforeWindowExtensionVersion(5)
val colorBackground = EmbeddingAnimationBackground.createColorBackground(Color.BLUE)
val splitAttributesWithColorBackground =
@@ -402,7 +402,7 @@
@OptIn(androidx.window.core.ExperimentalWindowApi::class)
@Test
fun testTranslateEmbeddingConfigurationToWindowAttributes() {
- WindowTestUtils.assumeAtLeastVendorApiLevel(5)
+ WindowTestUtils.assumeAtLeastWindowExtensionVersion(5)
val dimAreaBehavior = EmbeddingConfiguration.DimAreaBehavior.ON_TASK
adapter.embeddingConfiguration =
@@ -414,7 +414,7 @@
@Test
fun testTranslateDividerAttributes_draggable() {
- WindowTestUtils.assumeAtLeastVendorApiLevel(6)
+ WindowTestUtils.assumeAtLeastWindowExtensionVersion(6)
val dividerAttributes =
DraggableDividerAttributes.Builder()
.setWidthDp(20)
@@ -435,7 +435,7 @@
@Test
fun testTranslateDividerAttributes_fixed() {
- WindowTestUtils.assumeAtLeastVendorApiLevel(6)
+ WindowTestUtils.assumeAtLeastWindowExtensionVersion(6)
val dividerAttributes =
FixedDividerAttributes.Builder().setWidthDp(20).setColor(Color.GRAY).build()
val oemDividerAttributes =
@@ -467,7 +467,7 @@
@Test
fun testTranslateDividerAttributes_0width_withApiLevel7() {
- WindowTestUtils.assumeVendorApiLevel(7)
+ WindowTestUtils.assumeWindowExtensionVersionEquals(7)
val dividerAttributes =
DraggableDividerAttributes.Builder().setWidthDp(0).setColor(Color.GRAY).build()
@@ -484,7 +484,7 @@
@Test
fun testTranslateDividerAttributes_noDivider() {
- WindowTestUtils.assumeAtLeastVendorApiLevel(6)
+ WindowTestUtils.assumeAtLeastWindowExtensionVersion(6)
val dividerAttributes = DividerAttributes.NO_DIVIDER
val oemDividerAttributes = null
diff --git a/window/window/src/androidTest/java/androidx/window/layout/WindowInfoTrackerImplTest.kt b/window/window/src/androidTest/java/androidx/window/layout/WindowInfoTrackerImplTest.kt
index 7cf977a..30c985d 100644
--- a/window/window/src/androidTest/java/androidx/window/layout/WindowInfoTrackerImplTest.kt
+++ b/window/window/src/androidTest/java/androidx/window/layout/WindowInfoTrackerImplTest.kt
@@ -27,8 +27,8 @@
import androidx.window.TestActivity
import androidx.window.WindowSdkExtensions
import androidx.window.WindowTestUtils
-import androidx.window.WindowTestUtils.Companion.assumeAtLeastVendorApiLevel
-import androidx.window.WindowTestUtils.Companion.assumeBeforeVendorApiLevel
+import androidx.window.WindowTestUtils.Companion.assumeAtLeastWindowExtensionVersion
+import androidx.window.WindowTestUtils.Companion.assumeBeforeWindowExtensionVersion
import androidx.window.layout.adapter.WindowBackend
import com.google.common.truth.Truth.assertThat
import com.google.common.truth.TruthJUnit.assume
@@ -91,7 +91,7 @@
fun testWindowLayoutInfo_contextAsListener() =
testScope.runTest {
assume().that(Build.VERSION.SDK_INT).isAtLeast(Build.VERSION_CODES.R)
- assumeAtLeastVendorApiLevel(2)
+ assumeAtLeastWindowExtensionVersion(2)
Dispatchers.setMain(testDispatcher) // Needed for flowOn(Dispatchers.Main).
val collector = mutableListOf<WindowLayoutInfo>()
val windowContext = WindowTestUtils.createOverlayWindowContext()
@@ -127,7 +127,7 @@
fun testWindowLayoutInfo_multicastingWithContext() =
testScope.runTest {
assume().that(Build.VERSION.SDK_INT).isAtLeast(Build.VERSION_CODES.R)
- assumeAtLeastVendorApiLevel(2)
+ assumeAtLeastWindowExtensionVersion(2)
Dispatchers.setMain(testDispatcher) // Needed for flowOn(Dispatchers.Main).
val collector = mutableListOf<WindowLayoutInfo>()
val windowContext = WindowTestUtils.createOverlayWindowContext()
@@ -146,7 +146,7 @@
fun testWindowLayoutInfo_nonUiContext_throwsError() =
testScope.runTest {
assume().that(Build.VERSION.SDK_INT).isAtLeast(Build.VERSION_CODES.R)
- assumeAtLeastVendorApiLevel(2)
+ assumeAtLeastWindowExtensionVersion(2)
Dispatchers.setMain(testDispatcher) // Needed for flowOn(Dispatchers.Main).
val context: Context = ApplicationProvider.getApplicationContext()
val tracker = WindowInfoTracker.getOrCreate(context)
@@ -160,7 +160,7 @@
@Test
fun testSupportedWindowPostures_throwsBeforeApi6() {
- assumeBeforeVendorApiLevel(6)
+ assumeBeforeWindowExtensionVersion(6)
activityScenario.scenario.onActivity { _ ->
assertFailsWith<UnsupportedOperationException> { tracker.supportedPostures }
}
@@ -168,7 +168,7 @@
@Test
fun testSupportedWindowPostures_reportsFeatures() {
- assumeAtLeastVendorApiLevel(6)
+ assumeAtLeastWindowExtensionVersion(6)
activityScenario.scenario.onActivity { _ ->
val fakeBackend =
FakeWindowBackend(supportedPostures = listOf(SupportedPosture.TABLETOP))
diff --git a/window/window/src/androidTest/java/androidx/window/layout/adapter/extensions/ExtensionWindowBackendTest.kt b/window/window/src/androidTest/java/androidx/window/layout/adapter/extensions/ExtensionWindowBackendTest.kt
index 22b63c7..e4f0e55 100644
--- a/window/window/src/androidTest/java/androidx/window/layout/adapter/extensions/ExtensionWindowBackendTest.kt
+++ b/window/window/src/androidTest/java/androidx/window/layout/adapter/extensions/ExtensionWindowBackendTest.kt
@@ -30,8 +30,8 @@
import androidx.window.TestActivity
import androidx.window.TestConsumer
import androidx.window.WindowTestUtils
-import androidx.window.WindowTestUtils.Companion.assumeAtLeastVendorApiLevel
-import androidx.window.WindowTestUtils.Companion.assumeBeforeVendorApiLevel
+import androidx.window.WindowTestUtils.Companion.assumeAtLeastWindowExtensionVersion
+import androidx.window.WindowTestUtils.Companion.assumeBeforeWindowExtensionVersion
import androidx.window.core.ConsumerAdapter
import androidx.window.core.ExtensionsUtil
import androidx.window.extensions.core.util.function.Consumer as OEMConsumer
@@ -81,7 +81,7 @@
@Test
fun testExtensionWindowBackend_delegatesToWindowLayoutComponent() {
- assumeAtLeastVendorApiLevel(1)
+ assumeAtLeastWindowExtensionVersion(1)
val component = RequestTrackingWindowComponent()
val backend = ExtensionWindowBackend.newInstance(component, consumerAdapter)
@@ -99,7 +99,7 @@
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
return
}
- assumeAtLeastVendorApiLevel(2)
+ assumeAtLeastWindowExtensionVersion(2)
val component = RequestTrackingWindowComponent()
@@ -123,7 +123,7 @@
@Suppress("Deprecation")
@Test
fun testExtensionWindowBackend_registerAtMostOnce() {
- assumeBeforeVendorApiLevel(2)
+ assumeBeforeWindowExtensionVersion(2)
val component = mock<WindowLayoutComponent>()
val backend = ExtensionWindowBackend.newInstance(component, consumerAdapter)
@@ -143,7 +143,7 @@
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
return
}
- assumeAtLeastVendorApiLevel(2)
+ assumeAtLeastWindowExtensionVersion(2)
val component = mock<WindowLayoutComponent>()
@@ -203,7 +203,7 @@
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
return
}
- assumeAtLeastVendorApiLevel(2)
+ assumeAtLeastWindowExtensionVersion(2)
val component = FakeWindowComponent()
val windowContext = WindowTestUtils.createOverlayWindowContext()
@@ -227,7 +227,7 @@
@Suppress("NewApi", "Deprecation") // java.util.function.Consumer was added in API 24 (N)
@Test
fun testExtensionWindowBackend_infoReplayedForAdditionalListener() {
- assumeBeforeVendorApiLevel(2)
+ assumeBeforeWindowExtensionVersion(2)
assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.N)
val component =
@@ -257,7 +257,7 @@
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
return
}
- assumeAtLeastVendorApiLevel(2)
+ assumeAtLeastWindowExtensionVersion(2)
val component =
mock<WindowLayoutComponent> {
@@ -289,7 +289,7 @@
@Suppress("Deprecation")
@Test
fun testExtensionWindowBackend_removeMatchingCallback() {
- assumeBeforeVendorApiLevel(2)
+ assumeBeforeWindowExtensionVersion(2)
val component = mock<WindowLayoutComponent>()
val backend = ExtensionWindowBackend.newInstance(component, consumerAdapter)
@@ -309,7 +309,7 @@
@Suppress("Deprecation")
@Test
fun testExtensionWindowBackend_removesMultipleCallback() {
- assumeBeforeVendorApiLevel(2)
+ assumeBeforeWindowExtensionVersion(2)
val component = mock<WindowLayoutComponent>()
val backend = ExtensionWindowBackend.newInstance(component, consumerAdapter)
@@ -341,7 +341,7 @@
// createWindowContext is available after R.
return
}
- assumeAtLeastVendorApiLevel(2)
+ assumeAtLeastWindowExtensionVersion(2)
val component = mock<WindowLayoutComponent>()
@@ -377,7 +377,7 @@
// createWindowContext is available after R.
return
}
- assumeAtLeastVendorApiLevel(2)
+ assumeAtLeastWindowExtensionVersion(2)
val component = mock<WindowLayoutComponent>()
@@ -421,7 +421,7 @@
@Suppress("Deprecation")
@Test
fun testExtensionWindowBackend_reRegisterCallback() {
- assumeBeforeVendorApiLevel(2)
+ assumeBeforeWindowExtensionVersion(2)
val component = mock<WindowLayoutComponent>()
val backend = ExtensionWindowBackend.newInstance(component, consumerAdapter)
@@ -448,7 +448,7 @@
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
return
}
- assumeAtLeastVendorApiLevel(2)
+ assumeAtLeastWindowExtensionVersion(2)
val component = mock<WindowLayoutComponent>()
@@ -480,7 +480,7 @@
@Test
fun testRegisterLayoutChangeCallback_clearListeners() {
- assumeBeforeVendorApiLevel(2)
+ assumeBeforeWindowExtensionVersion(2)
activityScenario.scenario.onActivity { activity ->
val component = FakeWindowComponent()
val backend = ExtensionWindowBackend.newInstance(component, consumerAdapter)
@@ -518,7 +518,7 @@
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
return
}
- assumeAtLeastVendorApiLevel(2)
+ assumeAtLeastWindowExtensionVersion(2)
activityScenario.scenario.onActivity { activity ->
val component = FakeWindowComponent()
@@ -550,7 +550,7 @@
@RequiresApi(Build.VERSION_CODES.R)
@Test
fun testLayoutChangeCallback_emitNewValue() {
- assumeBeforeVendorApiLevel(2)
+ assumeBeforeWindowExtensionVersion(2)
activityScenario.scenario.onActivity { activity ->
val component = FakeWindowComponent()
val backend = ExtensionWindowBackend.newInstance(component, consumerAdapter)
@@ -571,7 +571,7 @@
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
return
}
- assumeAtLeastVendorApiLevel(2)
+ assumeAtLeastWindowExtensionVersion(2)
val component = FakeWindowComponent()
val backend = ExtensionWindowBackend.newInstance(component, consumerAdapter)
@@ -589,7 +589,7 @@
@RequiresApi(Build.VERSION_CODES.R)
@Test
fun testWindowLayoutInfo_updatesOnSubsequentRegistration() {
- assumeAtLeastVendorApiLevel(1)
+ assumeAtLeastWindowExtensionVersion(1)
activityScenario.scenario.onActivity { activity ->
val component = FakeWindowComponent()
val backend = ExtensionWindowBackend.newInstance(component, consumerAdapter)
@@ -616,7 +616,7 @@
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
return
}
- assumeAtLeastVendorApiLevel(2)
+ assumeAtLeastWindowExtensionVersion(2)
val component = FakeWindowComponent()
val backend = ExtensionWindowBackend.newInstance(component, consumerAdapter)
@@ -643,7 +643,7 @@
@Test
fun testSupportedFeatures_throwsBeforeApi6() {
- assumeBeforeVendorApiLevel(6)
+ assumeBeforeWindowExtensionVersion(6)
val component = FakeWindowComponent()
val backend = ExtensionWindowBackend.newInstance(component, consumerAdapter)
@@ -653,7 +653,7 @@
@Test
fun testSupportedFeatures_emptyListReturnsNoFeatures() {
- assumeAtLeastVendorApiLevel(6)
+ assumeAtLeastWindowExtensionVersion(6)
val supportedWindowFeatures = SupportedWindowFeatures.Builder(listOf()).build()
val component = FakeWindowComponent(windowFeatures = supportedWindowFeatures)
@@ -665,7 +665,7 @@
@Test
fun testSupportedFeatures_halfOpenedReturnsTabletopSupport() {
- assumeAtLeastVendorApiLevel(6)
+ assumeAtLeastWindowExtensionVersion(6)
val foldFeature =
DisplayFoldFeature.Builder(DisplayFoldFeature.TYPE_SCREEN_FOLD_IN)