Merge "Remove usage of --Xuse-k2-uast / --Xuse-k1-uast metalava flags" into androidx-main
diff --git a/biometric/biometric-compose/samples/src/main/java/androidx/biometric/compose/samples/RememberLauncherForAuthResult.kt b/biometric/biometric-compose/samples/src/main/java/androidx/biometric/compose/samples/RememberLauncherForAuthResult.kt
index d11d5c1..bdb07c4 100644
--- a/biometric/biometric-compose/samples/src/main/java/androidx/biometric/compose/samples/RememberLauncherForAuthResult.kt
+++ b/biometric/biometric-compose/samples/src/main/java/androidx/biometric/compose/samples/RememberLauncherForAuthResult.kt
@@ -40,7 +40,7 @@
launcher.launch(
biometricRequest(
title = "test",
- authFallback = AuthenticationRequest.Biometric.Fallback.DeviceCredential,
+ AuthenticationRequest.Biometric.Fallback.DeviceCredential,
) {
// Optionally set the other configurations. setSubtitle(), setContent(), etc
}
@@ -59,5 +59,7 @@
"AuthenticationResult Success, auth type: $authType, crypto object: $crypto"
is AuthenticationResult.Error ->
"AuthenticationResult Error, error code: $errorCode, err string: $errString"
+ is AuthenticationResult.CustomFallbackSelected ->
+ "Fallback selected, text: ${fallback.text}"
}
}
diff --git a/biometric/biometric/api/current.txt b/biometric/biometric/api/current.txt
index b4e1778..29d01d0 100644
--- a/biometric/biometric/api/current.txt
+++ b/biometric/biometric/api/current.txt
@@ -6,16 +6,17 @@
}
public static final class AuthenticationRequest.Biometric extends androidx.biometric.AuthenticationRequest {
- method @InaccessibleFromKotlin public androidx.biometric.AuthenticationRequest.Biometric.Fallback getAuthFallback();
+ method @InaccessibleFromKotlin public java.util.List<androidx.biometric.AuthenticationRequest.Biometric.Fallback> getAuthFallbacks();
method @InaccessibleFromKotlin public androidx.biometric.AuthenticationRequest.BodyContent? getContent();
method @InaccessibleFromKotlin @RequiresPermission(android.Manifest.permission.SET_BIOMETRIC_DIALOG_ADVANCED) public android.graphics.Bitmap? getLogoBitmap();
method @InaccessibleFromKotlin @RequiresPermission(android.Manifest.permission.SET_BIOMETRIC_DIALOG_ADVANCED) public String? getLogoDescription();
method @InaccessibleFromKotlin @RequiresPermission(android.Manifest.permission.SET_BIOMETRIC_DIALOG_ADVANCED) public int getLogoRes();
+ method public static int getMaxFallbackOptions();
method @InaccessibleFromKotlin public androidx.biometric.AuthenticationRequest.Biometric.Strength getMinStrength();
method @InaccessibleFromKotlin public String? getSubtitle();
method @InaccessibleFromKotlin public String getTitle();
method @InaccessibleFromKotlin public boolean isConfirmationRequired();
- property public androidx.biometric.AuthenticationRequest.Biometric.Fallback authFallback;
+ property public java.util.List<androidx.biometric.AuthenticationRequest.Biometric.Fallback> authFallbacks;
property public androidx.biometric.AuthenticationRequest.BodyContent? content;
property public boolean isConfirmationRequired;
property @RequiresPermission(android.Manifest.permission.SET_BIOMETRIC_DIALOG_ADVANCED) public android.graphics.Bitmap? logoBitmap;
@@ -24,10 +25,11 @@
property public androidx.biometric.AuthenticationRequest.Biometric.Strength minStrength;
property public String? subtitle;
property public String title;
+ field public static final androidx.biometric.AuthenticationRequest.Biometric.Companion Companion;
}
public static final class AuthenticationRequest.Biometric.Builder {
- ctor public AuthenticationRequest.Biometric.Builder(String title, androidx.biometric.AuthenticationRequest.Biometric.Fallback authFallback);
+ ctor public AuthenticationRequest.Biometric.Builder(String title, androidx.biometric.AuthenticationRequest.Biometric.Fallback... authFallbacks);
method public androidx.biometric.AuthenticationRequest.Biometric build();
method public androidx.biometric.AuthenticationRequest.Biometric.Builder setContent(androidx.biometric.AuthenticationRequest.BodyContent? content);
method public androidx.biometric.AuthenticationRequest.Biometric.Builder setIsConfirmationRequired(boolean isConfirmationRequired);
@@ -38,19 +40,39 @@
method public androidx.biometric.AuthenticationRequest.Biometric.Builder setSubtitle(String? subtitle);
}
+ public static final class AuthenticationRequest.Biometric.Companion {
+ method public int getMaxFallbackOptions();
+ }
+
public abstract static class AuthenticationRequest.Biometric.Fallback {
+ field public static final androidx.biometric.AuthenticationRequest.Biometric.Fallback.Companion Companion;
+ field public static final int ICON_TYPE_ACCOUNT = 2; // 0x2
+ field public static final int ICON_TYPE_GENERIC = 3; // 0x3
+ field public static final int ICON_TYPE_PASSWORD = 0; // 0x0
+ field public static final int ICON_TYPE_QR_CODE = 1; // 0x1
+ }
+
+ public static final class AuthenticationRequest.Biometric.Fallback.Companion {
+ property public static int ICON_TYPE_ACCOUNT;
+ property public static int ICON_TYPE_GENERIC;
+ property public static int ICON_TYPE_PASSWORD;
+ property public static int ICON_TYPE_QR_CODE;
+ }
+
+ public static final class AuthenticationRequest.Biometric.Fallback.CustomOption extends androidx.biometric.AuthenticationRequest.Biometric.Fallback {
+ ctor public AuthenticationRequest.Biometric.Fallback.CustomOption(String text);
+ ctor public AuthenticationRequest.Biometric.Fallback.CustomOption(String text, optional int iconType);
+ ctor @BytecodeOnly public AuthenticationRequest.Biometric.Fallback.CustomOption(String!, int, int, kotlin.jvm.internal.DefaultConstructorMarker!);
+ method @InaccessibleFromKotlin public int getIconType();
+ method @InaccessibleFromKotlin public String getText();
+ property public int iconType;
+ property public String text;
}
public static final class AuthenticationRequest.Biometric.Fallback.DeviceCredential extends androidx.biometric.AuthenticationRequest.Biometric.Fallback {
field public static final androidx.biometric.AuthenticationRequest.Biometric.Fallback.DeviceCredential INSTANCE;
}
- public static final class AuthenticationRequest.Biometric.Fallback.NegativeButton extends androidx.biometric.AuthenticationRequest.Biometric.Fallback {
- ctor public AuthenticationRequest.Biometric.Fallback.NegativeButton(String negativeButtonText);
- method @InaccessibleFromKotlin public String getNegativeButtonText();
- property public String negativeButtonText;
- }
-
public abstract static class AuthenticationRequest.Biometric.Strength {
}
@@ -95,7 +117,7 @@
}
public static final class AuthenticationRequest.Companion {
- method public inline androidx.biometric.AuthenticationRequest.Biometric biometricRequest(String title, androidx.biometric.AuthenticationRequest.Biometric.Fallback authFallback, kotlin.jvm.functions.Function1<? super androidx.biometric.AuthenticationRequest.Biometric.Builder,kotlin.Unit> init);
+ method public inline androidx.biometric.AuthenticationRequest.Biometric biometricRequest(String title, androidx.biometric.AuthenticationRequest.Biometric.Fallback[] authFallbacks, kotlin.jvm.functions.Function1<? super androidx.biometric.AuthenticationRequest.Biometric.Builder,kotlin.Unit> init);
method @RequiresApi(android.os.Build.VERSION_CODES.R) public androidx.biometric.AuthenticationRequest.Credential credentialRequest(String title, kotlin.jvm.functions.Function1<? super androidx.biometric.AuthenticationRequest.Credential.Builder,kotlin.Unit> init);
}
@@ -119,12 +141,20 @@
}
public sealed exhaustive interface AuthenticationResult {
+ method public default androidx.biometric.AuthenticationResult.CustomFallbackSelected? customFallbackSelected();
method public default androidx.biometric.AuthenticationResult.Error? error();
+ method public default boolean isCustomFallbackSelected();
method public default boolean isError();
method public default boolean isSuccess();
method public default androidx.biometric.AuthenticationResult.Success? success();
}
+ public static final class AuthenticationResult.CustomFallbackSelected implements androidx.biometric.AuthenticationResult {
+ ctor public AuthenticationResult.CustomFallbackSelected(androidx.biometric.AuthenticationRequest.Biometric.Fallback.CustomOption fallback);
+ method @InaccessibleFromKotlin public androidx.biometric.AuthenticationRequest.Biometric.Fallback.CustomOption getFallback();
+ property public androidx.biometric.AuthenticationRequest.Biometric.Fallback.CustomOption fallback;
+ }
+
public static final class AuthenticationResult.Error implements androidx.biometric.AuthenticationResult {
ctor public AuthenticationResult.Error(int errorCode, CharSequence errString);
method @InaccessibleFromKotlin public CharSequence getErrString();
diff --git a/biometric/biometric/api/restricted_current.txt b/biometric/biometric/api/restricted_current.txt
index b4e1778..29d01d0 100644
--- a/biometric/biometric/api/restricted_current.txt
+++ b/biometric/biometric/api/restricted_current.txt
@@ -6,16 +6,17 @@
}
public static final class AuthenticationRequest.Biometric extends androidx.biometric.AuthenticationRequest {
- method @InaccessibleFromKotlin public androidx.biometric.AuthenticationRequest.Biometric.Fallback getAuthFallback();
+ method @InaccessibleFromKotlin public java.util.List<androidx.biometric.AuthenticationRequest.Biometric.Fallback> getAuthFallbacks();
method @InaccessibleFromKotlin public androidx.biometric.AuthenticationRequest.BodyContent? getContent();
method @InaccessibleFromKotlin @RequiresPermission(android.Manifest.permission.SET_BIOMETRIC_DIALOG_ADVANCED) public android.graphics.Bitmap? getLogoBitmap();
method @InaccessibleFromKotlin @RequiresPermission(android.Manifest.permission.SET_BIOMETRIC_DIALOG_ADVANCED) public String? getLogoDescription();
method @InaccessibleFromKotlin @RequiresPermission(android.Manifest.permission.SET_BIOMETRIC_DIALOG_ADVANCED) public int getLogoRes();
+ method public static int getMaxFallbackOptions();
method @InaccessibleFromKotlin public androidx.biometric.AuthenticationRequest.Biometric.Strength getMinStrength();
method @InaccessibleFromKotlin public String? getSubtitle();
method @InaccessibleFromKotlin public String getTitle();
method @InaccessibleFromKotlin public boolean isConfirmationRequired();
- property public androidx.biometric.AuthenticationRequest.Biometric.Fallback authFallback;
+ property public java.util.List<androidx.biometric.AuthenticationRequest.Biometric.Fallback> authFallbacks;
property public androidx.biometric.AuthenticationRequest.BodyContent? content;
property public boolean isConfirmationRequired;
property @RequiresPermission(android.Manifest.permission.SET_BIOMETRIC_DIALOG_ADVANCED) public android.graphics.Bitmap? logoBitmap;
@@ -24,10 +25,11 @@
property public androidx.biometric.AuthenticationRequest.Biometric.Strength minStrength;
property public String? subtitle;
property public String title;
+ field public static final androidx.biometric.AuthenticationRequest.Biometric.Companion Companion;
}
public static final class AuthenticationRequest.Biometric.Builder {
- ctor public AuthenticationRequest.Biometric.Builder(String title, androidx.biometric.AuthenticationRequest.Biometric.Fallback authFallback);
+ ctor public AuthenticationRequest.Biometric.Builder(String title, androidx.biometric.AuthenticationRequest.Biometric.Fallback... authFallbacks);
method public androidx.biometric.AuthenticationRequest.Biometric build();
method public androidx.biometric.AuthenticationRequest.Biometric.Builder setContent(androidx.biometric.AuthenticationRequest.BodyContent? content);
method public androidx.biometric.AuthenticationRequest.Biometric.Builder setIsConfirmationRequired(boolean isConfirmationRequired);
@@ -38,19 +40,39 @@
method public androidx.biometric.AuthenticationRequest.Biometric.Builder setSubtitle(String? subtitle);
}
+ public static final class AuthenticationRequest.Biometric.Companion {
+ method public int getMaxFallbackOptions();
+ }
+
public abstract static class AuthenticationRequest.Biometric.Fallback {
+ field public static final androidx.biometric.AuthenticationRequest.Biometric.Fallback.Companion Companion;
+ field public static final int ICON_TYPE_ACCOUNT = 2; // 0x2
+ field public static final int ICON_TYPE_GENERIC = 3; // 0x3
+ field public static final int ICON_TYPE_PASSWORD = 0; // 0x0
+ field public static final int ICON_TYPE_QR_CODE = 1; // 0x1
+ }
+
+ public static final class AuthenticationRequest.Biometric.Fallback.Companion {
+ property public static int ICON_TYPE_ACCOUNT;
+ property public static int ICON_TYPE_GENERIC;
+ property public static int ICON_TYPE_PASSWORD;
+ property public static int ICON_TYPE_QR_CODE;
+ }
+
+ public static final class AuthenticationRequest.Biometric.Fallback.CustomOption extends androidx.biometric.AuthenticationRequest.Biometric.Fallback {
+ ctor public AuthenticationRequest.Biometric.Fallback.CustomOption(String text);
+ ctor public AuthenticationRequest.Biometric.Fallback.CustomOption(String text, optional int iconType);
+ ctor @BytecodeOnly public AuthenticationRequest.Biometric.Fallback.CustomOption(String!, int, int, kotlin.jvm.internal.DefaultConstructorMarker!);
+ method @InaccessibleFromKotlin public int getIconType();
+ method @InaccessibleFromKotlin public String getText();
+ property public int iconType;
+ property public String text;
}
public static final class AuthenticationRequest.Biometric.Fallback.DeviceCredential extends androidx.biometric.AuthenticationRequest.Biometric.Fallback {
field public static final androidx.biometric.AuthenticationRequest.Biometric.Fallback.DeviceCredential INSTANCE;
}
- public static final class AuthenticationRequest.Biometric.Fallback.NegativeButton extends androidx.biometric.AuthenticationRequest.Biometric.Fallback {
- ctor public AuthenticationRequest.Biometric.Fallback.NegativeButton(String negativeButtonText);
- method @InaccessibleFromKotlin public String getNegativeButtonText();
- property public String negativeButtonText;
- }
-
public abstract static class AuthenticationRequest.Biometric.Strength {
}
@@ -95,7 +117,7 @@
}
public static final class AuthenticationRequest.Companion {
- method public inline androidx.biometric.AuthenticationRequest.Biometric biometricRequest(String title, androidx.biometric.AuthenticationRequest.Biometric.Fallback authFallback, kotlin.jvm.functions.Function1<? super androidx.biometric.AuthenticationRequest.Biometric.Builder,kotlin.Unit> init);
+ method public inline androidx.biometric.AuthenticationRequest.Biometric biometricRequest(String title, androidx.biometric.AuthenticationRequest.Biometric.Fallback[] authFallbacks, kotlin.jvm.functions.Function1<? super androidx.biometric.AuthenticationRequest.Biometric.Builder,kotlin.Unit> init);
method @RequiresApi(android.os.Build.VERSION_CODES.R) public androidx.biometric.AuthenticationRequest.Credential credentialRequest(String title, kotlin.jvm.functions.Function1<? super androidx.biometric.AuthenticationRequest.Credential.Builder,kotlin.Unit> init);
}
@@ -119,12 +141,20 @@
}
public sealed exhaustive interface AuthenticationResult {
+ method public default androidx.biometric.AuthenticationResult.CustomFallbackSelected? customFallbackSelected();
method public default androidx.biometric.AuthenticationResult.Error? error();
+ method public default boolean isCustomFallbackSelected();
method public default boolean isError();
method public default boolean isSuccess();
method public default androidx.biometric.AuthenticationResult.Success? success();
}
+ public static final class AuthenticationResult.CustomFallbackSelected implements androidx.biometric.AuthenticationResult {
+ ctor public AuthenticationResult.CustomFallbackSelected(androidx.biometric.AuthenticationRequest.Biometric.Fallback.CustomOption fallback);
+ method @InaccessibleFromKotlin public androidx.biometric.AuthenticationRequest.Biometric.Fallback.CustomOption getFallback();
+ property public androidx.biometric.AuthenticationRequest.Biometric.Fallback.CustomOption fallback;
+ }
+
public static final class AuthenticationResult.Error implements androidx.biometric.AuthenticationResult {
ctor public AuthenticationResult.Error(int errorCode, CharSequence errString);
method @InaccessibleFromKotlin public CharSequence getErrString();
diff --git a/biometric/biometric/samples/src/main/java/androidx/biometric/samples/AuthenticationSamples.kt b/biometric/biometric/samples/src/main/java/androidx/biometric/samples/AuthenticationSamples.kt
index 1996d4e..9d44915 100644
--- a/biometric/biometric/samples/src/main/java/androidx/biometric/samples/AuthenticationSamples.kt
+++ b/biometric/biometric/samples/src/main/java/androidx/biometric/samples/AuthenticationSamples.kt
@@ -43,8 +43,8 @@
is AuthenticationResult.Success -> {
Log.i(TAG, "onAuthenticationSucceeded with type ${result.authType}")
}
- // Handle authentication error, e.g. negative button click, user
- // cancellation, etc
+ // Handle authentication error, e.g. user cancellation, lockout errors,
+ // etc
is AuthenticationResult.Error -> {
Log.i(
TAG,
@@ -53,6 +53,10 @@
"and error string: ${result.errString}",
)
}
+ // Handle fallback option clicks
+ is AuthenticationResult.CustomFallbackSelected -> {
+ Log.i(TAG, "fallback is selected, text: ${result.fallback.text}")
+ }
}
}
@@ -67,10 +71,7 @@
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val authRequest =
- biometricRequest(
- title = "Title",
- authFallback = Biometric.Fallback.DeviceCredential,
- ) {
+ biometricRequest(title = "Title", Biometric.Fallback.DeviceCredential) {
setSubtitle("Subtitle")
setContent(
AuthenticationRequest.BodyContent.VerticalList(
@@ -100,8 +101,7 @@
is AuthenticationResult.Success -> {
Log.i(TAG, "onAuthenticationSucceeded with type ${result.authType}")
}
- // Handle authentication error, e.g. negative button click, user
- // cancellation, etc
+ // Handle authentication error, e.g. user cancellation, lockout errors, etc
is AuthenticationResult.Error -> {
Log.i(
TAG,
@@ -110,16 +110,17 @@
"and error string: ${result.errString}",
)
}
+ // Handle fallback option clicks
+ is AuthenticationResult.CustomFallbackSelected -> {
+ Log.i(TAG, "fallback is selected, text: ${result.fallback.text}")
+ }
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val authRequest =
- biometricRequest(
- title = "Title",
- authFallback = Biometric.Fallback.DeviceCredential,
- ) {
+ biometricRequest(title = "Title", Biometric.Fallback.DeviceCredential) {
setSubtitle("Subtitle")
setContent(
AuthenticationRequest.BodyContent.VerticalList(
diff --git a/biometric/biometric/samples/src/main/java/androidx/biometric/samples/java/AuthenticationSampleActivity.java b/biometric/biometric/samples/src/main/java/androidx/biometric/samples/java/AuthenticationSampleActivity.java
index 9f3f51d..1e5f2e0 100644
--- a/biometric/biometric/samples/src/main/java/androidx/biometric/samples/java/AuthenticationSampleActivity.java
+++ b/biometric/biometric/samples/src/main/java/androidx/biometric/samples/java/AuthenticationSampleActivity.java
@@ -55,6 +55,9 @@
+ " "
+ result.error().getErrString()
);
+ } else if (result.isCustomFallbackSelected()) {
+ Log.i(TAG, "fallback is selected, text:"
+ + result.customFallbackSelected().getFallback().getText());
}
}
@@ -80,11 +83,10 @@
new PromptContentItemBulletedText("test item2")))
);
- Biometric.Fallback fallback = new Biometric.Fallback.NegativeButton("Cancel button");
Biometric.Strength minStrength = Biometric.Strength.Class2.INSTANCE;
AuthenticationRequest authRequest =
- new Biometric.Builder(title, fallback)
+ new Biometric.Builder(title, new Biometric.Fallback.CustomOption("Cancel button"))
.setMinStrength(minStrength)
.setSubtitle(subtitle)
.setContent(bodyContent)
diff --git a/biometric/biometric/src/androidTest/java/androidx/biometric/internal/AuthenticationResultRegistryTest.kt b/biometric/biometric/src/androidTest/java/androidx/biometric/internal/AuthenticationResultRegistryTest.kt
new file mode 100644
index 0000000..9f6ef52
--- /dev/null
+++ b/biometric/biometric/src/androidTest/java/androidx/biometric/internal/AuthenticationResultRegistryTest.kt
@@ -0,0 +1,121 @@
+/*
+ * Copyright 2026 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.biometric.internal
+
+import android.R
+import androidx.biometric.AuthenticationRequest
+import androidx.biometric.AuthenticationResult
+import androidx.biometric.AuthenticationResultLauncher
+import androidx.biometric.TestActivity
+import androidx.biometric.internal.viewmodel.AuthenticationViewModel
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.ViewModelProvider
+import androidx.test.ext.junit.rules.ActivityScenarioRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+@OptIn(ExperimentalCoroutinesApi::class)
+class AuthenticationResultRegistryTest {
+ @get:Rule val activityRule = ActivityScenarioRule(TestActivity::class.java)
+ private var lastResult: AuthenticationResult? = null
+ private lateinit var launcher: AuthenticationResultLauncher
+
+ @Before
+ fun setUp() {
+ activityRule.scenario.moveToState(Lifecycle.State.CREATED)
+ activityRule.scenario.onActivity { activity ->
+ val registry = AuthenticationResultRegistry()
+ launcher =
+ registry.register(
+ context = activity,
+ lifecycleOwner = activity,
+ viewModelStoreOwner = activity,
+ confirmCredentialActivityLauncher = {},
+ resultCallback = { result -> lastResult = result },
+ )
+ }
+ }
+
+ @Test
+ fun launcherLaunch_showsPromptWithCorrectInfo() {
+ activityRule.scenario.moveToState(Lifecycle.State.STARTED)
+
+ activityRule.scenario.onActivity { activity ->
+ val request =
+ AuthenticationRequest.biometricRequest(title = "Test Title") {
+ setSubtitle("Test Subtitle")
+ }
+ launcher.launch(request)
+
+ val viewModel = ViewModelProvider(activity)[AuthenticationViewModel::class.java]
+ assertThat(viewModel.title).isEqualTo("Test Title")
+ assertThat(viewModel.subtitle).isEqualTo("Test Subtitle")
+ }
+ }
+
+ @Test
+ fun launcherLaunch_withDefaultCancelButton() {
+ activityRule.scenario.moveToState(Lifecycle.State.STARTED)
+
+ activityRule.scenario.onActivity { activity ->
+ val request = AuthenticationRequest.biometricRequest(title = "Test Title") {}
+ launcher.launch(request)
+
+ val defaultCancelButtonText = activity.getString(R.string.cancel)
+
+ val viewModel = ViewModelProvider(activity)[AuthenticationViewModel::class.java]
+ assertThat(viewModel.singleFallbackOptionText).isEqualTo(defaultCancelButtonText)
+ }
+ }
+
+ @Test
+ fun launcherLaunch_withMultipleFallbacks() {
+ activityRule.scenario.moveToState(Lifecycle.State.STARTED)
+
+ val fallback1Text = "Fallback 1"
+ val fallback2Text = "Fallback 2"
+ val fallback1 = AuthenticationRequest.Biometric.Fallback.CustomOption(fallback1Text)
+ val fallback2 = AuthenticationRequest.Biometric.Fallback.CustomOption(fallback2Text)
+ val fallbackList = arrayOf(fallback1, fallback2)
+
+ activityRule.scenario.onActivity { activity ->
+ val request =
+ AuthenticationRequest.biometricRequest(
+ title = "Title",
+ authFallbacks = fallbackList,
+ ) {}
+
+ launcher.launch(request)
+
+ val viewModel = ViewModelProvider(activity)[AuthenticationViewModel::class.java]
+
+ if (fallbackList.toList().multipleFallbackOptionsValid()) {
+ fallbackList.forEachIndexed { index, option ->
+ assertThat(viewModel.multipleFallbackOptionList?.get(index)).isEqualTo(option)
+ }
+ } else {
+ assertThat(viewModel.singleFallbackOption).isEqualTo(fallback1)
+ }
+ }
+ }
+}
diff --git a/biometric/biometric/src/main/java/androidx/biometric/AuthenticationRequest.kt b/biometric/biometric/src/main/java/androidx/biometric/AuthenticationRequest.kt
index 8a88eb7..71fafd9 100644
--- a/biometric/biometric/src/main/java/androidx/biometric/AuthenticationRequest.kt
+++ b/biometric/biometric/src/main/java/androidx/biometric/AuthenticationRequest.kt
@@ -19,8 +19,10 @@
import android.graphics.Bitmap
import android.os.Build
import androidx.annotation.DrawableRes
+import androidx.annotation.IntDef
import androidx.annotation.RequiresApi
import androidx.annotation.RequiresPermission
+import androidx.annotation.RestrictTo
/**
* Types for configuring authentication prompt with options that are commonly used together. For
@@ -28,10 +30,10 @@
* ```
* val request = biometricRequest(
* title = "title",
- * authFallback = BiometricRequest.Fallback.NegativeButton("cancel")
+ * Biometric.Fallback.CustomOption("cancel")
* ) {
* setSubtitle("sub title")
- * setMinStrength(BiometricRequest.Strength.Class2)
+ * setMinStrength(Biometric.Strength.Class2)
* }
* ```
*
@@ -60,13 +62,29 @@
*
* **Note for Java users:** This method is intended for use in Kotlin. For Java, please use
* [Biometric.Builder] instead.
+ *
+ * @param title The title of the prompt.
+ * @param authFallbacks A list of [Biometric.Fallback] options (up to
+ * [Biometric.getMaxFallbackOptions]). If empty, a default "Cancel" button is used; If one
+ * option is provided, it is displayed as the negative button on the primary
+ * authentication screen. If multiple options are provided, these are displayed as a list
+ * on a separate fallback options page.
+ * @param init A block to configure the [Biometric] instance.
+ * @throws IllegalArgumentException if more than one [Biometric.Fallback.DeviceCredential]
+ * is provided, or if the total number of fallbacks exceeds
+ * [Biometric.getMaxFallbackOptions].
+ *
+ * **Compatibility Note:** Prior to [Build.VERSION_CODES_FULL.BAKLAVA_1] (API 36.1), only
+ * one fallback option is supported. If multiple options are provided on earlier Android
+ * versions, only the first fallback will be displayed as the negative button (without an
+ * icon), and the remaining fallbacks will be ignored.
*/
@Suppress("MissingJvmstatic")
public inline fun biometricRequest(
title: String,
- authFallback: Biometric.Fallback,
+ vararg authFallbacks: Biometric.Fallback,
init: Biometric.Builder.() -> Unit,
- ): Biometric = Biometric.Builder(title, authFallback).apply(init).build()
+ ): Biometric = Biometric.Builder(title, *authFallbacks).apply(init).build()
/**
* Construct an instance of [Credential] that includes a set of configurable options for how
@@ -88,7 +106,7 @@
* biometric authentication with fallbacks.
*
* @property title The title of the prompt.
- * @property authFallback The [Fallback] for the biometric authentication.
+ * @property authFallbacks The [Fallback] options for the biometric authentication.
* @property minStrength The minimum biometric strength for the authentication. Note that
* **Class 3** biometrics are guaranteed to meet the requirements for **Class 2** and thus
* will also be accepted.
@@ -100,7 +118,7 @@
public class Biometric
private constructor(
public val title: String,
- public val authFallback: Fallback,
+ public val authFallbacks: List<Fallback>,
public val minStrength: Strength = Strength.Class2,
public val subtitle: String? = null,
public val content: BodyContent? = null,
@@ -108,11 +126,33 @@
@get:RequiresPermission(SET_BIOMETRIC_DIALOG_ADVANCED)
public val logoBitmap: Bitmap? = null,
@get:RequiresPermission(SET_BIOMETRIC_DIALOG_ADVANCED)
- @DrawableRes
+ @param:DrawableRes
public val logoRes: Int = 0,
@get:RequiresPermission(SET_BIOMETRIC_DIALOG_ADVANCED)
public val logoDescription: String? = null,
) : AuthenticationRequest() {
+ init {
+ // Enforce the limit of at most one DeviceCredential
+ require(authFallbacks.count { it is Fallback.DeviceCredential } <= 1) {
+ "At most one DeviceCredential can be provided as a fallback option."
+ }
+
+ // Enforce total size limit
+ require(authFallbacks.size <= getMaxFallbackOptions()) {
+ "Maximum fallback option count exceeded. " +
+ "A maximum of ${getMaxFallbackOptions()} fallbacks are allowed."
+ }
+ }
+
+ public companion object {
+ /**
+ * Returns the maximum number of fallback options that can be added to the prompt.
+ *
+ * @return The maximum number of fallback options.
+ */
+ @JvmStatic
+ public fun getMaxFallbackOptions(): Int = BiometricPrompt.MAX_FALLBACK_OPTIONS
+ }
/**
* Builder used to create an instance of [Biometric].
@@ -121,9 +161,22 @@
* function to construct [Biometric] instances.
*
* @param title The title of the prompt.
- * @param authFallback The [Fallback] for the biometric authentication.
+ * @param authFallbacks A list of [Biometric.Fallback] options (up to
+ * [Biometric.getMaxFallbackOptions]). If empty, a default "Cancel" button is used; If one
+ * option is provided, it is displayed as the negative button on the primary
+ * authentication screen. If multiple options are provided, these are displayed as a list
+ * on a separate fallback options page.
+ * @throws IllegalArgumentException if more than one [Biometric.Fallback.DeviceCredential]
+ * is provided, or if the total number of fallbacks exceeds
+ * [Biometric.getMaxFallbackOptions].
+ *
+ * **Compatibility Note:** Prior to [Build.VERSION_CODES_FULL.BAKLAVA_1] (API 36.1), only
+ * one fallback option is supported. If multiple options are provided on earlier Android
+ * versions, only the first fallback will be displayed as the negative button (without an
+ * icon), and the remaining fallbacks will be ignored.
*/
- public class Builder(private val title: String, private val authFallback: Fallback) {
+ public class Builder(private val title: String, vararg authFallbacks: Fallback) {
+ private val authFallbacks: List<Fallback> = authFallbacks.toList()
private var minStrength: Strength = Strength.Class2
private var subtitle: String? = null
private var content: BodyContent? = null
@@ -194,7 +247,7 @@
public fun build(): Biometric {
return Biometric(
title = title,
- authFallback = authFallback,
+ authFallbacks = authFallbacks,
minStrength = minStrength,
subtitle = subtitle,
content = content,
@@ -220,11 +273,59 @@
public object DeviceCredential : Fallback()
/**
- * A customized negative button as the fallback.
+ * Custom Fallback option displayed as the negative button in prompt authentication
+ * screen if it is the only option or displayed in a list in the fallback options page
+ * if there are more than one.
*
- * @property negativeButtonText The text of the button.
+ * @param text Text to be shown on the fallback option for the prompt.
+ * @param iconType Icon to be shown for the fallback option
*/
- public class NegativeButton(public val negativeButtonText: String) : Fallback()
+ public class CustomOption
+ @JvmOverloads
+ constructor(
+ public val text: String,
+ @param:IconType public val iconType: Int = ICON_TYPE_GENERIC,
+ ) : Fallback()
+
+ /**
+ * Default cancel button displayed as the negative button in prompt authentication
+ * screen if there's no custom option set and device credential is not allowed.
+ *
+ * @param text Text to be shown on the fallback option for the prompt.
+ */
+ internal class DefaultCancel(val text: String) : Fallback()
+
+ /**
+ * Device credential button displayed as the negative button in prompt authentication
+ * prompt. This is used for older Android versions when
+ * [androidx.biometric.internal.isManagingDeviceCredentialButton] is true.
+ *
+ * @param text Text to be shown on the fallback option for the prompt.
+ */
+ internal class OverriddenDeviceCredential(val text: String) : Fallback()
+
+ public companion object {
+ /** An icon representing a password. */
+ public const val ICON_TYPE_PASSWORD: Int = BiometricConstants.ICON_TYPE_PASSWORD
+
+ /** An icon representing a QR code. */
+ public const val ICON_TYPE_QR_CODE: Int = BiometricConstants.ICON_TYPE_QR_CODE
+
+ /** An icon representing a user account. */
+ public const val ICON_TYPE_ACCOUNT: Int = BiometricConstants.ICON_TYPE_ACCOUNT
+
+ /** A generic icon. */
+ public const val ICON_TYPE_GENERIC: Int = BiometricConstants.ICON_TYPE_GENERIC
+
+ /**
+ * An [IntDef] representing the different icon types that can be used in the
+ * biometric prompt fallback options
+ */
+ @IntDef(ICON_TYPE_PASSWORD, ICON_TYPE_QR_CODE, ICON_TYPE_ACCOUNT, ICON_TYPE_GENERIC)
+ @Retention(AnnotationRetention.SOURCE)
+ @RestrictTo(RestrictTo.Scope.LIBRARY)
+ public annotation class IconType
+ }
}
/** Types of biometric strength for the prompt. */
diff --git a/biometric/biometric/src/main/java/androidx/biometric/AuthenticationResult.kt b/biometric/biometric/src/main/java/androidx/biometric/AuthenticationResult.kt
index dd6f603..a8b4d14 100644
--- a/biometric/biometric/src/main/java/androidx/biometric/AuthenticationResult.kt
+++ b/biometric/biometric/src/main/java/androidx/biometric/AuthenticationResult.kt
@@ -30,9 +30,7 @@
public val crypto: BiometricPrompt.CryptoObject?,
@param:BiometricPrompt.AuthenticationResultType public val authType: Int,
) : AuthenticationResult {
- override fun success(): Success {
- return this
- }
+ override fun success(): Success = this
}
/**
@@ -46,28 +44,34 @@
@param:BiometricPrompt.AuthenticationError public val errorCode: Int,
public val errString: CharSequence,
) : AuthenticationResult {
- override fun error(): Error {
- return this
- }
+ override fun error(): Error = this
+ }
+
+ /** The prompt was dismissed because the user clicked a custom fallback option. */
+ public class CustomFallbackSelected(
+ public val fallback: AuthenticationRequest.Biometric.Fallback.CustomOption
+ ) : AuthenticationResult {
+ override fun customFallbackSelected(): CustomFallbackSelected = this
}
/** Whether this [AuthenticationResult] is a [Success]. */
- public fun isSuccess(): Boolean {
- return this is Success
- }
+ public fun isSuccess(): Boolean = this is Success
- /** Returns a [Success] only if it's a [Success], throws otherwise. */
- public fun success(): Success? {
- throw IllegalArgumentException("This is not a Success result.")
- }
+ /** Returns a [Success] only if it's a [Success], returns null otherwise. */
+ public fun success(): Success? = null
/** Whether this [AuthenticationResult] is an [Error]. */
- public fun isError(): Boolean {
- return this is Error
- }
+ public fun isError(): Boolean = this is Error
- /** Returns a [Error] only if it's a [Error], throws otherwise. */
- public fun error(): Error? {
- throw IllegalArgumentException("This is not a Error result.")
- }
+ /** Returns a [Error] only if it's a [Error], returns null otherwise. */
+ public fun error(): Error? = null
+
+ /** Whether this [AuthenticationResult] is a [CustomFallbackSelected]. */
+ public fun isCustomFallbackSelected(): Boolean = this is CustomFallbackSelected
+
+ /**
+ * Returns a [CustomFallbackSelected] only if it's a [CustomFallbackSelected], returns null
+ * otherwise.
+ */
+ public fun customFallbackSelected(): CustomFallbackSelected? = null
}
diff --git a/biometric/biometric/src/main/java/androidx/biometric/BiometricConstants.java b/biometric/biometric/src/main/java/androidx/biometric/BiometricConstants.java
index d97f128..15229bd 100644
--- a/biometric/biometric/src/main/java/androidx/biometric/BiometricConstants.java
+++ b/biometric/biometric/src/main/java/androidx/biometric/BiometricConstants.java
@@ -109,7 +109,11 @@
int ERROR_HW_NOT_PRESENT = 12;
/**
- * The user pressed the negative button.
+ * Indicates that the user pressed the negative button.
+ *
+ * **Note:** This constant is not used for results of type [AuthenticationResult.Error]
+ * in the new API. Instead, negative button clicks are delivered via
+ * [AuthenticationResult.CustomFallbackSelected].
*/
int ERROR_NEGATIVE_BUTTON = 13;
@@ -177,4 +181,36 @@
*/
int AUTHENTICATION_RESULT_TYPE_BIOMETRIC = 2;
+ /**
+ * An icon representing a password. Added in
+ * {@link android.os.Build.VERSION_CODES_FULL#BAKLAVA_1}.
+ */
+ @RestrictTo(RestrictTo.Scope.LIBRARY)
+ int ICON_TYPE_PASSWORD = 0;
+
+ /**
+ * An icon representing a QR code. Added in
+ * {@link android.os.Build.VERSION_CODES_FULL#BAKLAVA_1}
+ */
+ @RestrictTo(RestrictTo.Scope.LIBRARY)
+ int ICON_TYPE_QR_CODE = 1;
+
+ /**
+ * An icon representing a user account. Added in
+ * {@link android.os.Build.VERSION_CODES_FULL#BAKLAVA_1}
+ */
+ @RestrictTo(RestrictTo.Scope.LIBRARY)
+ int ICON_TYPE_ACCOUNT = 2;
+
+ /**
+ * A generic icon.Added in {@link android.os.Build.VERSION_CODES_FULL#BAKLAVA_1}
+ */
+ @RestrictTo(RestrictTo.Scope.LIBRARY)
+ int ICON_TYPE_GENERIC = 3;
+
+ /**
+ * The maximum amount of fallback options that can be added to the prompt
+ */
+ @RestrictTo(RestrictTo.Scope.LIBRARY)
+ int MAX_FALLBACK_OPTIONS = 4;
}
diff --git a/biometric/biometric/src/main/java/androidx/biometric/BiometricPrompt.java b/biometric/biometric/src/main/java/androidx/biometric/BiometricPrompt.java
index 4215618..931dc97 100644
--- a/biometric/biometric/src/main/java/androidx/biometric/BiometricPrompt.java
+++ b/biometric/biometric/src/main/java/androidx/biometric/BiometricPrompt.java
@@ -415,6 +415,16 @@
}
/**
+ * The prompt was dismissed because the user clicked a custom fallback option.
+ *
+ * @param fallback The clicked {@link AuthenticationRequest.Biometric.Fallback.CustomOption}
+ */
+ @RestrictTo(RestrictTo.Scope.LIBRARY)
+ public void onFallbackSelected(
+ AuthenticationRequest.Biometric.Fallback.@NonNull CustomOption fallback) {
+ }
+
+ /**
* Called when a biometric (e.g. fingerprint, face, etc.) is recognized, indicating that the
* user has successfully authenticated.
*
@@ -452,6 +462,8 @@
private @Nullable CharSequence mDescription = null;
private @Nullable PromptContentView mPromptContentView = null;
private @Nullable CharSequence mNegativeButtonText = null;
+ private @Nullable List<AuthenticationRequest.Biometric.Fallback> mFallbackOptionList =
+ null;
private boolean mIsConfirmationRequired = true;
private boolean mIsDeviceCredentialAllowed = false;
@BiometricManager.AuthenticatorTypes
@@ -584,6 +596,20 @@
}
/**
+ * Optional: Sets the text, icon, executor, and click listener for a fallback option in
+ * biometric prompt.
+ */
+ @RestrictTo(RestrictTo.Scope.LIBRARY)
+ public @NonNull Builder addFallbackOption(
+ AuthenticationRequest.Biometric.@NonNull Fallback fallbackOptionList) {
+ if (mFallbackOptionList == null) {
+ mFallbackOptionList = new ArrayList<>();
+ }
+ mFallbackOptionList.add(fallbackOptionList);
+ return this;
+ }
+
+ /**
* Optional: Sets a system hint for whether to require explicit user confirmation after
* a passive biometric (e.g. iris or face) has been recognized but before
* {@link AuthenticationCallback#onAuthenticationSucceeded(AuthenticationResult)} is
@@ -689,17 +715,6 @@
+ AuthenticatorUtils.convertToString(mAllowedAuthenticators));
}
- final boolean isDeviceCredentialAllowed = mAllowedAuthenticators != 0
- ? AuthenticatorUtils.isDeviceCredentialAllowed(mAllowedAuthenticators)
- : mIsDeviceCredentialAllowed;
- if (TextUtils.isEmpty(mNegativeButtonText) && !isDeviceCredentialAllowed) {
- throw new IllegalArgumentException("Negative text must be set and non-empty.");
- }
- if (!TextUtils.isEmpty(mNegativeButtonText) && isDeviceCredentialAllowed) {
- throw new IllegalArgumentException("Negative text must not be set if device "
- + "credential authentication is allowed.");
- }
-
return new PromptInfo(
mLogoRes,
mLogoBitmap,
@@ -709,6 +724,7 @@
mDescription,
mPromptContentView,
mNegativeButtonText,
+ mFallbackOptionList,
mIsConfirmationRequired,
mIsDeviceCredentialAllowed,
mAllowedAuthenticators);
@@ -725,6 +741,7 @@
private final @Nullable CharSequence mDescription;
private final @Nullable PromptContentView mPromptContentView;
private final @Nullable CharSequence mNegativeButtonText;
+ private final @Nullable List<AuthenticationRequest.Biometric.Fallback> mFallbackOptionList;
private final boolean mIsConfirmationRequired;
private final boolean mIsDeviceCredentialAllowed;
@BiometricManager.AuthenticatorTypes
@@ -741,6 +758,7 @@
@Nullable CharSequence description,
@Nullable PromptContentView promptContentView,
@Nullable CharSequence negativeButtonText,
+ @Nullable List<AuthenticationRequest.Biometric.Fallback> fallbackOptionList,
boolean confirmationRequired,
boolean deviceCredentialAllowed,
@BiometricManager.AuthenticatorTypes int allowedAuthenticators) {
@@ -752,6 +770,7 @@
mDescription = description;
mPromptContentView = promptContentView;
mNegativeButtonText = negativeButtonText;
+ mFallbackOptionList = fallbackOptionList;
mIsConfirmationRequired = confirmationRequired;
mIsDeviceCredentialAllowed = deviceCredentialAllowed;
mAllowedAuthenticators = allowedAuthenticators;
@@ -844,6 +863,12 @@
return mNegativeButtonText != null ? mNegativeButtonText : "";
}
+ @RestrictTo(RestrictTo.Scope.LIBRARY)
+ public @Nullable
+ List<AuthenticationRequest.Biometric.Fallback> getFallbackOptionList() {
+ return mFallbackOptionList != null ? new ArrayList<>(mFallbackOptionList) : null;
+ }
+
/**
* Checks if the confirmation required option is enabled for the prompt.
*
diff --git a/biometric/biometric/src/main/java/androidx/biometric/internal/AuthenticationHandlerBiometricPrompt.kt b/biometric/biometric/src/main/java/androidx/biometric/internal/AuthenticationHandlerBiometricPrompt.kt
index fe451c0..5d55399 100644
--- a/biometric/biometric/src/main/java/androidx/biometric/internal/AuthenticationHandlerBiometricPrompt.kt
+++ b/biometric/biometric/src/main/java/androidx/biometric/internal/AuthenticationHandlerBiometricPrompt.kt
@@ -29,6 +29,7 @@
import androidx.annotation.DoNotInline
import androidx.annotation.RequiresApi
import androidx.annotation.VisibleForTesting
+import androidx.biometric.AuthenticationRequest
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt.AuthenticationCallback
import androidx.biometric.BiometricPrompt.CryptoObject
@@ -74,12 +75,6 @@
clientAuthenticationCallback,
)
- private val isMoreOptionsButtonPressPendingObserver = {
- if (viewModel.isPromptShowing) {
- onMoreOptionsButtonPressed()
- }
- }
-
init {
val resultDispatcher =
object :
@@ -96,9 +91,7 @@
val knownErrorCode = ErrorUtils.toKnownErrorCodeForAuthenticate(errorCode)
if (
ErrorUtils.isLockoutError(knownErrorCode) &&
- context.isManagingDeviceCredentialButton(
- viewModel.allowedAuthenticators
- )
+ viewModel.isOverriddenDeviceCredential
) {
showKMAsFallback()
return
@@ -119,8 +112,17 @@
}
}
launch {
+ viewModel.isFallbackOptionPressPending.collect { fallback ->
+ if (viewModel.isPromptShowing) {
+ onFallbackOptionPressed(fallback)
+ }
+ }
+ }
+ launch {
viewModel.isMoreOptionsButtonPressPending.collect {
- isMoreOptionsButtonPressPendingObserver()
+ if (viewModel.isPromptShowing) {
+ onMoreOptionsButtonPressed()
+ }
}
}
}
@@ -157,7 +159,7 @@
Api28Impl.setDescription(builder, description)
}
- val negativeButtonText: CharSequence? = viewModel.negativeButtonText
+ val negativeButtonText: CharSequence? = viewModel.singleFallbackOptionText
if (negativeButtonText != null && !TextUtils.isEmpty(negativeButtonText)) {
Api28Impl.setNegativeButton(
builder,
@@ -167,6 +169,19 @@
)
}
+ val fallbackOptionList = viewModel.multipleFallbackOptionList
+ fallbackOptionList
+ ?.filterIsInstance<AuthenticationRequest.Biometric.Fallback.CustomOption>()
+ ?.forEach {
+ Api36MinorImpl.addFallbackOption(
+ builder,
+ it.text,
+ it.iconType,
+ clientExecutor,
+ viewModel.fallbackOptionListener(it),
+ )
+ }
+
// Set the confirmation required option introduced in Android 10 (API 29).
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
Api29Impl.setConfirmationRequired(builder, viewModel.isConfirmationRequired)
@@ -258,6 +273,38 @@
)
cancelAuthentication(CanceledFrom.MORE_OPTIONS_BUTTON)
}
+
+ /**
+ * Callback that is run when the view model reports that the fallback options has been pressed.
+ */
+ private fun onFallbackOptionPressed(
+ fallback: AuthenticationRequest.Biometric.Fallback.CustomOption
+ ) {
+ authenticationManager.resultDispatcher.sendFallbackOptionAndDismiss(fallback)
+ cancelAuthentication(CanceledFrom.FALLBACK_OPTION)
+ }
+}
+
+@RequiresApi(Build.VERSION_CODES_FULL.BAKLAVA_1)
+private object Api36MinorImpl {
+ /**
+ * Sets the text, icon, executor, and click listener for a fallback option in biometric prompt.
+ *
+ * @param text Text to be shown on the fallback option for the prompt.
+ * @param iconType Icon to be shown for the fallback option
+ * @param executor Executor that will be used to run the on click callback.
+ * @param listener Listener containing a callback to be run when the button is pressed.
+ */
+ @DoNotInline
+ fun addFallbackOption(
+ builder: BiometricPrompt.Builder,
+ text: CharSequence,
+ iconType: Int,
+ executor: Executor,
+ listener: DialogInterface.OnClickListener,
+ ) {
+ builder.addFallbackOption(text, iconType, executor, listener)
+ }
}
/** Nested class to avoid verification errors for methods introduced in Android 15.0 (API 35). */
diff --git a/biometric/biometric/src/main/java/androidx/biometric/internal/AuthenticationHandlerFingerprintManager.kt b/biometric/biometric/src/main/java/androidx/biometric/internal/AuthenticationHandlerFingerprintManager.kt
index 672d204..3762773 100644
--- a/biometric/biometric/src/main/java/androidx/biometric/internal/AuthenticationHandlerFingerprintManager.kt
+++ b/biometric/biometric/src/main/java/androidx/biometric/internal/AuthenticationHandlerFingerprintManager.kt
@@ -133,10 +133,7 @@
private fun onAuthenticationError(errorCode: Int, errorMessage: CharSequence?) {
// Ensure we're only sending publicly defined errors.
val knownErrorCode = ErrorUtils.toKnownErrorCodeForAuthenticate(errorCode)
- if (
- ErrorUtils.isLockoutError(knownErrorCode) &&
- context.isManagingDeviceCredentialButton(viewModel.allowedAuthenticators)
- ) {
+ if (ErrorUtils.isLockoutError(knownErrorCode) && viewModel.isOverriddenDeviceCredential) {
showKMAsFallback()
return
}
diff --git a/biometric/biometric/src/main/java/androidx/biometric/internal/AuthenticationManager.kt b/biometric/biometric/src/main/java/androidx/biometric/internal/AuthenticationManager.kt
index f1be5e3..677016e 100644
--- a/biometric/biometric/src/main/java/androidx/biometric/internal/AuthenticationManager.kt
+++ b/biometric/biometric/src/main/java/androidx/biometric/internal/AuthenticationManager.kt
@@ -19,6 +19,7 @@
import android.app.Activity
import android.content.Context
import android.os.Build
+import androidx.biometric.AuthenticationRequest.Biometric.Fallback
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.biometric.BiometricPrompt.AuthenticationCallback
@@ -103,10 +104,21 @@
*/
val isNegativeButtonPressPendingObserver = {
if (viewModel.isPromptShowing) {
- if (context.isManagingDeviceCredentialButton(viewModel.allowedAuthenticators)) {
- resultDispatcher.showKMAsFallback()
- } else {
- onCancelButtonPressed()
+ when (viewModel.singleFallbackOption) {
+ is Fallback.OverriddenDeviceCredential -> resultDispatcher.showKMAsFallback()
+ is Fallback.DefaultCancel -> {
+ resultDispatcher.onAuthenticationError(
+ BiometricPrompt.ERROR_CANCELED,
+ context.getString(R.string.generic_error_user_canceled),
+ )
+ cancelAuthentication(CanceledFrom.USER)
+ }
+ is Fallback.CustomOption -> {
+ resultDispatcher.sendFallbackOptionAndDismiss(
+ viewModel.singleFallbackOption as Fallback.CustomOption
+ )
+ cancelAuthentication(CanceledFrom.NEGATIVE_BUTTON)
+ }
}
}
}
@@ -315,21 +327,6 @@
}
/**
- * Callback that is run when the view model reports that the cancel button has been pressed on
- * the prompt.
- */
- private fun onCancelButtonPressed() {
- val negativeButtonText: CharSequence? = viewModel.negativeButtonText
-
- resultDispatcher.sendErrorAndDismiss(
- BiometricPrompt.ERROR_NEGATIVE_BUTTON,
- negativeButtonText ?: context.getString(R.string.default_error_msg),
- )
-
- cancelAuthentication(CanceledFrom.NEGATIVE_BUTTON)
- }
-
- /**
* Shows any of the framework biometric prompt, or framework credential view, or AndroidX
* fingerprint UI dialog to the user and begins authentication.
*/
diff --git a/biometric/biometric/src/main/java/androidx/biometric/internal/AuthenticationResultDispatcher.kt b/biometric/biometric/src/main/java/androidx/biometric/internal/AuthenticationResultDispatcher.kt
index 815c723..1793dc6 100644
--- a/biometric/biometric/src/main/java/androidx/biometric/internal/AuthenticationResultDispatcher.kt
+++ b/biometric/biometric/src/main/java/androidx/biometric/internal/AuthenticationResultDispatcher.kt
@@ -19,6 +19,7 @@
import android.app.KeyguardManager
import android.content.Context
import android.util.Log
+import androidx.biometric.AuthenticationRequest
import androidx.biometric.BiometricPrompt
import androidx.biometric.BiometricPrompt.AuthenticationCallback
import androidx.biometric.R
@@ -145,4 +146,28 @@
clientExecutor.execute { clientAuthenticationCallback.onAuthenticationFailed() }
}
+
+ /**
+ * Sends an unrecoverable fallback option result with [fallback] to the client and dismisses the
+ * prompt.
+ *
+ * @param fallback The selected fallback option
+ */
+ fun sendFallbackOptionAndDismiss(
+ fallback: AuthenticationRequest.Biometric.Fallback.CustomOption
+ ) {
+ sendFallbackOption(fallback)
+ dismiss()
+ }
+
+ /** Sends an unrecoverable fallback option result to the client. */
+ fun sendFallbackOption(fallback: AuthenticationRequest.Biometric.Fallback.CustomOption) {
+ if (!viewModel.isAwaitingResult) {
+ Log.w(TAG, "Error not sent to client. Client is not awaiting a result.")
+ return
+ }
+
+ viewModel.isAwaitingResult = false
+ clientExecutor.execute { clientAuthenticationCallback.onFallbackSelected(fallback) }
+ }
}
diff --git a/biometric/biometric/src/main/java/androidx/biometric/internal/AuthenticationResultRegistry.kt b/biometric/biometric/src/main/java/androidx/biometric/internal/AuthenticationResultRegistry.kt
index 4ec0383..b16e05d 100644
--- a/biometric/biometric/src/main/java/androidx/biometric/internal/AuthenticationResultRegistry.kt
+++ b/biometric/biometric/src/main/java/androidx/biometric/internal/AuthenticationResultRegistry.kt
@@ -19,9 +19,11 @@
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Bitmap
+import android.os.Build
import androidx.annotation.RestrictTo
import androidx.biometric.AuthenticationRequest
import androidx.biometric.AuthenticationRequest.Biometric
+import androidx.biometric.AuthenticationRequest.Credential
import androidx.biometric.AuthenticationResult
import androidx.biometric.AuthenticationResultCallback
import androidx.biometric.AuthenticationResultLauncher
@@ -33,6 +35,7 @@
import androidx.biometric.BiometricPrompt.PromptInfo
import androidx.biometric.PromptContentViewWithMoreOptionsButton
import androidx.biometric.PromptVerticalListContentView
+import androidx.biometric.R
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner
@@ -87,7 +90,7 @@
return object : AuthenticationResultLauncher {
override fun launch(input: AuthenticationRequest) {
- biometricPrompt?.let { onLaunch(input, it) }
+ biometricPrompt?.onLaunch(context, input)
}
override fun cancel() {
@@ -97,10 +100,11 @@
}
}
-private fun onLaunch(input: AuthenticationRequest, biometricPrompt: BiometricPrompt) {
+private fun BiometricPrompt.onLaunch(context: Context, input: AuthenticationRequest) {
when (input) {
is Biometric ->
- biometricPrompt.authInternal(
+ authInternal(
+ context = context,
title = input.title,
subtitle = input.subtitle,
content = input.content,
@@ -109,10 +113,11 @@
logoDescription = input.logoDescription,
minBiometricStrength = input.minStrength,
isConfirmationRequired = input.isConfirmationRequired,
- authFallback = input.authFallback,
+ authFallbacks = input.authFallbacks,
)
- is AuthenticationRequest.Credential ->
- biometricPrompt.authInternal(
+ is Credential ->
+ authInternal(
+ context = context,
title = input.title,
subtitle = input.subtitle,
content = input.content,
@@ -138,12 +143,17 @@
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
resultCallback.onAuthResult(AuthenticationResult.Error(errorCode, errString))
}
+
+ override fun onFallbackSelected(fallback: Biometric.Fallback.CustomOption) {
+ resultCallback.onAuthResult(AuthenticationResult.CustomFallbackSelected(fallback))
+ }
}
}
/** Shows the authentication prompt to the user with biometric and/or device credential. */
@SuppressLint("MissingPermission")
private fun BiometricPrompt.authInternal(
+ context: Context,
title: String,
subtitle: String? = null,
content: AuthenticationRequest.BodyContent? = null,
@@ -153,20 +163,35 @@
minBiometricStrength: Biometric.Strength? = null,
isConfirmationRequired: Boolean = true,
cryptoObjectForCredentialOnly: CryptoObject? = null,
- authFallback: Biometric.Fallback? = null,
+ authFallbacks: List<Biometric.Fallback>? = null,
) {
-
PromptInfo.Builder().apply {
// Set authenticators and fallbacks
var authType =
minBiometricStrength?.toAuthenticationType()
?: BiometricManager.Authenticators.DEVICE_CREDENTIAL
- when (authFallback) {
- is Biometric.Fallback.DeviceCredential ->
- authType = authType or BiometricManager.Authenticators.DEVICE_CREDENTIAL
- is Biometric.Fallback.NegativeButton ->
- setNegativeButtonText(authFallback.negativeButtonText)
+
+ minBiometricStrength?.toAuthenticationType()?.let {
+ fun defaultCancelButton(): Biometric.Fallback =
+ Biometric.Fallback.DefaultCancel(context.getString(android.R.string.cancel))
+
+ val fallbacksToProcess =
+ when {
+ authFallbacks.multipleFallbackOptionsValid() -> authFallbacks!!
+ !authFallbacks.isNullOrEmpty() -> listOf(authFallbacks.first())
+ else -> listOf(defaultCancelButton())
+ }
+
+ fallbacksToProcess.forEach { fallback ->
+ when (fallback) {
+ is Biometric.Fallback.DeviceCredential -> {
+ authType = authType or BiometricManager.Authenticators.DEVICE_CREDENTIAL
+ }
+ else -> addFallbackOption(fallback)
+ }
+ }
}
+
setAllowedAuthenticators(authType)
// Set body content
@@ -216,3 +241,9 @@
}
}
}
+
+internal fun List<Biometric.Fallback>?.multipleFallbackOptionsValid(): Boolean =
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA &&
+ Build.VERSION.SDK_INT_FULL >= Build.VERSION_CODES_FULL.BAKLAVA_1 &&
+ this != null &&
+ this.size > 1
diff --git a/biometric/biometric/src/main/java/androidx/biometric/internal/data/AuthenticationStateRepository.kt b/biometric/biometric/src/main/java/androidx/biometric/internal/data/AuthenticationStateRepository.kt
index a65756b..a0cace4 100644
--- a/biometric/biometric/src/main/java/androidx/biometric/internal/data/AuthenticationStateRepository.kt
+++ b/biometric/biometric/src/main/java/androidx/biometric/internal/data/AuthenticationStateRepository.kt
@@ -16,6 +16,7 @@
package androidx.biometric.internal.data
+import androidx.biometric.AuthenticationRequest
import androidx.biometric.BiometricPrompt
import androidx.biometric.utils.BiometricErrorData
import androidx.biometric.utils.CancellationSignalProvider
@@ -58,6 +59,9 @@
/** A flow that emits when the negative button is pressed. */
val isNegativeButtonPressPending: Flow<Unit>
+ /** A flow that emits when the fallback option is pressed. */
+ val isFallbackOptionPressPending: Flow<AuthenticationRequest.Biometric.Fallback.CustomOption>
+
/** A flow that emits when the more options button is pressed. */
val isMoreOptionsButtonPressPending: Flow<Unit>
@@ -85,6 +89,11 @@
/** Emits an event for a negative button press. */
suspend fun setNegativeButtonPressPending()
+ /** Emits an event for a [fallbackOption] press. */
+ suspend fun setFallbackOptionPressPending(
+ fallbackOption: AuthenticationRequest.Biometric.Fallback.CustomOption
+ )
+
/** Emits an event for a more options button press. */
suspend fun setMoreOptionsButtonPressPending()
@@ -130,6 +139,12 @@
override val isNegativeButtonPressPending: SharedFlow<Unit> =
_isNegativeButtonPressPending.asSharedFlow()
+ private val _isFallbackOptionPressPending =
+ MutableSharedFlow<AuthenticationRequest.Biometric.Fallback.CustomOption>()
+ override val isFallbackOptionPressPending:
+ SharedFlow<AuthenticationRequest.Biometric.Fallback.CustomOption> =
+ _isFallbackOptionPressPending.asSharedFlow()
+
private val _isMoreOptionsButtonPressPending = MutableSharedFlow<Unit>()
override val isMoreOptionsButtonPressPending: SharedFlow<Unit> =
_isMoreOptionsButtonPressPending.asSharedFlow()
@@ -161,6 +176,12 @@
_isNegativeButtonPressPending.emit(Unit)
}
+ override suspend fun setFallbackOptionPressPending(
+ fallbackOption: AuthenticationRequest.Biometric.Fallback.CustomOption
+ ) {
+ _isFallbackOptionPressPending.emit(fallbackOption)
+ }
+
override suspend fun setMoreOptionsButtonPressPending() {
_isMoreOptionsButtonPressPending.emit(Unit)
}
diff --git a/biometric/biometric/src/main/java/androidx/biometric/internal/data/CanceledFrom.kt b/biometric/biometric/src/main/java/androidx/biometric/internal/data/CanceledFrom.kt
index f2601f2..c82f0c5 100644
--- a/biometric/biometric/src/main/java/androidx/biometric/internal/data/CanceledFrom.kt
+++ b/biometric/biometric/src/main/java/androidx/biometric/internal/data/CanceledFrom.kt
@@ -35,6 +35,12 @@
NEGATIVE_BUTTON,
/**
+ * Authentication was canceled by the user by pressing the fallback option on the prompt
+ * fallback option page.
+ */
+ FALLBACK_OPTION,
+
+ /**
* Authentication was canceled by the user by pressing the "more options" button on the prompt
* content.
*/
diff --git a/biometric/biometric/src/main/java/androidx/biometric/internal/data/PromptConfigRepository.kt b/biometric/biometric/src/main/java/androidx/biometric/internal/data/PromptConfigRepository.kt
index 1ca6524..0398424 100644
--- a/biometric/biometric/src/main/java/androidx/biometric/internal/data/PromptConfigRepository.kt
+++ b/biometric/biometric/src/main/java/androidx/biometric/internal/data/PromptConfigRepository.kt
@@ -17,10 +17,12 @@
package androidx.biometric.internal.data
import android.os.Build
+import androidx.biometric.AuthenticationRequest.Biometric.Fallback
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.biometric.utils.AuthenticatorUtils
import androidx.biometric.utils.CryptoObjectUtils
+import kotlin.collections.emptyList
import kotlinx.coroutines.delay
/**
@@ -54,12 +56,15 @@
/** The type(s) of authenticators that may be invoked by the biometric prompt. */
@BiometricManager.AuthenticatorTypes val allowedAuthenticators: Int
- /** The text that should be shown for the negative button on the biometric prompt. */
- val negativeButtonText: CharSequence?
-
/** Whether the identity check is available on the current API level. */
var isIdentityCheckAvailable: Boolean
+ /**
+ * The fallback option list that should be shown on the biometric prompt screen or separate
+ * fallback options page.
+ */
+ val fallbackOptionList: List<Fallback>
+
/** Sets a text override for the negative button. */
fun setNegativeButtonTextOverride(negativeButtonTextOverride: CharSequence?)
@@ -146,17 +151,17 @@
* [BiometricPrompt.PromptInfo.getNegativeButtonText].
*/
private var _negativeButtonTextOverride: CharSequence? = null
- /**
- * The text that should be shown for the negative button on the biometric prompt.
- *
- * If non-null, the value set by [_negativeButtonTextOverride] is used. Otherwise, falls back to
- * the value returned by [BiometricPrompt.PromptInfo.getNegativeButtonText], or `null` if a
- * non-null [BiometricPrompt.PromptInfo] has not been set.
- *
- * @return The negative button text for the prompt, or `null` if not set.
- */
- override val negativeButtonText
- get() = _negativeButtonTextOverride ?: promptInfo?.negativeButtonText
+ override val fallbackOptionList
+ get() =
+ _negativeButtonTextOverride?.let {
+ listOf(Fallback.OverriddenDeviceCredential(it.toString()))
+ }
+ ?: promptInfo
+ ?.negativeButtonText
+ ?.takeUnless { it.isEmpty() }
+ ?.let { listOf(Fallback.CustomOption(it.toString())) }
+ ?: promptInfo?.fallbackOptionList
+ ?: emptyList()
override fun setNegativeButtonTextOverride(negativeButtonTextOverride: CharSequence?) {
_negativeButtonTextOverride = negativeButtonTextOverride
diff --git a/biometric/biometric/src/main/java/androidx/biometric/internal/ui/FingerprintDialogActivity.kt b/biometric/biometric/src/main/java/androidx/biometric/internal/ui/FingerprintDialogActivity.kt
index b272093..526f5bb 100644
--- a/biometric/biometric/src/main/java/androidx/biometric/internal/ui/FingerprintDialogActivity.kt
+++ b/biometric/biometric/src/main/java/androidx/biometric/internal/ui/FingerprintDialogActivity.kt
@@ -37,7 +37,6 @@
import androidx.biometric.BiometricPrompt
import androidx.biometric.R
import androidx.biometric.internal.data.CanceledFrom
-import androidx.biometric.internal.isManagingDeviceCredentialButton
import androidx.biometric.internal.viewmodel.AuthenticationViewModel
import androidx.biometric.internal.viewmodel.AuthenticationViewModelFactory
import androidx.biometric.internal.viewmodel.FingerprintDialogViewModel
@@ -185,7 +184,7 @@
) {
getString(R.string.confirm_device_credential_password)
} else {
- authenticationViewModel.negativeButtonText
+ authenticationViewModel.singleFallbackOptionText
}
builder.setNegativeButton(negativeButtonText) { _, _ ->
authenticationViewModel.setNegativeButtonPressPending()
@@ -305,9 +304,7 @@
// Define the special cases where we should NOT show an error message.
val isLockoutHandledByButton =
ErrorUtils.isLockoutError(knownErrorCode) &&
- isManagingDeviceCredentialButton(
- authenticationViewModel.allowedAuthenticators
- )
+ authenticationViewModel.isOverriddenDeviceCredential
val isCanceled = knownErrorCode == BiometricPrompt.ERROR_CANCELED
diff --git a/biometric/biometric/src/main/java/androidx/biometric/internal/viewmodel/AuthenticationViewModel.kt b/biometric/biometric/src/main/java/androidx/biometric/internal/viewmodel/AuthenticationViewModel.kt
index 5fce258..fb180ad 100644
--- a/biometric/biometric/src/main/java/androidx/biometric/internal/viewmodel/AuthenticationViewModel.kt
+++ b/biometric/biometric/src/main/java/androidx/biometric/internal/viewmodel/AuthenticationViewModel.kt
@@ -18,6 +18,7 @@
import android.content.DialogInterface
import android.graphics.Bitmap
+import androidx.biometric.AuthenticationRequest.Biometric.Fallback
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.biometric.PromptContentView
@@ -169,9 +170,30 @@
val isConfirmationRequired: Boolean
get() = promptInfo?.isConfirmationRequired ?: true
- /** The text that should be shown for the negative button on the biometric prompt. */
- val negativeButtonText: CharSequence?
- get() = promptConfigRepository.negativeButtonText
+ private val fallbackOptions: List<Fallback>
+ get() = promptConfigRepository.fallbackOptionList
+
+ /** List of options if multiple exist; otherwise null. */
+ val multipleFallbackOptionList: List<Fallback>?
+ get() = fallbackOptions.takeIf { it.size > 1 }
+
+ /** The single fallback option if exactly one exists. */
+ val singleFallbackOption: Fallback?
+ get() = fallbackOptions.singleOrNull()
+
+ /** Checks if the single option is a device credential override. */
+ val isOverriddenDeviceCredential: Boolean
+ get() = singleFallbackOption is Fallback.OverriddenDeviceCredential
+
+ /** The display text for the single fallback option. */
+ val singleFallbackOptionText: CharSequence?
+ get() =
+ when (val option = singleFallbackOption) {
+ is Fallback.OverriddenDeviceCredential -> option.text
+ is Fallback.CustomOption -> option.text
+ is Fallback.DefaultCancel -> option.text
+ else -> null
+ }
/** A provider for cross-platform compatible cancellation signal objects. */
val cancellationSignalProvider: CancellationSignalProvider
@@ -187,6 +209,14 @@
NegativeButtonListener(this)
}
+ /**
+ * A dialog listener for the fallback option shown on the biometric prompt separate fallback
+ * options page.
+ */
+ fun fallbackOptionListener(fallback: Fallback.CustomOption): DialogInterface.OnClickListener {
+ return FallbackOptionListener(this, fallback)
+ }
+
/** A dialog listener for the more options button shown on the prompt content. */
val moreOptionsButtonListener: DialogInterface.OnClickListener by lazy {
MoreOptionsButtonListener(this)
@@ -212,6 +242,10 @@
val isNegativeButtonPressPending: Flow<Unit>
get() = authenticationStateRepository.isNegativeButtonPressPending
+ /** A flow that emits when the fallback option is pressed. */
+ val isFallbackOptionPressPending: Flow<Fallback.CustomOption>
+ get() = authenticationStateRepository.isFallbackOptionPressPending
+
/** A flow that emits when the more options button is pressed. */
val isMoreOptionsButtonPressPending: Flow<Unit>
get() = authenticationStateRepository.isMoreOptionsButtonPressPending
@@ -337,6 +371,12 @@
viewModelScope.launch { authenticationStateRepository.setNegativeButtonPressPending() }
}
+ fun setFallbackOptionPressPending(fallbackOption: Fallback.CustomOption) {
+ viewModelScope.launch {
+ authenticationStateRepository.setFallbackOptionPressPending(fallbackOption)
+ }
+ }
+
/** Emits an event for a more options button press. */
fun setMoreOptionsButtonPressPending() {
viewModelScope.launch { authenticationStateRepository.setMoreOptionsButtonPressPending() }
@@ -402,6 +442,18 @@
}
}
+ /** The dialog listener that is returned by [fallbackOptionListener]. */
+ private class FallbackOptionListener(
+ viewModel: AuthenticationViewModel?,
+ val fallbackOption: Fallback.CustomOption,
+ ) : DialogInterface.OnClickListener {
+ private val viewModelRef: WeakReference<AuthenticationViewModel> = WeakReference(viewModel)
+
+ override fun onClick(dialogInterface: DialogInterface?, which: Int) {
+ viewModelRef.get()?.setFallbackOptionPressPending(fallbackOption)
+ }
+ }
+
/** The dialog listener that is returned by [moreOptionsButtonListener]. */
private class MoreOptionsButtonListener(viewModel: AuthenticationViewModel?) :
DialogInterface.OnClickListener {
diff --git a/biometric/biometric/src/test-common/java/androidx/biometric/internal/data/FakeAuthenticationStateRepository.kt b/biometric/biometric/src/test-common/java/androidx/biometric/internal/data/FakeAuthenticationStateRepository.kt
index e624396..4b8ee30 100644
--- a/biometric/biometric/src/test-common/java/androidx/biometric/internal/data/FakeAuthenticationStateRepository.kt
+++ b/biometric/biometric/src/test-common/java/androidx/biometric/internal/data/FakeAuthenticationStateRepository.kt
@@ -16,6 +16,7 @@
package androidx.biometric.internal.data
+import androidx.biometric.AuthenticationRequest
import androidx.biometric.BiometricPrompt
import androidx.biometric.utils.BiometricErrorData
import androidx.biometric.utils.CancellationSignalProvider
@@ -57,6 +58,12 @@
override val isNegativeButtonPressPending: SharedFlow<Unit> =
_isNegativeButtonPressPending.asSharedFlow()
+ private val _isFallbackOptionPressPending =
+ MutableSharedFlow<AuthenticationRequest.Biometric.Fallback.CustomOption>()
+ override val isFallbackOptionPressPending:
+ SharedFlow<AuthenticationRequest.Biometric.Fallback.CustomOption> =
+ _isFallbackOptionPressPending.asSharedFlow()
+
private val _isMoreOptionsButtonPressPending = MutableSharedFlow<Unit>()
override val isMoreOptionsButtonPressPending: SharedFlow<Unit> =
_isMoreOptionsButtonPressPending.asSharedFlow()
@@ -87,6 +94,12 @@
_isNegativeButtonPressPending.emit(Unit)
}
+ override suspend fun setFallbackOptionPressPending(
+ fallbackOption: AuthenticationRequest.Biometric.Fallback.CustomOption
+ ) {
+ _isFallbackOptionPressPending.emit(fallbackOption)
+ }
+
override suspend fun setMoreOptionsButtonPressPending() {
_isMoreOptionsButtonPressPending.emit(Unit)
}
diff --git a/biometric/biometric/src/test-common/java/androidx/biometric/internal/data/FakePromptConfigRepository.kt b/biometric/biometric/src/test-common/java/androidx/biometric/internal/data/FakePromptConfigRepository.kt
index 554b5bc..8ffa3b8 100644
--- a/biometric/biometric/src/test-common/java/androidx/biometric/internal/data/FakePromptConfigRepository.kt
+++ b/biometric/biometric/src/test-common/java/androidx/biometric/internal/data/FakePromptConfigRepository.kt
@@ -16,6 +16,7 @@
package androidx.biometric.internal.data
+import androidx.biometric.AuthenticationRequest.Biometric.Fallback
import androidx.biometric.BiometricPrompt
/** A fake implementation of [PromptConfigRepository] for testing purposes. */
@@ -38,8 +39,17 @@
get() = promptInfo?.allowedAuthenticators ?: 0
private var _negativeButtonTextOverride: CharSequence? = null
- override val negativeButtonText: CharSequence?
- get() = _negativeButtonTextOverride ?: promptInfo?.negativeButtonText
+ override val fallbackOptionList: List<Fallback>
+ get() =
+ _negativeButtonTextOverride?.let {
+ listOf(Fallback.OverriddenDeviceCredential(it.toString()))
+ }
+ ?: promptInfo
+ ?.negativeButtonText
+ ?.takeUnless { it.isEmpty() }
+ ?.let { listOf(Fallback.CustomOption(it.toString())) }
+ ?: promptInfo?.fallbackOptionList
+ ?: emptyList()
override var isIdentityCheckAvailable = false
diff --git a/biometric/biometric/src/test/java/androidx/biometric/AuthenticationRequestTest.kt b/biometric/biometric/src/test/java/androidx/biometric/AuthenticationRequestTest.kt
new file mode 100644
index 0000000..b423341
--- /dev/null
+++ b/biometric/biometric/src/test/java/androidx/biometric/AuthenticationRequestTest.kt
@@ -0,0 +1,151 @@
+/*
+ * Copyright 2026 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.biometric
+
+import androidx.biometric.AuthenticationRequest.Biometric
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.common.truth.Truth.assertThat
+import org.junit.Assert.assertThrows
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class AuthenticationRequestTest {
+
+ @Test
+ fun biometricRequest_withEmptyFallbacks_doesNotThrow() {
+ val request =
+ AuthenticationRequest.biometricRequest(
+ title = "Title",
+ authFallbacks = emptyArray<Biometric.Fallback>(),
+ ) {}
+ assertThat(request.authFallbacks).isEmpty()
+ }
+
+ @Test
+ fun biometricRequest_withMultipleDeviceCredentials_throwsIllegalArgumentException() {
+ assertThrows(IllegalArgumentException::class.java) {
+ AuthenticationRequest.biometricRequest(
+ title = "Title",
+ Biometric.Fallback.DeviceCredential,
+ Biometric.Fallback.DeviceCredential,
+ ) {}
+ }
+ }
+
+ @Test
+ fun biometricRequest_withTooManyFallbacks_throwsIllegalArgumentException() {
+ val maxFallbacks = Biometric.getMaxFallbackOptions()
+ val fallbacks = Array(maxFallbacks + 1) { Biometric.Fallback.CustomOption("Option $it") }
+ assertThrows(IllegalArgumentException::class.java) {
+ AuthenticationRequest.biometricRequest(title = "Title", authFallbacks = fallbacks) {}
+ }
+ }
+
+ @Test
+ fun biometricRequest_withValidFallbacks_doesNotThrow() {
+ val request =
+ AuthenticationRequest.biometricRequest(
+ title = "Title",
+ Biometric.Fallback.CustomOption("Cancel"),
+ ) {
+ setSubtitle("Subtitle")
+ }
+ assertThat(request.title).isEqualTo("Title")
+ assertThat(request.subtitle).isEqualTo("Subtitle")
+ assertThat(request.authFallbacks).hasSize(1)
+ assertThat((request.authFallbacks[0] as Biometric.Fallback.CustomOption).text)
+ .isEqualTo("Cancel")
+ }
+
+ @Test
+ fun biometricRequest_withMultipleCustomOptions_storesAllFallbacks() {
+ val request =
+ AuthenticationRequest.biometricRequest(
+ title = "Title",
+ Biometric.Fallback.CustomOption("Option 1"),
+ Biometric.Fallback.CustomOption("Option 2"),
+ ) {}
+ assertThat(request.authFallbacks).hasSize(2)
+ assertThat((request.authFallbacks[0] as Biometric.Fallback.CustomOption).text)
+ .isEqualTo("Option 1")
+ assertThat((request.authFallbacks[1] as Biometric.Fallback.CustomOption).text)
+ .isEqualTo("Option 2")
+ }
+
+ @Test
+ fun biometricRequest_withCustomOptionAndDeviceCredential_storesBoth() {
+ val request =
+ AuthenticationRequest.biometricRequest(
+ title = "Title",
+ Biometric.Fallback.CustomOption("Cancel"),
+ Biometric.Fallback.DeviceCredential,
+ ) {}
+ assertThat(request.authFallbacks).hasSize(2)
+ assertThat(request.authFallbacks[0])
+ .isInstanceOf(Biometric.Fallback.CustomOption::class.java)
+ assertThat(request.authFallbacks[1]).isEqualTo(Biometric.Fallback.DeviceCredential)
+ }
+
+ @Test
+ fun biometricRequest_withMaxFallbacks_storesAll() {
+ val maxFallbacks = Biometric.getMaxFallbackOptions()
+ val fallbacks = Array(maxFallbacks) { Biometric.Fallback.CustomOption("Option $it") }
+ val request =
+ AuthenticationRequest.biometricRequest(title = "Title", authFallbacks = fallbacks) {}
+ assertThat(request.authFallbacks).hasSize(maxFallbacks)
+ }
+
+ @Test
+ fun biometricRequest_usingBuilder_storesOptionsCorrectly() {
+ val fallback = Biometric.Fallback.CustomOption("Option")
+ val request =
+ Biometric.Builder("Title", fallback)
+ .setSubtitle("Subtitle")
+ .setContent(AuthenticationRequest.BodyContent.PlainText("Content"))
+ .setIsConfirmationRequired(false)
+ .build()
+
+ assertThat(request.title).isEqualTo("Title")
+ assertThat(request.subtitle).isEqualTo("Subtitle")
+ assertThat((request.content as AuthenticationRequest.BodyContent.PlainText).description)
+ .isEqualTo("Content")
+ assertThat(request.isConfirmationRequired).isFalse()
+ assertThat(request.authFallbacks).containsExactly(fallback)
+ }
+
+ @Test
+ fun biometricRequest_withMultipleValidFallbacks_storesAllFallbacks() {
+ val request =
+ AuthenticationRequest.biometricRequest(
+ title = "Title",
+ Biometric.Fallback.CustomOption("Option 1", BiometricPrompt.ICON_TYPE_PASSWORD),
+ Biometric.Fallback.CustomOption("Option 2", BiometricPrompt.ICON_TYPE_ACCOUNT),
+ Biometric.Fallback.DeviceCredential,
+ ) {}
+ assertThat(request.authFallbacks).hasSize(3)
+ assertThat((request.authFallbacks[0] as Biometric.Fallback.CustomOption).text)
+ .isEqualTo("Option 1")
+ assertThat((request.authFallbacks[0] as Biometric.Fallback.CustomOption).iconType)
+ .isEqualTo(BiometricPrompt.ICON_TYPE_PASSWORD)
+ assertThat((request.authFallbacks[1] as Biometric.Fallback.CustomOption).text)
+ .isEqualTo("Option 2")
+ assertThat((request.authFallbacks[1] as Biometric.Fallback.CustomOption).iconType)
+ .isEqualTo(BiometricPrompt.ICON_TYPE_ACCOUNT)
+ assertThat(request.authFallbacks[2]).isEqualTo(Biometric.Fallback.DeviceCredential)
+ }
+}
diff --git a/biometric/biometric/src/test/java/androidx/biometric/AuthenticationResultTest.kt b/biometric/biometric/src/test/java/androidx/biometric/AuthenticationResultTest.kt
new file mode 100644
index 0000000..a769279
--- /dev/null
+++ b/biometric/biometric/src/test/java/androidx/biometric/AuthenticationResultTest.kt
@@ -0,0 +1,83 @@
+/*
+ * Copyright 2026 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.biometric
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class AuthenticationResultTest {
+
+ @Test
+ fun success_helpersReturnCorrectValues() {
+ val success =
+ AuthenticationResult.Success(null, BiometricPrompt.AUTHENTICATION_RESULT_TYPE_BIOMETRIC)
+
+ assertThat(success.isSuccess()).isTrue()
+ assertThat(success.isError()).isFalse()
+ assertThat(success.isCustomFallbackSelected()).isFalse()
+
+ assertThat(success.success()).isEqualTo(success)
+ assertThat(success.error()).isNull()
+ assertThat(success.customFallbackSelected()).isNull()
+ }
+
+ @Test
+ fun success_withCryptoObject_returnsCorrectValues() {
+ val crypto =
+ BiometricPrompt.CryptoObject(javax.crypto.Cipher.getInstance("AES/CBC/PKCS5Padding"))
+ val success =
+ AuthenticationResult.Success(
+ crypto,
+ BiometricPrompt.AUTHENTICATION_RESULT_TYPE_BIOMETRIC,
+ )
+
+ assertThat(success.crypto).isEqualTo(crypto)
+ assertThat(success.authType).isEqualTo(BiometricPrompt.AUTHENTICATION_RESULT_TYPE_BIOMETRIC)
+ }
+
+ @Test
+ fun error_helpersReturnCorrectValues() {
+ val error = AuthenticationResult.Error(BiometricPrompt.ERROR_CANCELED, "Canceled")
+
+ assertThat(error.isSuccess()).isFalse()
+ assertThat(error.isError()).isTrue()
+ assertThat(error.isCustomFallbackSelected()).isFalse()
+
+ assertThat(error.error()).isEqualTo(error)
+ assertThat(error.success()).isNull()
+ assertThat(error.customFallbackSelected()).isNull()
+ }
+
+ @Test
+ fun customFallbackSelected_helpersReturnCorrectValues() {
+ val fallback = AuthenticationRequest.Biometric.Fallback.CustomOption("Test Fallback")
+ val result = AuthenticationResult.CustomFallbackSelected(fallback)
+
+ assertThat(result.isSuccess()).isFalse()
+ assertThat(result.isError()).isFalse()
+ assertThat(result.isCustomFallbackSelected()).isTrue()
+
+ assertThat(result.customFallbackSelected()).isEqualTo(result)
+ assertThat(result.fallback).isEqualTo(fallback)
+
+ assertThat(result.success()).isNull()
+ assertThat(result.error()).isNull()
+ }
+}
diff --git a/biometric/biometric/src/test/java/androidx/biometric/BiometricPromptTest.java b/biometric/biometric/src/test/java/androidx/biometric/BiometricPromptTest.java
index 8e622d8..eec2892 100644
--- a/biometric/biometric/src/test/java/androidx/biometric/BiometricPromptTest.java
+++ b/biometric/biometric/src/test/java/androidx/biometric/BiometricPromptTest.java
@@ -148,6 +148,84 @@
.getDescription()).isEqualTo(contentDescription);
}
+ @Test
+ public void testPromptInfo_CanSetAndGetOptions_fallbackOptions() {
+ final String title = "Title";
+ final String negativeButtonText = "Negative";
+ final AuthenticationRequest.Biometric.Fallback.CustomOption fallback =
+ new AuthenticationRequest.Biometric.Fallback.CustomOption("fallback",
+ BiometricPrompt.ICON_TYPE_ACCOUNT);
+
+ final BiometricPrompt.PromptInfo info = new BiometricPrompt.PromptInfo.Builder()
+ .setTitle(title)
+ .setNegativeButtonText(negativeButtonText)
+ .addFallbackOption(fallback)
+ .build();
+
+ assertThat(info.getFallbackOptionList()).containsExactly(fallback);
+ }
+
+ @Test
+ public void testPromptInfo_CanSetAndGetOptions_multipleFallbackOptions() {
+ final String title = "Title";
+ final AuthenticationRequest.Biometric.Fallback.CustomOption fallback1 =
+ new AuthenticationRequest.Biometric.Fallback.CustomOption("fallback 1",
+ BiometricPrompt.ICON_TYPE_PASSWORD);
+ final AuthenticationRequest.Biometric.Fallback.CustomOption fallback2 =
+ new AuthenticationRequest.Biometric.Fallback.CustomOption("fallback 2",
+ BiometricPrompt.ICON_TYPE_ACCOUNT);
+
+ final BiometricPrompt.PromptInfo info = new BiometricPrompt.PromptInfo.Builder()
+ .setTitle(title)
+ .addFallbackOption(fallback1)
+ .addFallbackOption(fallback2)
+ .build();
+
+ assertThat(info.getFallbackOptionList()).containsExactly(fallback1, fallback2);
+ }
+
+ @Test
+ public void testPromptInfo_CanSetAndGetOptions_iconTypes() {
+ final AuthenticationRequest.Biometric.Fallback.CustomOption fallback =
+ new AuthenticationRequest.Biometric.Fallback.CustomOption("fallback",
+ BiometricPrompt.ICON_TYPE_QR_CODE);
+
+ final BiometricPrompt.PromptInfo info = new BiometricPrompt.PromptInfo.Builder()
+ .setTitle("Title")
+ .addFallbackOption(fallback)
+ .build();
+
+ assertThat(((AuthenticationRequest.Biometric.Fallback.CustomOption)
+ info.getFallbackOptionList().get(0)).getIconType())
+ .isEqualTo(BiometricPrompt.ICON_TYPE_QR_CODE);
+ }
+
+ @Test
+ public void testPromptInfo_DefaultFallbackOptionListIsNull() {
+ final BiometricPrompt.PromptInfo info = new BiometricPrompt.PromptInfo.Builder()
+ .setTitle("Title")
+ .setNegativeButtonText("Negative")
+ .build();
+
+ assertThat(info.getFallbackOptionList()).isNull();
+ }
+
+ @Test
+ public void testPromptInfo_CanBuildWithMixedFallbackOptions() {
+ final AuthenticationRequest.Biometric.Fallback.CustomOption fallback1 =
+ new AuthenticationRequest.Biometric.Fallback.CustomOption("fallback 1");
+ final AuthenticationRequest.Biometric.Fallback.DeviceCredential fallback2 =
+ AuthenticationRequest.Biometric.Fallback.DeviceCredential.INSTANCE;
+
+ final BiometricPrompt.PromptInfo info = new BiometricPrompt.PromptInfo.Builder()
+ .setTitle("Title")
+ .addFallbackOption(fallback1)
+ .addFallbackOption(fallback2)
+ .build();
+
+ assertThat(info.getFallbackOptionList()).containsExactly(fallback1, fallback2);
+ }
+
@Test(expected = IllegalArgumentException.class)
public void testPromptInfo_FailsToBuild_WithNoTitle() {
new BiometricPrompt.PromptInfo.Builder().setNegativeButtonText("Cancel").build();
@@ -162,29 +240,6 @@
}
@Test(expected = IllegalArgumentException.class)
- public void testPromptInfo_FailsToBuild_WithNoNegativeText() {
- new BiometricPrompt.PromptInfo.Builder().setTitle("Title").build();
- }
-
- @Test(expected = IllegalArgumentException.class)
- public void testPromptInfo_FailsToBuild_WithEmptyNegativeText() {
- new BiometricPrompt.PromptInfo.Builder()
- .setTitle("Title")
- .setNegativeButtonText("")
- .build();
- }
-
- @Test(expected = IllegalArgumentException.class)
- public void testPromptInfo_FailsToBuild_WithNegativeTextAndDeviceCredential() {
- new BiometricPrompt.PromptInfo.Builder()
- .setTitle("Title")
- .setNegativeButtonText("Cancel")
- .setAllowedAuthenticators(
- Authenticators.BIOMETRIC_WEAK | Authenticators.DEVICE_CREDENTIAL)
- .build();
- }
-
- @Test(expected = IllegalArgumentException.class)
@Config(maxSdk = Build.VERSION_CODES.Q)
public void testPromptInfo_FailsToBuild_WithUnsupportedAuthenticatorCombination() {
new BiometricPrompt.PromptInfo.Builder()
diff --git a/biometric/biometric/src/test/java/androidx/biometric/internal/AuthenticationHandlerBiometricPromptTest.kt b/biometric/biometric/src/test/java/androidx/biometric/internal/AuthenticationHandlerBiometricPromptTest.kt
index 4517311..16d4d99e 100644
--- a/biometric/biometric/src/test/java/androidx/biometric/internal/AuthenticationHandlerBiometricPromptTest.kt
+++ b/biometric/biometric/src/test/java/androidx/biometric/internal/AuthenticationHandlerBiometricPromptTest.kt
@@ -20,6 +20,7 @@
import android.app.KeyguardManager
import android.content.Context
import android.os.Build
+import androidx.biometric.AuthenticationRequest
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.biometric.internal.data.CanceledFrom
@@ -40,6 +41,8 @@
import org.robolectric.annotation.Config
import org.robolectric.shadows.ShadowKeyguardManager
+private const val NEGATIVE_BUTTON_TEXT = "test"
+
@RunWith(AndroidJUnit4::class)
@Config(minSdk = Build.VERSION_CODES.P)
class AuthenticationHandlerBiometricPromptTest {
@@ -52,11 +55,18 @@
private var isConfirmCredentialActivityLaunched = false
private var errorCode: Int = -1
+ private var fallback: AuthenticationRequest.Biometric.Fallback.CustomOption? = null
private val clientAuthenticationCallback =
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
this@AuthenticationHandlerBiometricPromptTest.errorCode = errorCode
}
+
+ override fun onFallbackSelected(
+ fallback: AuthenticationRequest.Biometric.Fallback.CustomOption
+ ) {
+ this@AuthenticationHandlerBiometricPromptTest.fallback = fallback
+ }
}
private lateinit var authenticationHandler: AuthenticationHandlerBiometricPrompt
@@ -136,24 +146,60 @@
}
@Test
- fun onNegativeButtonPressed_sendsErrorAndCancels() {
+ fun onFallbackOptionPressed_sendsFallbackOptionAndCancels() {
+ authenticationHandler.authenticate(getPromptInfo(), null)
+ val cancellationSignal = viewModel.cancellationSignalProvider.biometricCancellationSignal
+ assertThat(cancellationSignal.isCanceled).isFalse()
+
+ val expectedFallback = AuthenticationRequest.Biometric.Fallback.CustomOption("test")
+ viewModel.setFallbackOptionPressPending(expectedFallback)
+
+ assertThat(fallback).isEqualTo(expectedFallback)
+ assertThat(cancellationSignal.isCanceled).isTrue()
+ assertThat(viewModel.canceledFrom).isEqualTo(CanceledFrom.FALLBACK_OPTION)
+ }
+
+ @Test
+ fun onNegativeButtonPressed_sendsFallbackOptionAndCancels() {
authenticationHandler.authenticate(getPromptInfo(), null)
val cancellationSignal = viewModel.cancellationSignalProvider.biometricCancellationSignal
assertThat(cancellationSignal.isCanceled).isFalse()
viewModel.setNegativeButtonPressPending()
- assertThat(errorCode).isEqualTo(BiometricPrompt.ERROR_NEGATIVE_BUTTON)
+ assertThat(fallback?.text).isEqualTo(NEGATIVE_BUTTON_TEXT)
assertThat(cancellationSignal.isCanceled).isTrue()
assertThat(viewModel.canceledFrom).isEqualTo(CanceledFrom.NEGATIVE_BUTTON)
}
+ @Test
+ fun onDefaultCancelButtonPressed_sendsErrorAndCancels() {
+ val authenticators = BiometricManager.Authenticators.BIOMETRIC_WEAK
+ val builder = BiometricPrompt.PromptInfo.Builder().setTitle("test")
+ val defaultCancelText = "Cancel"
+ builder.addFallbackOption(
+ AuthenticationRequest.Biometric.Fallback.DefaultCancel(defaultCancelText)
+ )
+ builder.setAllowedAuthenticators(authenticators)
+ authenticationHandler.authenticate(builder.build(), null)
+ val cancellationSignal = viewModel.cancellationSignalProvider.biometricCancellationSignal
+ assertThat(cancellationSignal.isCanceled).isFalse()
+
+ viewModel.setNegativeButtonPressPending()
+
+ assertThat(errorCode).isEqualTo(BiometricPrompt.ERROR_CANCELED)
+ assertThat(cancellationSignal.isCanceled).isTrue()
+ assertThat(viewModel.canceledFrom).isEqualTo(CanceledFrom.USER)
+ }
+
private fun getPromptInfo(
authenticators: Int = BiometricManager.Authenticators.BIOMETRIC_WEAK
): BiometricPrompt.PromptInfo {
val builder = BiometricPrompt.PromptInfo.Builder().setTitle("test")
if (!AuthenticatorUtils.isDeviceCredentialAllowed(authenticators)) {
- builder.setNegativeButtonText("test")
+ builder.addFallbackOption(
+ AuthenticationRequest.Biometric.Fallback.CustomOption(NEGATIVE_BUTTON_TEXT)
+ )
}
builder.setAllowedAuthenticators(authenticators)
return builder.build()
diff --git a/biometric/biometric/src/test/java/androidx/biometric/internal/AuthenticationHandlerFingerprintManagerTest.kt b/biometric/biometric/src/test/java/androidx/biometric/internal/AuthenticationHandlerFingerprintManagerTest.kt
index a6888ce..3f134b9 100644
--- a/biometric/biometric/src/test/java/androidx/biometric/internal/AuthenticationHandlerFingerprintManagerTest.kt
+++ b/biometric/biometric/src/test/java/androidx/biometric/internal/AuthenticationHandlerFingerprintManagerTest.kt
@@ -21,6 +21,7 @@
import android.content.Context
import android.hardware.biometrics.BiometricManager
import android.os.Build
+import androidx.biometric.AuthenticationRequest
import androidx.biometric.BiometricPrompt
import androidx.biometric.internal.data.CanceledFrom
import androidx.biometric.internal.data.FakeAuthenticationStateRepository
@@ -40,6 +41,8 @@
import org.robolectric.annotation.Config
import org.robolectric.shadows.ShadowKeyguardManager
+private const val NEGATIVE_BUTTON_TEXT = "test"
+
@RunWith(AndroidJUnit4::class)
class AuthenticationHandlerFingerprintManagerTest {
private val context: Application = ApplicationProvider.getApplicationContext()
@@ -51,11 +54,18 @@
private var isConfirmCredentialActivityLaunched = false
private var errorCode: Int = -1
+ private var fallback: AuthenticationRequest.Biometric.Fallback.CustomOption? = null
private val clientAuthenticationCallback =
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
this@AuthenticationHandlerFingerprintManagerTest.errorCode = errorCode
}
+
+ override fun onFallbackSelected(
+ fallback: AuthenticationRequest.Biometric.Fallback.CustomOption
+ ) {
+ this@AuthenticationHandlerFingerprintManagerTest.fallback = fallback
+ }
}
private lateinit var authenticationHandler: AuthenticationHandlerFingerprintManager
@@ -167,12 +177,47 @@
assertThat(errorCode).isEqualTo(BiometricPrompt.ERROR_HW_UNAVAILABLE)
}
+ @Test
+ fun onNegativeButtonPressed_sendsFallbackOptionAndCancels() {
+ authenticationHandler.authenticate(getPromptInfo(), null)
+ val cancellationSignal = viewModel.cancellationSignalProvider.fingerprintCancellationSignal
+ assertThat(cancellationSignal.isCanceled).isFalse()
+
+ viewModel.setNegativeButtonPressPending()
+
+ assertThat(fallback?.text).isEqualTo(NEGATIVE_BUTTON_TEXT)
+ assertThat(cancellationSignal.isCanceled).isTrue()
+ assertThat(viewModel.canceledFrom).isEqualTo(CanceledFrom.NEGATIVE_BUTTON)
+ }
+
+ @Test
+ fun onDefaultCancelButtonPressed_sendsErrorAndCancels() {
+ val authenticators = androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_WEAK
+ val builder = BiometricPrompt.PromptInfo.Builder().setTitle("test")
+ val defaultCancelText = "Cancel"
+ builder.addFallbackOption(
+ AuthenticationRequest.Biometric.Fallback.DefaultCancel(defaultCancelText)
+ )
+ builder.setAllowedAuthenticators(authenticators)
+ authenticationHandler.authenticate(builder.build(), null)
+ val cancellationSignal = viewModel.cancellationSignalProvider.biometricCancellationSignal
+ assertThat(cancellationSignal.isCanceled).isFalse()
+
+ viewModel.setNegativeButtonPressPending()
+
+ assertThat(errorCode).isEqualTo(BiometricPrompt.ERROR_CANCELED)
+ assertThat(cancellationSignal.isCanceled).isTrue()
+ assertThat(viewModel.canceledFrom).isEqualTo(CanceledFrom.USER)
+ }
+
private fun getPromptInfo(
authenticators: Int = BiometricManager.Authenticators.BIOMETRIC_WEAK
): BiometricPrompt.PromptInfo {
val builder = BiometricPrompt.PromptInfo.Builder().setTitle("test")
if (!AuthenticatorUtils.isDeviceCredentialAllowed(authenticators)) {
- builder.setNegativeButtonText("test")
+ builder.addFallbackOption(
+ AuthenticationRequest.Biometric.Fallback.CustomOption(NEGATIVE_BUTTON_TEXT)
+ )
}
builder.setAllowedAuthenticators(authenticators)
return builder.build()
diff --git a/biometric/biometric/src/test/java/androidx/biometric/internal/AuthenticationManagerTest.kt b/biometric/biometric/src/test/java/androidx/biometric/internal/AuthenticationManagerTest.kt
index 3b3360a..dab05d5 100644
--- a/biometric/biometric/src/test/java/androidx/biometric/internal/AuthenticationManagerTest.kt
+++ b/biometric/biometric/src/test/java/androidx/biometric/internal/AuthenticationManagerTest.kt
@@ -17,6 +17,7 @@
package androidx.biometric.internal
import android.app.Application
+import androidx.biometric.AuthenticationRequest
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.biometric.internal.data.CanceledFrom
@@ -56,6 +57,7 @@
private var authErrorCode: Int = -1
private var authErrorString: CharSequence = ""
private var authResult: BiometricPrompt.AuthenticationResult? = null
+ private var authFallback: AuthenticationRequest.Biometric.Fallback.CustomOption? = null
private var authFailed: Boolean = false
private val clientAuthenticationCallback =
object : BiometricPrompt.AuthenticationCallback() {
@@ -68,6 +70,12 @@
this@AuthenticationManagerTest.authResult = result
}
+ override fun onFallbackSelected(
+ fallback: AuthenticationRequest.Biometric.Fallback.CustomOption
+ ) {
+ this@AuthenticationManagerTest.authFallback = fallback
+ }
+
override fun onAuthenticationFailed() {
this@AuthenticationManagerTest.authFailed = true
}
@@ -96,7 +104,7 @@
assertThat(viewModel.isPromptShowing).isTrue()
assertThat(viewModel.isAwaitingResult).isTrue()
assertThat(viewModel.title).isEqualTo(promptInfo.title)
- assertThat(viewModel.negativeButtonText).isEqualTo(promptInfo.negativeButtonText)
+ assertThat(viewModel.singleFallbackOptionText).isEqualTo(promptInfo.negativeButtonText)
assertThat(viewModel.allowedAuthenticators).isEqualTo(promptInfo.allowedAuthenticators)
}
@@ -357,9 +365,4 @@
builder.setAllowedAuthenticators(authenticators)
return builder.build()
}
-
- companion object {
- private const val AUTHENTICATION_KEY1 = 1
- private const val AUTHENTICATION_KEY2 = 2
- }
}
diff --git a/biometric/biometric/src/test/java/androidx/biometric/internal/AuthenticationResultDispatcherTest.kt b/biometric/biometric/src/test/java/androidx/biometric/internal/AuthenticationResultDispatcherTest.kt
index 023280a..f092a61 100644
--- a/biometric/biometric/src/test/java/androidx/biometric/internal/AuthenticationResultDispatcherTest.kt
+++ b/biometric/biometric/src/test/java/androidx/biometric/internal/AuthenticationResultDispatcherTest.kt
@@ -17,6 +17,7 @@
package androidx.biometric.internal
import android.app.Application
+import androidx.biometric.AuthenticationRequest
import androidx.biometric.BiometricPrompt
import androidx.biometric.R
import androidx.biometric.internal.data.FakeAuthenticationStateRepository
@@ -44,6 +45,7 @@
private var authErrorCode: Int = -1
private var authErrorString: CharSequence = ""
private var authResult: BiometricPrompt.AuthenticationResult? = null
+ private var authFallback: AuthenticationRequest.Biometric.Fallback.CustomOption? = null
private var authFailed: Boolean = false
private val clientAuthenticationCallback =
object : BiometricPrompt.AuthenticationCallback() {
@@ -56,6 +58,12 @@
this@AuthenticationResultDispatcherTest.authResult = result
}
+ override fun onFallbackSelected(
+ fallback: AuthenticationRequest.Biometric.Fallback.CustomOption
+ ) {
+ this@AuthenticationResultDispatcherTest.authFallback = fallback
+ }
+
override fun onAuthenticationFailed() {
this@AuthenticationResultDispatcherTest.authFailed = true
}
@@ -91,6 +99,17 @@
}
@Test
+ fun testSendFallbackOptionAndDismiss_sendsFallbackAndDismisses() {
+ viewModel.isAwaitingResult = true
+ val fallback = AuthenticationRequest.Biometric.Fallback.CustomOption("test")
+ dispatcher.sendFallbackOptionAndDismiss(fallback)
+
+ assertThat(authFallback).isEqualTo(fallback)
+ assertThat(isDismissed).isTrue()
+ assertThat(viewModel.isAwaitingResult).isFalse()
+ }
+
+ @Test
fun testOnAuthenticationSucceeded_whenNotAwaitingResult_doesNothing() {
viewModel.isAwaitingResult = false
val result = BiometricPrompt.AuthenticationResult(null, 0)
diff --git a/biometric/biometric/src/test/java/androidx/biometric/internal/data/AuthenticationStateRepositoryTest.kt b/biometric/biometric/src/test/java/androidx/biometric/internal/data/AuthenticationStateRepositoryTest.kt
index c0f76b3..d2f90baf 100644
--- a/biometric/biometric/src/test/java/androidx/biometric/internal/data/AuthenticationStateRepositoryTest.kt
+++ b/biometric/biometric/src/test/java/androidx/biometric/internal/data/AuthenticationStateRepositoryTest.kt
@@ -16,6 +16,7 @@
package androidx.biometric.internal.data
+import androidx.biometric.AuthenticationRequest
import androidx.biometric.BiometricPrompt
import androidx.biometric.utils.BiometricErrorData
import androidx.test.ext.junit.runners.AndroidJUnit4
@@ -114,6 +115,22 @@
}
@Test
+ fun testSetFallbackOptionPressPending() =
+ runTest(UnconfinedTestDispatcher()) {
+ var actualFallback: AuthenticationRequest.Biometric.Fallback.CustomOption? = null
+ val job = launch {
+ repository.isFallbackOptionPressPending.collect { actualFallback = it }
+ }
+
+ val expectedFallback = AuthenticationRequest.Biometric.Fallback.CustomOption("test")
+ repository.setFallbackOptionPressPending(expectedFallback)
+ runCurrent()
+
+ assertThat(actualFallback).isEqualTo(expectedFallback)
+ job.cancel()
+ }
+
+ @Test
fun testSetMoreOptionsButtonPressPending() =
runTest(UnconfinedTestDispatcher()) {
var moreOptionsPressPending = false
diff --git a/biometric/biometric/src/test/java/androidx/biometric/internal/viewmodel/AuthenticationViewModelTest.kt b/biometric/biometric/src/test/java/androidx/biometric/internal/viewmodel/AuthenticationViewModelTest.kt
index 626c1fc..1dba60d 100644
--- a/biometric/biometric/src/test/java/androidx/biometric/internal/viewmodel/AuthenticationViewModelTest.kt
+++ b/biometric/biometric/src/test/java/androidx/biometric/internal/viewmodel/AuthenticationViewModelTest.kt
@@ -16,6 +16,7 @@
package androidx.biometric.internal.viewmodel
+import androidx.biometric.AuthenticationRequest
import androidx.biometric.BiometricPrompt
import androidx.biometric.internal.data.FakeAuthenticationStateRepository
import androidx.biometric.internal.data.FakePromptConfigRepository
@@ -114,6 +115,22 @@
}
@Test
+ fun testFallbackOptionPressPending() =
+ runTest(UnconfinedTestDispatcher()) {
+ var actualFallback: AuthenticationRequest.Biometric.Fallback.CustomOption? = null
+ val job = launch {
+ viewModel.isFallbackOptionPressPending.collect { actualFallback = it }
+ }
+
+ val expectedFallback = AuthenticationRequest.Biometric.Fallback.CustomOption("test")
+ authRepository.setFallbackOptionPressPending(expectedFallback)
+ runCurrent()
+
+ assertThat(actualFallback).isEqualTo(expectedFallback)
+ job.cancel()
+ }
+
+ @Test
fun testMoreOptionsButtonPressPending() =
runTest(UnconfinedTestDispatcher()) {
var moreOptionsPressPending = false
diff --git a/biometric/integration-tests/testapp-compose/src/main/java/androidx/biometric/integration/testappcompose/MainActivity.kt b/biometric/integration-tests/testapp-compose/src/main/java/androidx/biometric/integration/testappcompose/MainActivity.kt
index 94b872f..9776f1a 100644
--- a/biometric/integration-tests/testapp-compose/src/main/java/androidx/biometric/integration/testappcompose/MainActivity.kt
+++ b/biometric/integration-tests/testapp-compose/src/main/java/androidx/biometric/integration/testappcompose/MainActivity.kt
@@ -78,7 +78,7 @@
launcher.launch(
biometricRequest(
title = "test",
- authFallback = AuthenticationRequest.Biometric.Fallback.DeviceCredential,
+ AuthenticationRequest.Biometric.Fallback.DeviceCredential,
) {
// Optionally set the other configurations. setSubtitle(), setContent(), etc
}
@@ -97,5 +97,7 @@
"AuthenticationResult Success, auth type: $authType, crypto object: $crypto"
is AuthenticationResult.Error ->
"AuthenticationResult Error, error code: $errorCode, err string: $errString"
+ is AuthenticationResult.CustomFallbackSelected ->
+ "AuthenticationResult CustomFallbackSelected, fallback option text: ${fallback.text}"
}
}
diff --git a/biometric/integration-tests/testapp/src/main/java/androidx/biometric/integration/testapp/AuthenticationResultTestActivity.kt b/biometric/integration-tests/testapp/src/main/java/androidx/biometric/integration/testapp/AuthenticationResultTestActivity.kt
index cbe9218..414653c 100644
--- a/biometric/integration-tests/testapp/src/main/java/androidx/biometric/integration/testapp/AuthenticationResultTestActivity.kt
+++ b/biometric/integration-tests/testapp/src/main/java/androidx/biometric/integration/testapp/AuthenticationResultTestActivity.kt
@@ -54,6 +54,9 @@
private val secondAuthResultLauncher = getAuthResultLauncher(2)
private val thirdAuthResultLauncher = getAuthResultLauncher(3)
+ private val fallbackOptionText1 = "Reset Button 1"
+ private val fallbackOptionText2 = "Account Button 2"
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = AuthenticationResultTestActivityBinding.inflate(layoutInflater)
@@ -122,15 +125,30 @@
null
}
} else {
- biometricRequest(
- title = title,
- authFallback =
- if (binding.credentialFallback.isChecked) {
- Biometric.Fallback.DeviceCredential
- } else {
- Biometric.Fallback.NegativeButton("Cancel button")
- },
- ) {
+ val authFallbacks: Array<Biometric.Fallback> =
+ when {
+ binding.credentialFallback.isChecked -> {
+ arrayOf(Biometric.Fallback.DeviceCredential)
+ }
+ binding.negativeButtonFallback.isChecked -> {
+ arrayOf(Biometric.Fallback.CustomOption(fallbackOptionText1))
+ }
+ binding.multipleFallbackOptions.isChecked -> {
+ arrayOf(
+ Biometric.Fallback.CustomOption(
+ fallbackOptionText1,
+ Biometric.Fallback.ICON_TYPE_PASSWORD,
+ ),
+ Biometric.Fallback.CustomOption(
+ fallbackOptionText2,
+ Biometric.Fallback.ICON_TYPE_GENERIC,
+ ),
+ Biometric.Fallback.DeviceCredential,
+ )
+ }
+ else -> emptyArray()
+ }
+ biometricRequest(title, *authFallbacks) {
setSubtitle(subtitle)
setContent(bodyContent)
setMinStrength(
@@ -146,12 +164,10 @@
try {
authRequest?.let {
- if (buttonNumber == 1) {
- authResultLauncher.launch(it)
- } else if (buttonNumber == 2) {
- secondAuthResultLauncher.launch(it)
- } else if (buttonNumber == 3) {
- thirdAuthResultLauncher.launch(it)
+ when (buttonNumber) {
+ 1 -> authResultLauncher.launch(it)
+ 2 -> secondAuthResultLauncher.launch(it)
+ 3 -> thirdAuthResultLauncher.launch(it)
}
}
} catch (e: Exception) {
@@ -255,29 +271,41 @@
override fun onAuthResult(result: AuthenticationResult) {
when (result) {
is AuthenticationResult.Success -> {
- onAuthenticationSucceeded(result)
- log("button$id")
+ onAuthenticationSucceeded(id, result)
}
is AuthenticationResult.Error -> {
- onAuthenticationError(result)
- log("button$id")
+ onAuthenticationError(id, result)
+ }
+
+ is AuthenticationResult.CustomFallbackSelected -> {
+ onFallbackOptionSelected(id, result.fallback)
}
}
}
override fun onAuthAttemptFailed() {
- onAuthenticationFailed()
- log("button$id")
+ onAuthenticationFailed(id)
}
}
)
- private fun onAuthenticationError(result: AuthenticationResult.Error) {
- log("onAuthenticationError " + result.errorCode + " " + result.errString)
+ private fun onAuthenticationError(id: Int, result: AuthenticationResult.Error) {
+ log("button$id - authentication error: " + result.errorCode + " " + result.errString)
}
- private fun onAuthenticationSucceeded(result: AuthenticationResult.Success) {
- log("onAuthenticationSucceeded with type " + result.authType)
+ private fun onFallbackOptionSelected(
+ id: Int,
+ fallback: AuthenticationRequest.Biometric.Fallback.CustomOption,
+ ) {
+ if (fallback.text == fallbackOptionText1) {
+ log("button$id - authentication fallback option: $fallbackOptionText1 resetting...")
+ } else if (fallback.text == fallbackOptionText2) {
+ log("button$id - authentication fallback option: $fallbackOptionText2 account...")
+ }
+ }
+
+ private fun onAuthenticationSucceeded(id: Int, result: AuthenticationResult.Success) {
+ log("button$id - authentication success with type " + result.authType)
// Encrypt a test payload using the result of crypto-based auth.
if (binding.common.useCryptoAuthCheckbox.isChecked) {
val encryptedPayload =
@@ -286,8 +314,8 @@
}
}
- private fun onAuthenticationFailed() {
- log("onAuthenticationFailed, try again")
+ private fun onAuthenticationFailed(id: Int) {
+ log("button$id - onAuthenticationFailed, try again")
}
/** Returns a new crypto object for authentication or `null`, based on the selected options. */
diff --git a/biometric/integration-tests/testapp/src/main/res/layout/authentication_result_test_activity.xml b/biometric/integration-tests/testapp/src/main/res/layout/authentication_result_test_activity.xml
index a41a73c..29414f2 100644
--- a/biometric/integration-tests/testapp/src/main/res/layout/authentication_result_test_activity.xml
+++ b/biometric/integration-tests/testapp/src/main/res/layout/authentication_result_test_activity.xml
@@ -64,7 +64,13 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
- android:checkedButton="@+id/negative_button_fallback">
+ android:checkedButton="@+id/multiple_fallback_options">
+ <RadioButton
+ style="@style/LabelText"
+ android:id="@+id/no_fallback_options"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="@string/no_fallback_options"/>
<RadioButton
style="@style/LabelText"
android:id="@+id/negative_button_fallback"
@@ -77,6 +83,12 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/device_credential_fallback"/>
+ <RadioButton
+ style="@style/LabelText"
+ android:id="@+id/multiple_fallback_options"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="@string/multiple_fallback_options"/>
</RadioGroup>
<include android:id="@+id/common" layout="@layout/common_section" />
</LinearLayout>
diff --git a/biometric/integration-tests/testapp/src/main/res/values/donottranslate-strings.xml b/biometric/integration-tests/testapp/src/main/res/values/donottranslate-strings.xml
index e3b2db1..f6cce54 100644
--- a/biometric/integration-tests/testapp/src/main/res/values/donottranslate-strings.xml
+++ b/biometric/integration-tests/testapp/src/main/res/values/donottranslate-strings.xml
@@ -38,8 +38,10 @@
<string name="credential_label">Credential only</string>
<string name="biometric_fallback_type_label">Biometric Fallback type</string>
+ <string name="no_fallback_options">No fallback option</string>
<string name="device_credential_fallback">Device credential fallback</string>
<string name="negative_button_fallback">Negative button fallback</string>
+ <string name="multiple_fallback_options">Multiple fallback options</string>
<string name="body_content_type_label">Body content type</string>
<string name="plain_text">Plain text</string>
diff --git a/buildSrc/private/src/main/kotlin/androidx/build/metalava/MetalavaRunner.kt b/buildSrc/private/src/main/kotlin/androidx/build/metalava/MetalavaRunner.kt
index 5b6f5a6..ff01cc2 100644
--- a/buildSrc/private/src/main/kotlin/androidx/build/metalava/MetalavaRunner.kt
+++ b/buildSrc/private/src/main/kotlin/androidx/build/metalava/MetalavaRunner.kt
@@ -353,7 +353,7 @@
args += listOf("--compiled-sources", compiledSources.absolutePath)
}
- args += listOf("--format=v4", "--warnings-as-errors")
+ args += listOf("--format=4.0", "--warnings-as-errors")
pathToManifest?.let { args += listOf("--manifest", pathToManifest) }
diff --git a/buildSrc/private/src/main/kotlin/androidx/build/metalava/UpdateBaselineTasks.kt b/buildSrc/private/src/main/kotlin/androidx/build/metalava/UpdateBaselineTasks.kt
index 6f43d53..b535c39 100644
--- a/buildSrc/private/src/main/kotlin/androidx/build/metalava/UpdateBaselineTasks.kt
+++ b/buildSrc/private/src/main/kotlin/androidx/build/metalava/UpdateBaselineTasks.kt
@@ -120,6 +120,6 @@
baselineFile.toString(),
"--pass-baseline-updates",
"--delete-empty-baselines",
- "--format=v4",
+ "--format=4.0",
)
}
diff --git a/car/app/app/build.gradle b/car/app/app/build.gradle
index a3d4c58..5ffe0a5 100644
--- a/car/app/app/build.gradle
+++ b/car/app/app/build.gradle
@@ -136,7 +136,7 @@
(bootClasspath.files + dependencyClasspath.files).join(File.pathSeparator),
'--source-path',
sourceDirs.filter { it.exists() }.join(File.pathSeparator),
- '--format=v4',
+ '--format=4.0',
'--quiet'
]
standardArgs.addAll(additionalArgs)
diff --git a/compose/foundation/foundation/src/androidHostTest/kotlin/androidx/compose/foundation/text/input/internal/DeleteSurroundingTextInCodePointsCommandTest.kt b/compose/foundation/foundation/src/androidHostTest/kotlin/androidx/compose/foundation/text/input/internal/DeleteSurroundingTextInCodePointsCommandTest.kt
index 9cf1a7a..f9ce5c4 100644
--- a/compose/foundation/foundation/src/androidHostTest/kotlin/androidx/compose/foundation/text/input/internal/DeleteSurroundingTextInCodePointsCommandTest.kt
+++ b/compose/foundation/foundation/src/androidHostTest/kotlin/androidx/compose/foundation/text/input/internal/DeleteSurroundingTextInCodePointsCommandTest.kt
@@ -16,6 +16,7 @@
package androidx.compose.foundation.text.input.internal
+import androidx.compose.foundation.text.input.insert
import androidx.compose.ui.text.TextRange
import com.google.common.truth.Truth.assertThat
import kotlin.test.assertFailsWith
@@ -363,4 +364,30 @@
assertThat(state.selection.start).isEqualTo(0)
assertThat(state.selection.end).isEqualTo(0)
}
+
+ @Test
+ fun delete_before_cursor_with_additive_output_transformation() {
+ val text = "abcde"
+ val selection = TextRange(1)
+ initialize(text, selection) { insert(0, "f") }
+
+ imeScope.deleteSurroundingTextInCodePoints(lengthBeforeCursor = 2, lengthAfterCursor = 0)
+
+ assertThat(state.text.toString()).isEqualTo("bcde")
+ assertThat(state.selection.start).isEqualTo(0)
+ assertThat(state.selection.end).isEqualTo(0)
+ }
+
+ @Test
+ fun delete_after_cursor_with_additive_output_transformation() {
+ val text = "abcde"
+ val selection = TextRange(4)
+ initialize(text, selection) { insert(length, "f") }
+
+ imeScope.deleteSurroundingTextInCodePoints(lengthBeforeCursor = 0, lengthAfterCursor = 2)
+
+ assertThat(state.text.toString()).isEqualTo("abcd")
+ assertThat(state.selection.start).isEqualTo(4)
+ assertThat(state.selection.end).isEqualTo(4)
+ }
}
diff --git a/compose/foundation/foundation/src/androidHostTest/kotlin/androidx/compose/foundation/text/input/internal/SetComposingRegionCommandTest.kt b/compose/foundation/foundation/src/androidHostTest/kotlin/androidx/compose/foundation/text/input/internal/SetComposingRegionCommandTest.kt
index 5521746..577749a 100644
--- a/compose/foundation/foundation/src/androidHostTest/kotlin/androidx/compose/foundation/text/input/internal/SetComposingRegionCommandTest.kt
+++ b/compose/foundation/foundation/src/androidHostTest/kotlin/androidx/compose/foundation/text/input/internal/SetComposingRegionCommandTest.kt
@@ -16,6 +16,8 @@
package androidx.compose.foundation.text.input.internal
+import androidx.compose.foundation.text.input.delete
+import androidx.compose.foundation.text.input.insert
import androidx.compose.ui.text.TextRange
import com.google.common.truth.Truth.assertThat
import org.junit.Test
@@ -95,6 +97,18 @@
}
@Test
+ fun test_set_too_small_with_output_transformation() {
+ initialize("ABCDE", TextRange.Zero, outputTransformation = { insert(0, "F") })
+
+ imeScope.setComposingRegion(-1000, -1000)
+
+ assertThat(state.text.toString()).isEqualTo("ABCDE")
+ assertThat(state.selection.start).isEqualTo(0)
+ assertThat(state.selection.end).isEqualTo(0)
+ assertThat(state.composition).isNull()
+ }
+
+ @Test
fun test_set_too_large() {
initialize("ABCDE", TextRange.Zero)
@@ -107,6 +121,18 @@
}
@Test
+ fun test_set_too_large_with_output_transformation() {
+ initialize("ABCDE", TextRange.Zero, outputTransformation = { insert(0, "F") })
+
+ imeScope.setComposingRegion(1000, 1000)
+
+ assertThat(state.text.toString()).isEqualTo("ABCDE")
+ assertThat(state.selection.start).isEqualTo(0)
+ assertThat(state.selection.end).isEqualTo(0)
+ assertThat(state.composition).isNull()
+ }
+
+ @Test
fun test_set_too_small_and_too_large() {
initialize("ABCDE", TextRange.Zero)
@@ -121,6 +147,34 @@
}
@Test
+ fun test_set_too_small_and_too_large_with_output_transformation() {
+ initialize("ABCDE", TextRange.Zero, outputTransformation = { insert(0, "F") })
+
+ imeScope.setComposingRegion(-1000, 1000)
+
+ assertThat(state.text.toString()).isEqualTo("ABCDE")
+ assertThat(state.selection.start).isEqualTo(0)
+ assertThat(state.selection.end).isEqualTo(0)
+ assertThat(state.composition).isNotNull()
+ assertThat(state.composition?.start).isEqualTo(0)
+ assertThat(state.composition?.end).isEqualTo(5)
+ }
+
+ @Test
+ fun test_set_too_small_and_too_large_with_reductive_output_transformation() {
+ initialize("ABCDE", TextRange.Zero, outputTransformation = { delete(0, 3) })
+
+ imeScope.setComposingRegion(-1000, 1000)
+
+ assertThat(state.text.toString()).isEqualTo("ABCDE")
+ assertThat(state.selection.start).isEqualTo(0)
+ assertThat(state.selection.end).isEqualTo(0)
+ assertThat(state.composition).isNotNull()
+ assertThat(state.composition?.start).isEqualTo(0)
+ assertThat(state.composition?.end).isEqualTo(5)
+ }
+
+ @Test
fun test_set_too_small_and_too_large_reversed() {
initialize("ABCDE", TextRange.Zero)
@@ -133,4 +187,46 @@
assertThat(state.composition?.start).isEqualTo(0)
assertThat(state.composition?.end).isEqualTo(5)
}
+
+ @Test
+ fun test_set_too_small_and_too_large_reversed_with_output_transformation() {
+ initialize("ABCDE", TextRange.Zero, outputTransformation = { insert(0, "F") })
+
+ imeScope.setComposingRegion(1000, -1000)
+
+ assertThat(state.text.toString()).isEqualTo("ABCDE")
+ assertThat(state.selection.start).isEqualTo(0)
+ assertThat(state.selection.end).isEqualTo(0)
+ assertThat(state.composition).isNotNull()
+ assertThat(state.composition?.start).isEqualTo(0)
+ assertThat(state.composition?.end).isEqualTo(5)
+ }
+
+ @Test
+ fun test_output_transformation_additive_untransform_given_range() {
+ initialize("A", TextRange.Zero, outputTransformation = { insert(0, "F") })
+
+ imeScope.setComposingRegion(1, 2)
+
+ assertThat(state.text.toString()).isEqualTo("A")
+ assertThat(state.selection.start).isEqualTo(0)
+ assertThat(state.selection.end).isEqualTo(0)
+ assertThat(state.composition).isNotNull()
+ assertThat(state.composition?.start).isEqualTo(0)
+ assertThat(state.composition?.end).isEqualTo(1)
+ }
+
+ @Test
+ fun test_output_transformation_reductive_untransform_given_range() {
+ initialize("AB", TextRange.Zero, outputTransformation = { delete(0, 1) })
+
+ imeScope.setComposingRegion(0, 1)
+
+ assertThat(state.text.toString()).isEqualTo("AB")
+ assertThat(state.selection.start).isEqualTo(0)
+ assertThat(state.selection.end).isEqualTo(0)
+ assertThat(state.composition).isNotNull()
+ assertThat(state.composition?.start).isEqualTo(0)
+ assertThat(state.composition?.end).isEqualTo(2)
+ }
}
diff --git a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/ImeEditCommand.android.kt b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/ImeEditCommand.android.kt
index 7850618..a189d5e 100644
--- a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/ImeEditCommand.android.kt
+++ b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/ImeEditCommand.android.kt
@@ -25,6 +25,7 @@
import androidx.compose.foundation.text.input.delete
import androidx.compose.runtime.collection.mutableVectorOf
import androidx.compose.ui.text.TextRange
+import androidx.compose.ui.util.fastCoerceAtLeast
import androidx.compose.ui.util.fastCoerceIn
/**
@@ -188,9 +189,15 @@
commitComposition()
}
+ val clampedTransformedStart = start.fastCoerceAtLeast(0)
+ val clampedTransformedEnd = end.fastCoerceAtLeast(0)
+
+ // First, untransform the given range because IME works in the transformed space.
+ val range = mapFromTransformed(TextRange(clampedTransformedStart, clampedTransformedEnd))
+
// Sanitize the input: reverse if reversed, clamped into valid range, ignore empty range.
- val clampedStart = start.coerceIn(0, length)
- val clampedEnd = end.coerceIn(0, length)
+ val clampedStart = range.min.coerceIn(0, length)
+ val clampedEnd = range.max.coerceIn(0, length)
if (clampedStart == clampedEnd) {
// do nothing. empty composition range is not allowed.
} else if (clampedStart < clampedEnd) {
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/ComposeFoundationFlags.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/ComposeFoundationFlags.kt
index 87b16b6..77efbc3 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/ComposeFoundationFlags.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/ComposeFoundationFlags.kt
@@ -176,5 +176,5 @@
// TODO: Remove this flag once it has soaked (b/487251541)
@field:Suppress("MutableBareField")
@JvmField
- var isBasicTextFieldMinSizeOptimizationEnabled = true
+ var isBasicTextFieldMinSizeOptimizationEnabled = false
}
diff --git a/compose/integration-tests/hero/pokedex/pokedex-macrobenchmark-target/build.gradle b/compose/integration-tests/hero/pokedex/pokedex-macrobenchmark-target/build.gradle
index d1ac928..8cb17cd 100644
--- a/compose/integration-tests/hero/pokedex/pokedex-macrobenchmark-target/build.gradle
+++ b/compose/integration-tests/hero/pokedex/pokedex-macrobenchmark-target/build.gradle
@@ -46,7 +46,7 @@
dependencies {
implementation(libs.androidx.activity.compose)
implementation("androidx.appcompat:appcompat:1.4.1")
- implementation(project(":compose-hero-benchmarks:poxedex-compose:app"))
+ implementation(project(":compose-hero-benchmarks:pokedex-compose:app"))
implementation(project(":compose-hero-benchmarks:pokedex-views:app"))
implementation(project(":compose:ui:ui-util"))
implementation("com.github.bumptech.glide:glide:4.16.0")
diff --git a/compose/integration-tests/hero/pokedex/pokedex-macrobenchmark-target/src/main/java/androidx/compose/integration/hero/pokedex/macrobenchmark/target/PokedexActivity.kt b/compose/integration-tests/hero/pokedex/pokedex-macrobenchmark-target/src/main/java/androidx/compose/integration/hero/pokedex/macrobenchmark/target/PokedexActivity.kt
index 6088f51..11b8575 100644
--- a/compose/integration-tests/hero/pokedex/pokedex-macrobenchmark-target/src/main/java/androidx/compose/integration/hero/pokedex/macrobenchmark/target/PokedexActivity.kt
+++ b/compose/integration-tests/hero/pokedex/pokedex-macrobenchmark-target/src/main/java/androidx/compose/integration/hero/pokedex/macrobenchmark/target/PokedexActivity.kt
@@ -31,7 +31,7 @@
import okhttp3.HttpUrl.Companion.toHttpUrl
/**
- * Entry point for benchmarks against poxedex-compose.
+ * Entry point for benchmarks against pokedex-compose.
*
* See the manifest entry for the activity's registered name to use when launching benchmarks.
*/
diff --git a/compose/material3/benchmark/src/androidTest/java/androidx/compose/material3/benchmark/SearchBarBenchmark.kt b/compose/material3/benchmark/src/androidTest/java/androidx/compose/material3/benchmark/SearchBarBenchmark.kt
index a58abbc..f653503 100644
--- a/compose/material3/benchmark/src/androidTest/java/androidx/compose/material3/benchmark/SearchBarBenchmark.kt
+++ b/compose/material3/benchmark/src/androidTest/java/androidx/compose/material3/benchmark/SearchBarBenchmark.kt
@@ -29,7 +29,6 @@
import androidx.compose.testutils.LayeredComposeTestCase
import androidx.compose.testutils.ToggleableTestCase
import androidx.compose.testutils.benchmark.ComposeBenchmarkRule
-import androidx.compose.testutils.benchmark.benchmarkToFirstPixel
import androidx.compose.testutils.benchmark.toggleStateBenchmarkComposeMeasureLayout
import androidx.test.filters.LargeTest
import org.junit.Rule
@@ -50,7 +49,7 @@
@Test
fun firstPixel() {
- benchmarkRule.benchmarkToFirstPixel(testCaseFactory)
+ benchmarkRule.benchmarkFirstRenderUntilStable(testCaseFactory)
}
@Test
diff --git a/compose/material3/material3/src/androidDeviceTest/kotlin/androidx/compose/material3/OutlinedTextFieldScreenshotTest.kt b/compose/material3/material3/src/androidDeviceTest/kotlin/androidx/compose/material3/OutlinedTextFieldScreenshotTest.kt
index 468b5e1..50670dd 100644
--- a/compose/material3/material3/src/androidDeviceTest/kotlin/androidx/compose/material3/OutlinedTextFieldScreenshotTest.kt
+++ b/compose/material3/material3/src/androidDeviceTest/kotlin/androidx/compose/material3/OutlinedTextFieldScreenshotTest.kt
@@ -54,6 +54,7 @@
import androidx.test.filters.SdkSuppress
import androidx.test.screenshot.AndroidXScreenshotTestRule
import kotlinx.coroutines.test.StandardTestDispatcher
+import org.junit.Ignore
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@@ -496,6 +497,7 @@
}
@Test
+ @Ignore("b/486915458")
fun outlinedTextField_customShape() {
rule.setMaterialContent(lightColorScheme()) {
OutlinedTextField(
diff --git a/compose/material3/material3/src/androidDeviceTest/kotlin/androidx/compose/material3/OutlinedTextFieldTest.kt b/compose/material3/material3/src/androidDeviceTest/kotlin/androidx/compose/material3/OutlinedTextFieldTest.kt
index b442e7a..9c452a5 100644
--- a/compose/material3/material3/src/androidDeviceTest/kotlin/androidx/compose/material3/OutlinedTextFieldTest.kt
+++ b/compose/material3/material3/src/androidDeviceTest/kotlin/androidx/compose/material3/OutlinedTextFieldTest.kt
@@ -111,6 +111,7 @@
import kotlin.math.roundToInt
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.test.StandardTestDispatcher
+import org.junit.Ignore
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@@ -1582,6 +1583,7 @@
@Test
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
+ @Ignore("Enabled after b/484131458")
fun testOutlinedTextField_appliesContainerColor() {
rule.setMaterialContent(lightColorScheme()) {
OutlinedTextField(
diff --git a/compose/material3/material3/src/androidDeviceTest/kotlin/androidx/compose/material3/TextFieldTest.kt b/compose/material3/material3/src/androidDeviceTest/kotlin/androidx/compose/material3/TextFieldTest.kt
index 54b9d0c..550cc4d 100644
--- a/compose/material3/material3/src/androidDeviceTest/kotlin/androidx/compose/material3/TextFieldTest.kt
+++ b/compose/material3/material3/src/androidDeviceTest/kotlin/androidx/compose/material3/TextFieldTest.kt
@@ -126,6 +126,7 @@
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
+import org.junit.Ignore
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@@ -1625,6 +1626,7 @@
@Test
@LargeTest
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
+ @Ignore("Enabled after b/484131458")
fun testTextField_transformedTextIsUsed_toDefineLabelPosition() {
rule.setMaterialContent(lightColorScheme()) {
TextField(
diff --git a/compose/material3/material3/src/androidDeviceTest/kotlin/androidx/compose/material3/TimePickerTest.kt b/compose/material3/material3/src/androidDeviceTest/kotlin/androidx/compose/material3/TimePickerTest.kt
index abdb151..974a5ba 100644
--- a/compose/material3/material3/src/androidDeviceTest/kotlin/androidx/compose/material3/TimePickerTest.kt
+++ b/compose/material3/material3/src/androidDeviceTest/kotlin/androidx/compose/material3/TimePickerTest.kt
@@ -445,7 +445,7 @@
}
// only the first 4 is accepted
- assertThat(state.hour).isEqualTo(4)
+ rule.runOnIdle { assertThat(state.hour).isEqualTo(4) }
}
@Test
@@ -489,7 +489,7 @@
rule.onNodeWithText("11").performKeyInput { pressKey(Key.Four) }
- assertThat(state.isPm).isTrue()
+ rule.runOnIdle { assertThat(state.isPm).isTrue() }
}
@Test
@@ -503,8 +503,10 @@
pressKey(Key.Two)
}
- assertThat(state.isPm).isFalse()
- assertThat(state.hour).isEqualTo(0)
+ rule.runOnIdle {
+ assertThat(state.isPm).isFalse()
+ assertThat(state.hour).isEqualTo(0)
+ }
}
@Test
@@ -518,8 +520,10 @@
pressKey(Key.Two)
}
- assertThat(state.isPm).isTrue()
- assertThat(state.hour).isEqualTo(12)
+ rule.runOnIdle {
+ assertThat(state.isPm).isTrue()
+ assertThat(state.hour).isEqualTo(12)
+ }
}
@Test
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Chip.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Chip.kt
index a272134..a1d19c9 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Chip.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Chip.kt
@@ -40,6 +40,7 @@
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.SuggestionChipDefaults.defaultElevatedSuggestionChipColors
import androidx.compose.material3.internal.animateElevation
import androidx.compose.material3.tokens.AssistChipTokens
@@ -2888,7 +2889,7 @@
Row(
modifier =
- Modifier.width(IntrinsicSize.Max)
+ Modifier.widthIn(max = maxChipWidth)
.defaultMinSize(minHeight = minHeight)
.padding(paddingValues),
verticalAlignment = Alignment.CenterVertically,
@@ -2922,7 +2923,7 @@
}
Row(
- modifier = Modifier.weight(1f),
+ modifier = Modifier.weight(1f, fill = false),
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically,
content = { label() },
@@ -3653,3 +3654,9 @@
private val HorizontalElementsPadding = 8.dp
private val DefaultHorizontalArrangement = ChipArrangement(SuggestionChipDefaults.HorizontalSpacing)
+
+/**
+ * Max width for a chip. This is required to allow animations with Row and have expected behavior in
+ * the case where the chip is within a scrolling container.
+ */
+private val maxChipWidth = 1000.dp
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/TextFieldDefaults.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/TextFieldDefaults.kt
index 171f72ee..83701b0 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/TextFieldDefaults.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/TextFieldDefaults.kt
@@ -17,30 +17,30 @@
package androidx.compose.material3
import androidx.annotation.FloatRange
-import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.border
import androidx.compose.foundation.interaction.Interaction
import androidx.compose.foundation.interaction.InteractionSource
import androidx.compose.foundation.interaction.MutableInteractionSource
-import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.style.ExperimentalFoundationStyleApi
+import androidx.compose.foundation.style.MutableStyleState
+import androidx.compose.foundation.style.focused
+import androidx.compose.foundation.style.styleable
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.input.OutputTransformation
-import androidx.compose.foundation.text.input.TextFieldBuffer
import androidx.compose.foundation.text.input.TextFieldDecorator
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.TextFieldLineLimits.MultiLine
import androidx.compose.foundation.text.input.TextFieldLineLimits.SingleLine
import androidx.compose.foundation.text.input.TextFieldState
+import androidx.compose.foundation.text.input.toTextFieldBuffer
import androidx.compose.foundation.text.selection.LocalTextSelectionColors
import androidx.compose.foundation.text.selection.TextSelectionColors
import androidx.compose.material3.internal.CommonDecorationBox
import androidx.compose.material3.internal.SupportingTopPadding
import androidx.compose.material3.internal.TextFieldPadding
import androidx.compose.material3.internal.TextFieldType
-import androidx.compose.material3.internal.animateBorderStrokeAsState
-import androidx.compose.material3.internal.textFieldBackground
import androidx.compose.material3.tokens.FilledTextFieldTokens
import androidx.compose.material3.tokens.MotionSchemeKeyTokens
import androidx.compose.material3.tokens.OutlinedTextFieldTokens
@@ -178,13 +178,10 @@
},
): TextFieldDecorator = TextFieldDecorator { innerTextField ->
val visualText =
- if (outputTransformation == null) state.text
- else {
- // TODO: use constructor to create TextFieldBuffer from TextFieldState when
- // available
- lateinit var buffer: TextFieldBuffer
- state.edit { buffer = this }
- // after edit completes, mutations on buffer are ineffective
+ if (outputTransformation == null) {
+ state.text
+ } else {
+ val buffer = state.toTextFieldBuffer()
with(outputTransformation) { buffer.transformOutput() }
buffer.asCharSequence()
}
@@ -229,6 +226,7 @@
* @param unfocusedIndicatorLineThickness thickness of the indicator line when the text field is
* not focused
*/
+ @OptIn(ExperimentalFoundationStyleApi::class)
@Composable
fun Container(
enabled: Boolean,
@@ -240,16 +238,20 @@
focusedIndicatorLineThickness: Dp = FocusedIndicatorThickness,
unfocusedIndicatorLineThickness: Dp = UnfocusedIndicatorThickness,
) {
- val focused = interactionSource.collectIsFocusedAsState().value
+ val styleState = remember(interactionSource) { MutableStyleState(interactionSource) }
// TODO Load the motionScheme tokens from the component tokens file
- val containerColor =
- animateColorAsState(
- targetValue = colors.containerColor(enabled, isError, focused),
- animationSpec = MotionSchemeKeyTokens.FastEffects.value(),
- )
+ val animationSpec = MotionSchemeKeyTokens.FastEffects.value<Float>()
Box(
modifier
- .textFieldBackground(containerColor::value, shape)
+ .styleable(styleState) {
+ shape(shape)
+ background(colors.containerColor(enabled, isError, false))
+ focused {
+ animate(animationSpec) {
+ background(colors.containerColor(enabled, isError, true))
+ }
+ }
+ }
.indicatorLine(
enabled = enabled,
isError = isError,
@@ -972,11 +974,7 @@
val visualText =
if (outputTransformation == null) state.text
else {
- // TODO: use constructor to create TextFieldBuffer from TextFieldState when
- // available
- lateinit var buffer: TextFieldBuffer
- state.edit { buffer = this }
- // after edit completes, mutations on buffer are ineffective
+ val buffer = state.toTextFieldBuffer()
with(outputTransformation) { buffer.transformOutput() }
buffer.asCharSequence()
}
@@ -1019,6 +1017,7 @@
* @param focusedBorderThickness thickness of the border when the text field is focused
* @param unfocusedBorderThickness thickness of the border when the text field is not focused
*/
+ @OptIn(ExperimentalFoundationStyleApi::class)
@Composable
fun Container(
enabled: Boolean,
@@ -1030,26 +1029,23 @@
focusedBorderThickness: Dp = FocusedBorderThickness,
unfocusedBorderThickness: Dp = UnfocusedBorderThickness,
) {
- val focused = interactionSource.collectIsFocusedAsState().value
- val borderStroke =
- animateBorderStrokeAsState(
- enabled,
- isError,
- focused,
- colors,
- focusedBorderThickness,
- unfocusedBorderThickness,
- )
- // TODO Load the motionScheme tokens from the component tokens file
- val containerColor =
- animateColorAsState(
- targetValue = colors.containerColor(enabled, isError, focused),
- animationSpec = MotionSchemeKeyTokens.FastEffects.value(),
- )
+ val styleState = remember(interactionSource) { MutableStyleState(interactionSource) }
+ val animationSpec = MotionSchemeKeyTokens.FastEffects.value<Float>()
Box(
- modifier
- .border(borderStroke.value, shape)
- .textFieldBackground(containerColor::value, shape)
+ modifier.styleable(styleState) {
+ shape(shape)
+ background(colors.containerColor(enabled, isError, false))
+ border(unfocusedBorderThickness, colors.indicatorColor(enabled, isError, false))
+ focused {
+ animate(animationSpec) {
+ background(colors.containerColor(enabled, isError, true))
+ border(
+ focusedBorderThickness,
+ colors.indicatorColor(enabled, isError, true),
+ )
+ }
+ }
+ }
)
}
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/internal/TextFieldImpl.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/internal/TextFieldImpl.kt
index f61f729..b5b329e 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/internal/TextFieldImpl.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/internal/TextFieldImpl.kt
@@ -17,11 +17,8 @@
package androidx.compose.material3.internal
import androidx.compose.animation.animateColor
-import androidx.compose.animation.animateColorAsState
-import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.updateTransition
-import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.interaction.InteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Box
@@ -46,7 +43,6 @@
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
-import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.structuralEqualityPolicy
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -151,7 +147,6 @@
// Transparent components interfere with Talkback (b/261061240), so if any components below
// have alpha == 0, we set the component to null instead.
-
val placeholderColor = colors.placeholderColor(enabled, isError, isFocused)
val showPlaceholder by remember {
derivedStateOf(structuralEqualityPolicy()) { placeholderAlpha.value > 0f }
@@ -462,37 +457,6 @@
)
}
-@Composable
-internal fun animateBorderStrokeAsState(
- enabled: Boolean,
- isError: Boolean,
- focused: Boolean,
- colors: TextFieldColors,
- focusedBorderThickness: Dp,
- unfocusedBorderThickness: Dp,
-): State<BorderStroke> {
- // TODO Load the motionScheme tokens from the component tokens file
- val targetColor = colors.indicatorColor(enabled, isError, focused)
- val colorAnimationSpec = MotionSchemeKeyTokens.FastEffects.value<Color>()
- val indicatorColor =
- if (enabled) {
- animateColorAsState(targetColor, colorAnimationSpec)
- } else {
- rememberUpdatedState(targetColor)
- }
-
- val thicknessAnimationSpec = MotionSchemeKeyTokens.FastSpatial.value<Dp>()
- val thickness =
- if (enabled) {
- val targetThickness = if (focused) focusedBorderThickness else unfocusedBorderThickness
- animateDpAsState(targetThickness, thicknessAnimationSpec)
- } else {
- rememberUpdatedState(unfocusedBorderThickness)
- }
-
- return rememberUpdatedState(BorderStroke(thickness.value, indicatorColor.value))
-}
-
/** An internal state used to animate a label and an indicator. */
private enum class InputPhase {
// Text field is focused
diff --git a/compose/ui/ui-tooling-preview/api/current.txt b/compose/ui/ui-tooling-preview/api/current.txt
index 2285c84..65811ad 100644
--- a/compose/ui/ui-tooling-preview/api/current.txt
+++ b/compose/ui/ui-tooling-preview/api/current.txt
@@ -193,6 +193,17 @@
@androidx.compose.ui.tooling.preview.Preview(name="Phone", device=androidx.compose.ui.tooling.preview.Devices.PHONE, showSystemUi=true) @androidx.compose.ui.tooling.preview.Preview(name="Phone - Landscape", device="spec:width=411dp,height=891dp,orientation=landscape,dpi=420", showSystemUi=true) @androidx.compose.ui.tooling.preview.Preview(name="Unfolded Foldable", device=androidx.compose.ui.tooling.preview.Devices.FOLDABLE, showSystemUi=true) @androidx.compose.ui.tooling.preview.Preview(name="Tablet", device="spec:width=1280dp,height=800dp,dpi=240,orientation=portrait", showSystemUi=true) @androidx.compose.ui.tooling.preview.Preview(name="Tablet - Landscape", device=androidx.compose.ui.tooling.preview.Devices.TABLET, showSystemUi=true) @androidx.compose.ui.tooling.preview.Preview(name="Desktop", device=androidx.compose.ui.tooling.preview.Devices.DESKTOP, showSystemUi=true) @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.BINARY) @kotlin.annotation.Target(allowedTargets={kotlin.annotation.AnnotationTarget.ANNOTATION_CLASS, kotlin.annotation.AnnotationTarget.FUNCTION}) public @interface PreviewScreenSizes {
}
+ public interface PreviewWrapper {
+ method @KotlinOnly @androidx.compose.runtime.Composable public void Wrap(kotlin.jvm.functions.Function0<kotlin.Unit> content);
+ method @BytecodeOnly @androidx.compose.runtime.Composable public void Wrap(kotlin.jvm.functions.Function2<? super androidx.compose.runtime.Composer!,? super java.lang.Integer!,kotlin.Unit!>, androidx.compose.runtime.Composer?, int);
+ }
+
+ @kotlin.annotation.MustBeDocumented @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.BINARY) @kotlin.annotation.Target(allowedTargets=kotlin.annotation.AnnotationTarget.FUNCTION) public @interface PreviewWrapperProvider {
+ ctor @KotlinOnly public PreviewWrapperProvider(kotlin.reflect.KClass<? extends androidx.compose.ui.tooling.preview.PreviewWrapper> wrapper);
+ method @InaccessibleFromKotlin public abstract Class<? extends androidx.compose.ui.tooling.preview.PreviewWrapper> wrapper();
+ property public abstract kotlin.reflect.KClass<? extends androidx.compose.ui.tooling.preview.PreviewWrapper> wrapper;
+ }
+
public final class Wallpapers {
property public static int BLUE_DOMINATED_EXAMPLE;
property public static int GREEN_DOMINATED_EXAMPLE;
diff --git a/compose/ui/ui-tooling-preview/api/restricted_current.txt b/compose/ui/ui-tooling-preview/api/restricted_current.txt
index 2285c84..65811ad 100644
--- a/compose/ui/ui-tooling-preview/api/restricted_current.txt
+++ b/compose/ui/ui-tooling-preview/api/restricted_current.txt
@@ -193,6 +193,17 @@
@androidx.compose.ui.tooling.preview.Preview(name="Phone", device=androidx.compose.ui.tooling.preview.Devices.PHONE, showSystemUi=true) @androidx.compose.ui.tooling.preview.Preview(name="Phone - Landscape", device="spec:width=411dp,height=891dp,orientation=landscape,dpi=420", showSystemUi=true) @androidx.compose.ui.tooling.preview.Preview(name="Unfolded Foldable", device=androidx.compose.ui.tooling.preview.Devices.FOLDABLE, showSystemUi=true) @androidx.compose.ui.tooling.preview.Preview(name="Tablet", device="spec:width=1280dp,height=800dp,dpi=240,orientation=portrait", showSystemUi=true) @androidx.compose.ui.tooling.preview.Preview(name="Tablet - Landscape", device=androidx.compose.ui.tooling.preview.Devices.TABLET, showSystemUi=true) @androidx.compose.ui.tooling.preview.Preview(name="Desktop", device=androidx.compose.ui.tooling.preview.Devices.DESKTOP, showSystemUi=true) @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.BINARY) @kotlin.annotation.Target(allowedTargets={kotlin.annotation.AnnotationTarget.ANNOTATION_CLASS, kotlin.annotation.AnnotationTarget.FUNCTION}) public @interface PreviewScreenSizes {
}
+ public interface PreviewWrapper {
+ method @KotlinOnly @androidx.compose.runtime.Composable public void Wrap(kotlin.jvm.functions.Function0<kotlin.Unit> content);
+ method @BytecodeOnly @androidx.compose.runtime.Composable public void Wrap(kotlin.jvm.functions.Function2<? super androidx.compose.runtime.Composer!,? super java.lang.Integer!,kotlin.Unit!>, androidx.compose.runtime.Composer?, int);
+ }
+
+ @kotlin.annotation.MustBeDocumented @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.BINARY) @kotlin.annotation.Target(allowedTargets=kotlin.annotation.AnnotationTarget.FUNCTION) public @interface PreviewWrapperProvider {
+ ctor @KotlinOnly public PreviewWrapperProvider(kotlin.reflect.KClass<? extends androidx.compose.ui.tooling.preview.PreviewWrapper> wrapper);
+ method @InaccessibleFromKotlin public abstract Class<? extends androidx.compose.ui.tooling.preview.PreviewWrapper> wrapper();
+ property public abstract kotlin.reflect.KClass<? extends androidx.compose.ui.tooling.preview.PreviewWrapper> wrapper;
+ }
+
public final class Wallpapers {
property public static int BLUE_DOMINATED_EXAMPLE;
property public static int GREEN_DOMINATED_EXAMPLE;
diff --git a/compose/ui/ui-tooling-preview/bcv/native/current.txt b/compose/ui/ui-tooling-preview/bcv/native/current.txt
index e2831a4..5622ddd 100644
--- a/compose/ui/ui-tooling-preview/bcv/native/current.txt
+++ b/compose/ui/ui-tooling-preview/bcv/native/current.txt
@@ -66,6 +66,13 @@
constructor <init>() // androidx.compose.ui.tooling.preview/PreviewScreenSizes.<init>|<init>(){}[0]
}
+open annotation class androidx.compose.ui.tooling.preview/PreviewWrapperProvider : kotlin/Annotation { // androidx.compose.ui.tooling.preview/PreviewWrapperProvider|null[0]
+ constructor <init>(kotlin.reflect/KClass<out androidx.compose.ui.tooling.preview/PreviewWrapper>) // androidx.compose.ui.tooling.preview/PreviewWrapperProvider.<init>|<init>(kotlin.reflect.KClass<out|androidx.compose.ui.tooling.preview.PreviewWrapper>){}[0]
+
+ final val wrapper // androidx.compose.ui.tooling.preview/PreviewWrapperProvider.wrapper|{}wrapper[0]
+ final fun <get-wrapper>(): kotlin.reflect/KClass<out androidx.compose.ui.tooling.preview/PreviewWrapper> // androidx.compose.ui.tooling.preview/PreviewWrapperProvider.wrapper.<get-wrapper>|<get-wrapper>(){}[0]
+}
+
abstract interface <#A: kotlin/Any?> androidx.compose.ui.tooling.preview/PreviewParameterProvider { // androidx.compose.ui.tooling.preview/PreviewParameterProvider|null[0]
abstract val values // androidx.compose.ui.tooling.preview/PreviewParameterProvider.values|{}values[0]
abstract fun <get-values>(): kotlin.sequences/Sequence<#A> // androidx.compose.ui.tooling.preview/PreviewParameterProvider.values.<get-values>|<get-values>(){}[0]
@@ -75,6 +82,10 @@
open fun getDisplayName(kotlin/Int): kotlin/String? // androidx.compose.ui.tooling.preview/PreviewParameterProvider.getDisplayName|getDisplayName(kotlin.Int){}[0]
}
+abstract interface androidx.compose.ui.tooling.preview/PreviewWrapper { // androidx.compose.ui.tooling.preview/PreviewWrapper|null[0]
+ abstract fun Wrap(kotlin/Function2<androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>, androidx.compose.runtime/Composer?, kotlin/Int) // androidx.compose.ui.tooling.preview/PreviewWrapper.Wrap|Wrap(kotlin.Function2<androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>;androidx.compose.runtime.Composer?;kotlin.Int){}[0]
+}
+
open class <#A: kotlin/Any?> androidx.compose.ui.tooling.preview.datasource/CollectionPreviewParameterProvider : androidx.compose.ui.tooling.preview/PreviewParameterProvider<#A> { // androidx.compose.ui.tooling.preview.datasource/CollectionPreviewParameterProvider|null[0]
constructor <init>(kotlin.collections/Collection<#A>) // androidx.compose.ui.tooling.preview.datasource/CollectionPreviewParameterProvider.<init>|<init>(kotlin.collections.Collection<1:0>){}[0]
diff --git a/compose/ui/ui-tooling-preview/src/commonMain/kotlin/androidx/compose/ui/tooling/preview/PreviewWrapper.kt b/compose/ui/ui-tooling-preview/src/commonMain/kotlin/androidx/compose/ui/tooling/preview/PreviewWrapper.kt
new file mode 100644
index 0000000..f19f53a
--- /dev/null
+++ b/compose/ui/ui-tooling-preview/src/commonMain/kotlin/androidx/compose/ui/tooling/preview/PreviewWrapper.kt
@@ -0,0 +1,80 @@
+/*
+ * Copyright 2026 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.ui.tooling.preview
+
+import androidx.compose.runtime.Composable
+import kotlin.reflect.KClass
+
+/**
+ * Interface used to define custom rendering logic for Compose Previews in Android Studio.
+ *
+ * Implementations of this interface allow developers to wrap the content of a [Preview] to provide
+ * specific environments, themes, or containers (such as a Remote Compose) without requiring
+ * repetitive code in every preview function.
+ *
+ * **Usage:** Implementations are applied to previews using the [PreviewWrapperProvider] annotation.
+ *
+ * @see PreviewWrapperProvider
+ */
+interface PreviewWrapper {
+
+ /**
+ * Wraps the provided [content] with custom UI logic or containers.
+ *
+ * Example usage for applying a Theme:
+ * ```
+ * @Composable
+ * override fun Wrap(content: @Composable () -> Unit) {
+ * MyTheme {
+ * content()
+ * }
+ * }
+ * ```
+ *
+ * @param content The original composable content of the function annotated with [Preview].
+ */
+ @Composable fun Wrap(content: @Composable () -> Unit)
+}
+
+/**
+ * Annotation used to associate a [PreviewWrapper] with a Composable.
+ *
+ * When a preview is rendered, Android Studio looks for this annotation to determine if the preview
+ * content should be wrapped in a custom container (e.g., for Remote Compose or custom theming).
+ *
+ * **Scope and Precedence:** This annotation is not repeatable. Each preview rendered uses at most
+ * one wrapper.
+ *
+ * [AnnotationTarget.FUNCTION]: The wrapper is applied to previews directly annotating this
+ * function, including MultiPreviews.
+ *
+ * **Example**
+ *
+ * ```kotlin
+ * @PreviewWrapperProvider(wrapper = CustomThemeWrapper::class)
+ * @Preview
+ * @Composable
+ * fun MyThemedComponent() { ... }
+ * ```
+ *
+ * @param wrapper The [KClass] of the [PreviewWrapper] implementation to use. Must have a default
+ * zero-argument constructor.
+ */
+@MustBeDocumented
+@Retention(AnnotationRetention.BINARY)
+@Target(AnnotationTarget.FUNCTION)
+annotation class PreviewWrapperProvider(val wrapper: KClass<out PreviewWrapper>)
diff --git a/compose/ui/ui-tooling/src/androidDeviceTest/kotlin/androidx/compose/ui/tooling/ComposeViewAdapterTest.kt b/compose/ui/ui-tooling/src/androidDeviceTest/kotlin/androidx/compose/ui/tooling/ComposeViewAdapterTest.kt
index dc2025b..ffd56b1 100644
--- a/compose/ui/ui-tooling/src/androidDeviceTest/kotlin/androidx/compose/ui/tooling/ComposeViewAdapterTest.kt
+++ b/compose/ui/ui-tooling/src/androidDeviceTest/kotlin/androidx/compose/ui/tooling/ComposeViewAdapterTest.kt
@@ -23,6 +23,7 @@
import androidx.compose.ui.tooling.animation.PreviewAnimationClock
import androidx.compose.ui.tooling.animation.UnsupportedComposeAnimation
import androidx.compose.ui.tooling.data.UiToolingDataApi
+import androidx.compose.ui.tooling.preview.PreviewWrapper
import androidx.compose.ui.tooling.test.R
import androidx.test.filters.LargeTest
import androidx.test.filters.MediumTest
@@ -59,8 +60,12 @@
}
/** Asserts that the given Composable method executes correct and outputs some [ViewInfo]s. */
- private fun assertRendersCorrectly(className: String, methodName: String): List<ViewInfo> {
- initAndWaitForDraw(className, methodName)
+ private fun assertRendersCorrectly(
+ className: String,
+ methodName: String,
+ previewWrapper: Class<out PreviewWrapper>? = null,
+ ): List<ViewInfo> {
+ initAndWaitForDraw(className, methodName, previewWrapper = previewWrapper)
activityTestRule.runOnUiThread { assertTrue(composeViewAdapter.viewInfos.isNotEmpty()) }
return composeViewAdapter.viewInfos
@@ -73,6 +78,7 @@
className: String,
methodName: String,
designInfoProvidersArgument: String? = null,
+ previewWrapper: Class<out PreviewWrapper>? = null,
) {
val committedAndDrawn = CountDownLatch(1)
val committed = AtomicBoolean(false)
@@ -83,6 +89,7 @@
debugViewInfos = true,
lookForDesignInfoProviders = true,
designInfoProvidersArgument = designInfoProvidersArgument,
+ previewWrapper = previewWrapper,
onCommit = { committed.set(true) },
onDraw = {
if (committed.get()) {
@@ -586,6 +593,33 @@
}
@Test
+ fun testPreviewWrapper() {
+ val viewInfos =
+ assertRendersCorrectly(
+ "androidx.compose.ui.tooling.SimpleComposablePreviewKt",
+ "TestWrapperPreview",
+ previewWrapper = TestWrapper::class.java,
+ )
+
+ activityTestRule.runOnUiThread {
+ assertTrue(viewInfos.isNotEmpty())
+ // Verify that the wrapper (WrapperContainer) is present.
+ val wrapperInfo =
+ viewInfos
+ .flatMap { it.allChildren() + it }
+ .find {
+ it.name == "WrapperContainer" && it.fileName == "SimpleComposablePreview.kt"
+ }
+ assertTrue("WrapperContainer from wrapper should be present", wrapperInfo != null)
+
+ // Verify it has children (Header, Content, Footer)
+ // Content is the SimpleComposablePreview which has a Surface
+ // Header and Footer are Text
+ assertTrue((wrapperInfo?.children?.size ?: 0) > 0)
+ }
+ }
+
+ @Test
fun subcompositionDesignInfoProviderTest() {
checkDesignInfoList("ScaffoldDesignInfoProvider", "A", "ObjectA, x=0, y=0")
}
diff --git a/compose/ui/ui-tooling/src/androidDeviceTest/kotlin/androidx/compose/ui/tooling/SimpleComposablePreview.kt b/compose/ui/ui-tooling/src/androidDeviceTest/kotlin/androidx/compose/ui/tooling/SimpleComposablePreview.kt
index 1d71f02..0e08d4c 100644
--- a/compose/ui/ui-tooling/src/androidDeviceTest/kotlin/androidx/compose/ui/tooling/SimpleComposablePreview.kt
+++ b/compose/ui/ui-tooling/src/androidDeviceTest/kotlin/androidx/compose/ui/tooling/SimpleComposablePreview.kt
@@ -35,6 +35,8 @@
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.tooling.preview.PreviewScreenSizes
+import androidx.compose.ui.tooling.preview.PreviewWrapper
+import androidx.compose.ui.tooling.preview.PreviewWrapperProvider
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.currentStateAsState
@@ -174,3 +176,26 @@
) {
Text(radius.toString())
}
+
+@Preview
+@PreviewWrapperProvider(wrapper = TestWrapper::class)
+@Composable
+fun TestWrapperPreview() {
+ Text(text = "test")
+}
+
+class TestWrapper : PreviewWrapper {
+ @Composable
+ override fun Wrap(content: @Composable (() -> Unit)) {
+ WrapperContainer { content() }
+ }
+}
+
+@Composable
+fun WrapperContainer(content: @Composable () -> Unit) {
+ Column {
+ Text("Header")
+ content()
+ Text("Footer")
+ }
+}
diff --git a/compose/ui/ui-tooling/src/androidMain/kotlin/androidx/compose/ui/tooling/ComposeViewAdapter.android.kt b/compose/ui/ui-tooling/src/androidMain/kotlin/androidx/compose/ui/tooling/ComposeViewAdapter.android.kt
index 48fc4b5..787e231 100644
--- a/compose/ui/ui-tooling/src/androidMain/kotlin/androidx/compose/ui/tooling/ComposeViewAdapter.android.kt
+++ b/compose/ui/ui-tooling/src/androidMain/kotlin/androidx/compose/ui/tooling/ComposeViewAdapter.android.kt
@@ -60,6 +60,7 @@
import androidx.compose.ui.tooling.data.makeTree
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import androidx.compose.ui.tooling.preview.PreviewWrapper
import androidx.compose.ui.unit.IntRect
import androidx.core.app.ActivityOptionsCompat
import androidx.lifecycle.Lifecycle
@@ -444,6 +445,7 @@
internal fun init(
className: String,
methodName: String,
+ previewWrapper: Class<out PreviewWrapper>? = null,
parameterProvider: Class<out PreviewParameterProvider<*>>? = null,
parameterProviderIndex: Int = 0,
debugPaintBounds: Boolean = false,
@@ -460,7 +462,6 @@
this.lookForDesignInfoProviders = lookForDesignInfoProviders
this.designInfoProvidersArgument = designInfoProvidersArgument ?: ""
this.onDraw = onDraw
-
previewComposition =
@Composable {
SideEffect(onCommit)
@@ -470,29 +471,30 @@
// We need to delay the reflection instantiation of the class until we are in
// the composable to ensure all the right initialization has happened and the
// Composable class loads correctly.
- val composable = {
- try {
- ComposableInvoker.invokeComposable(
- className,
- methodName,
- composer,
- *getPreviewProviderParameters(
- parameterProvider,
- parameterProviderIndex,
- ),
- )
- } catch (t: Throwable) {
- // If there is an exception, store it for later but do not catch it so
- // compose can handle it and dispose correctly.
- var exception: Throwable = t
- // Find the root cause and use that for the delayedException.
- while (exception is ReflectiveOperationException) {
- exception = exception.cause ?: break
+ val innerComposable =
+ @Composable {
+ try {
+ ComposableInvoker.invokeComposable(
+ className,
+ methodName,
+ composer,
+ *getPreviewProviderParameters(
+ parameterProvider,
+ parameterProviderIndex,
+ ),
+ )
+ } catch (t: Throwable) {
+ // If there is an exception, store it for later but do not catch it
+ // so compose can handle it and dispose correctly.
+ var exception: Throwable = t
+ // Find the root cause and use that for the delayedException.
+ while (exception is ReflectiveOperationException) {
+ exception = exception.cause ?: break
+ }
+ delayedException.set(exception)
+ throw t
}
- delayedException.set(exception)
- throw t
}
- }
if (animationClockStartTime >= 0) {
// When animation inspection is enabled, i.e. when a valid (non-negative)
// `animationClockStartTime` is passed, set the Preview Animation Clock.
@@ -512,7 +514,15 @@
Snapshot.sendApplyNotifications()
}
}
- composable()
+ // The [PreviewWrapper] allows for custom behavior logic to be applied to the
+ // preview content.
+ // If a wrapper class is specified, we instantiate it and call its [Wrap]
+ // function, passing the composable function as the content. This enables
+ // features like Remote Compose, custom theme injection, or specialized layout
+ // containers.
+ previewWrapper?.let { wrapperClass ->
+ instantiatePreviewWrapper(wrapperClass).Wrap(innerComposable)
+ } ?: innerComposable()
}
}
composeView.setContent(previewComposition)
@@ -548,6 +558,10 @@
val composableName = attrs.getAttributeValue(TOOLS_NS_URI, "composableName") ?: return
val className = composableName.substringBeforeLast('.')
val methodName = composableName.substringAfterLast('.')
+
+ val previewWrapperClass =
+ attrs.getAttributeValue(TOOLS_NS_URI, "previewWrapperClass")?.asPreviewWrapperClass()
+
val parameterProviderIndex =
attrs.getAttributeIntValue(TOOLS_NS_URI, "parameterProviderIndex", 0)
val parameterProviderClass =
@@ -565,6 +579,7 @@
init(
className = className,
methodName = methodName,
+ previewWrapper = previewWrapperClass,
parameterProvider = parameterProviderClass,
parameterProviderIndex = parameterProviderIndex,
debugPaintBounds =
diff --git a/compose/ui/ui-tooling/src/androidMain/kotlin/androidx/compose/ui/tooling/PreviewUtils.android.kt b/compose/ui/ui-tooling/src/androidMain/kotlin/androidx/compose/ui/tooling/PreviewUtils.android.kt
index 914cab5..67291bc 100644
--- a/compose/ui/ui-tooling/src/androidMain/kotlin/androidx/compose/ui/tooling/PreviewUtils.android.kt
+++ b/compose/ui/ui-tooling/src/androidMain/kotlin/androidx/compose/ui/tooling/PreviewUtils.android.kt
@@ -19,6 +19,7 @@
import androidx.compose.ui.tooling.data.Group
import androidx.compose.ui.tooling.data.UiToolingDataApi
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import androidx.compose.ui.tooling.preview.PreviewWrapper
import kotlin.collections.removeLast as removeLastKt
/** Tries to find the [Class] of the [PreviewParameterProvider] corresponding to the given FQN. */
@@ -32,6 +33,17 @@
}
}
+/** Tries to find the [Class] of the [PreviewWrapper] corresponding to the given FQN. */
+internal fun String.asPreviewWrapperClass(): Class<out PreviewWrapper>? {
+ try {
+ @Suppress("UNCHECKED_CAST")
+ return Class.forName(this) as? Class<out PreviewWrapper>
+ } catch (e: ClassNotFoundException) {
+ PreviewLogger.logError("Unable to find PreviewWrapper '$this'", e)
+ return null
+ }
+}
+
/**
* Returns an array with some values of a [PreviewParameterProvider]. If the given provider class is
* `null`, returns an empty array. Otherwise, if the given `parameterProviderIndex` is a valid
@@ -75,6 +87,28 @@
}
/**
+ * Instantiates a [PreviewWrapper] from the provided [Class].
+ *
+ * This method attempts to find a no-argument constructor on the given class and use it to creates a
+ * new instance.
+ *
+ * @param previewWrapper The [Class] of the [PreviewWrapper] to instantiate.
+ * @return A new instance of the [PreviewWrapper].
+ * @throws IllegalArgumentException If the class does not have a public, no-argument constructor.
+ */
+internal fun instantiatePreviewWrapper(previewWrapper: Class<out PreviewWrapper>?): PreviewWrapper {
+ val constructor =
+ previewWrapper
+ ?.constructors
+ ?.singleOrNull { it.parameterTypes.isEmpty() }
+ ?.apply { isAccessible = true }
+ ?: throw IllegalArgumentException(
+ "PreviewWrapper constructor can not" + " have parameters"
+ )
+ return constructor.newInstance() as PreviewWrapper
+}
+
+/**
* Checks if the object is of inlined value type. If yes, unwraps and returns the packed value If
* not, returns the object as it is
*/
diff --git a/compose/ui/ui/api/current.txt b/compose/ui/ui/api/current.txt
index a6c27eb..120fd10 100644
--- a/compose/ui/ui/api/current.txt
+++ b/compose/ui/ui/api/current.txt
@@ -2627,7 +2627,7 @@
@androidx.compose.runtime.Immutable public final class PointerInputChange {
ctor @KotlinOnly public PointerInputChange(androidx.compose.ui.input.pointer.PointerId id, long uptimeMillis, androidx.compose.ui.geometry.Offset position, boolean pressed, float pressure, long previousUptimeMillis, androidx.compose.ui.geometry.Offset previousPosition, boolean previousPressed, boolean isInitiallyConsumed, optional androidx.compose.ui.input.pointer.PointerType type, optional androidx.compose.ui.geometry.Offset scrollDelta, optional float scaleFactor, optional androidx.compose.ui.geometry.Offset panOffset);
- ctor @KotlinOnly public PointerInputChange(androidx.compose.ui.input.pointer.PointerId id, long uptimeMillis, androidx.compose.ui.geometry.Offset position, boolean pressed, long previousUptimeMillis, androidx.compose.ui.geometry.Offset previousPosition, boolean previousPressed, boolean isInitiallyConsumed, optional androidx.compose.ui.input.pointer.PointerType type, optional androidx.compose.ui.geometry.Offset scrollDelta, optional float scaleGestureFactor, optional androidx.compose.ui.geometry.Offset panGestureOffset);
+ ctor @KotlinOnly public PointerInputChange(androidx.compose.ui.input.pointer.PointerId id, long uptimeMillis, androidx.compose.ui.geometry.Offset position, boolean pressed, long previousUptimeMillis, androidx.compose.ui.geometry.Offset previousPosition, boolean previousPressed, boolean isInitiallyConsumed, optional androidx.compose.ui.input.pointer.PointerType type, optional androidx.compose.ui.geometry.Offset scrollDelta, optional float scaleFactor, optional androidx.compose.ui.geometry.Offset panOffset);
ctor @BytecodeOnly public PointerInputChange(long, long, long, boolean, float, long, long, boolean, boolean, int, long, float, long, int, kotlin.jvm.internal.DefaultConstructorMarker!);
ctor @BytecodeOnly public PointerInputChange(long, long, long, boolean, float, long, long, boolean, boolean, int, long, float, long, kotlin.jvm.internal.DefaultConstructorMarker!);
ctor @BytecodeOnly public PointerInputChange(long, long, long, boolean, float, long, long, boolean, boolean, int, long, int, kotlin.jvm.internal.DefaultConstructorMarker!);
@@ -2641,7 +2641,7 @@
method public void consume();
method @KotlinOnly public androidx.compose.ui.input.pointer.PointerInputChange copy(optional androidx.compose.ui.input.pointer.PointerId id, optional long currentTime, optional androidx.compose.ui.geometry.Offset currentPosition, optional boolean currentPressed, optional float pressure, optional long previousTime, optional androidx.compose.ui.geometry.Offset previousPosition, optional boolean previousPressed, optional androidx.compose.ui.input.pointer.PointerType type, optional androidx.compose.ui.geometry.Offset scrollDelta);
method @KotlinOnly public androidx.compose.ui.input.pointer.PointerInputChange copy(optional androidx.compose.ui.input.pointer.PointerId id, optional long currentTime, optional androidx.compose.ui.geometry.Offset currentPosition, optional boolean currentPressed, optional float pressure, optional long previousTime, optional androidx.compose.ui.geometry.Offset previousPosition, optional boolean previousPressed, optional androidx.compose.ui.input.pointer.PointerType type, optional java.util.List<androidx.compose.ui.input.pointer.HistoricalChange> historical, optional androidx.compose.ui.geometry.Offset scrollDelta);
- method @KotlinOnly public androidx.compose.ui.input.pointer.PointerInputChange copy(optional androidx.compose.ui.input.pointer.PointerId id, optional long currentTime, optional androidx.compose.ui.geometry.Offset currentPosition, optional boolean currentPressed, optional float pressure, optional long previousTime, optional androidx.compose.ui.geometry.Offset previousPosition, optional boolean previousPressed, optional androidx.compose.ui.input.pointer.PointerType type, optional java.util.List<androidx.compose.ui.input.pointer.HistoricalChange> historical, optional androidx.compose.ui.geometry.Offset scrollDelta, optional float scaleGestureFactor, optional androidx.compose.ui.geometry.Offset panGestureOffset);
+ method @KotlinOnly public androidx.compose.ui.input.pointer.PointerInputChange copy(optional androidx.compose.ui.input.pointer.PointerId id, optional long currentTime, optional androidx.compose.ui.geometry.Offset currentPosition, optional boolean currentPressed, optional float pressure, optional long previousTime, optional androidx.compose.ui.geometry.Offset previousPosition, optional boolean previousPressed, optional androidx.compose.ui.input.pointer.PointerType type, optional java.util.List<androidx.compose.ui.input.pointer.HistoricalChange> historical, optional androidx.compose.ui.geometry.Offset scrollDelta, optional float scaleFactor, optional androidx.compose.ui.geometry.Offset panOffset);
method @KotlinOnly @Deprecated public androidx.compose.ui.input.pointer.PointerInputChange copy(optional androidx.compose.ui.input.pointer.PointerId id, optional long currentTime, optional androidx.compose.ui.geometry.Offset currentPosition, optional boolean currentPressed, optional long previousTime, optional androidx.compose.ui.geometry.Offset previousPosition, optional boolean previousPressed, androidx.compose.ui.input.pointer.ConsumedData consumed, optional androidx.compose.ui.input.pointer.PointerType type, optional androidx.compose.ui.geometry.Offset scrollDelta);
method @KotlinOnly public androidx.compose.ui.input.pointer.PointerInputChange copy(optional androidx.compose.ui.input.pointer.PointerId id, optional long currentTime, optional androidx.compose.ui.geometry.Offset currentPosition, optional boolean currentPressed, optional long previousTime, optional androidx.compose.ui.geometry.Offset previousPosition, optional boolean previousPressed, optional androidx.compose.ui.input.pointer.PointerType type, optional androidx.compose.ui.geometry.Offset scrollDelta);
method @KotlinOnly @SuppressCompatibility @androidx.compose.ui.ExperimentalComposeUiApi public androidx.compose.ui.input.pointer.PointerInputChange copy(optional androidx.compose.ui.input.pointer.PointerId id, optional long currentTime, optional androidx.compose.ui.geometry.Offset currentPosition, optional boolean currentPressed, optional long previousTime, optional androidx.compose.ui.geometry.Offset previousPosition, optional boolean previousPressed, optional androidx.compose.ui.input.pointer.PointerType type, java.util.List<androidx.compose.ui.input.pointer.HistoricalChange> historical, optional androidx.compose.ui.geometry.Offset scrollDelta);
diff --git a/compose/ui/ui/api/restricted_current.txt b/compose/ui/ui/api/restricted_current.txt
index 3a73a17..739bf6d 100644
--- a/compose/ui/ui/api/restricted_current.txt
+++ b/compose/ui/ui/api/restricted_current.txt
@@ -2628,7 +2628,7 @@
@androidx.compose.runtime.Immutable public final class PointerInputChange {
ctor @KotlinOnly public PointerInputChange(androidx.compose.ui.input.pointer.PointerId id, long uptimeMillis, androidx.compose.ui.geometry.Offset position, boolean pressed, float pressure, long previousUptimeMillis, androidx.compose.ui.geometry.Offset previousPosition, boolean previousPressed, boolean isInitiallyConsumed, optional androidx.compose.ui.input.pointer.PointerType type, optional androidx.compose.ui.geometry.Offset scrollDelta, optional float scaleFactor, optional androidx.compose.ui.geometry.Offset panOffset);
- ctor @KotlinOnly public PointerInputChange(androidx.compose.ui.input.pointer.PointerId id, long uptimeMillis, androidx.compose.ui.geometry.Offset position, boolean pressed, long previousUptimeMillis, androidx.compose.ui.geometry.Offset previousPosition, boolean previousPressed, boolean isInitiallyConsumed, optional androidx.compose.ui.input.pointer.PointerType type, optional androidx.compose.ui.geometry.Offset scrollDelta, optional float scaleGestureFactor, optional androidx.compose.ui.geometry.Offset panGestureOffset);
+ ctor @KotlinOnly public PointerInputChange(androidx.compose.ui.input.pointer.PointerId id, long uptimeMillis, androidx.compose.ui.geometry.Offset position, boolean pressed, long previousUptimeMillis, androidx.compose.ui.geometry.Offset previousPosition, boolean previousPressed, boolean isInitiallyConsumed, optional androidx.compose.ui.input.pointer.PointerType type, optional androidx.compose.ui.geometry.Offset scrollDelta, optional float scaleFactor, optional androidx.compose.ui.geometry.Offset panOffset);
ctor @BytecodeOnly public PointerInputChange(long, long, long, boolean, float, long, long, boolean, boolean, int, long, float, long, int, kotlin.jvm.internal.DefaultConstructorMarker!);
ctor @BytecodeOnly public PointerInputChange(long, long, long, boolean, float, long, long, boolean, boolean, int, long, float, long, kotlin.jvm.internal.DefaultConstructorMarker!);
ctor @BytecodeOnly public PointerInputChange(long, long, long, boolean, float, long, long, boolean, boolean, int, long, int, kotlin.jvm.internal.DefaultConstructorMarker!);
@@ -2642,7 +2642,7 @@
method public void consume();
method @KotlinOnly public androidx.compose.ui.input.pointer.PointerInputChange copy(optional androidx.compose.ui.input.pointer.PointerId id, optional long currentTime, optional androidx.compose.ui.geometry.Offset currentPosition, optional boolean currentPressed, optional float pressure, optional long previousTime, optional androidx.compose.ui.geometry.Offset previousPosition, optional boolean previousPressed, optional androidx.compose.ui.input.pointer.PointerType type, optional androidx.compose.ui.geometry.Offset scrollDelta);
method @KotlinOnly public androidx.compose.ui.input.pointer.PointerInputChange copy(optional androidx.compose.ui.input.pointer.PointerId id, optional long currentTime, optional androidx.compose.ui.geometry.Offset currentPosition, optional boolean currentPressed, optional float pressure, optional long previousTime, optional androidx.compose.ui.geometry.Offset previousPosition, optional boolean previousPressed, optional androidx.compose.ui.input.pointer.PointerType type, optional java.util.List<androidx.compose.ui.input.pointer.HistoricalChange> historical, optional androidx.compose.ui.geometry.Offset scrollDelta);
- method @KotlinOnly public androidx.compose.ui.input.pointer.PointerInputChange copy(optional androidx.compose.ui.input.pointer.PointerId id, optional long currentTime, optional androidx.compose.ui.geometry.Offset currentPosition, optional boolean currentPressed, optional float pressure, optional long previousTime, optional androidx.compose.ui.geometry.Offset previousPosition, optional boolean previousPressed, optional androidx.compose.ui.input.pointer.PointerType type, optional java.util.List<androidx.compose.ui.input.pointer.HistoricalChange> historical, optional androidx.compose.ui.geometry.Offset scrollDelta, optional float scaleGestureFactor, optional androidx.compose.ui.geometry.Offset panGestureOffset);
+ method @KotlinOnly public androidx.compose.ui.input.pointer.PointerInputChange copy(optional androidx.compose.ui.input.pointer.PointerId id, optional long currentTime, optional androidx.compose.ui.geometry.Offset currentPosition, optional boolean currentPressed, optional float pressure, optional long previousTime, optional androidx.compose.ui.geometry.Offset previousPosition, optional boolean previousPressed, optional androidx.compose.ui.input.pointer.PointerType type, optional java.util.List<androidx.compose.ui.input.pointer.HistoricalChange> historical, optional androidx.compose.ui.geometry.Offset scrollDelta, optional float scaleFactor, optional androidx.compose.ui.geometry.Offset panOffset);
method @KotlinOnly @Deprecated public androidx.compose.ui.input.pointer.PointerInputChange copy(optional androidx.compose.ui.input.pointer.PointerId id, optional long currentTime, optional androidx.compose.ui.geometry.Offset currentPosition, optional boolean currentPressed, optional long previousTime, optional androidx.compose.ui.geometry.Offset previousPosition, optional boolean previousPressed, androidx.compose.ui.input.pointer.ConsumedData consumed, optional androidx.compose.ui.input.pointer.PointerType type, optional androidx.compose.ui.geometry.Offset scrollDelta);
method @KotlinOnly public androidx.compose.ui.input.pointer.PointerInputChange copy(optional androidx.compose.ui.input.pointer.PointerId id, optional long currentTime, optional androidx.compose.ui.geometry.Offset currentPosition, optional boolean currentPressed, optional long previousTime, optional androidx.compose.ui.geometry.Offset previousPosition, optional boolean previousPressed, optional androidx.compose.ui.input.pointer.PointerType type, optional androidx.compose.ui.geometry.Offset scrollDelta);
method @KotlinOnly @SuppressCompatibility @androidx.compose.ui.ExperimentalComposeUiApi public androidx.compose.ui.input.pointer.PointerInputChange copy(optional androidx.compose.ui.input.pointer.PointerId id, optional long currentTime, optional androidx.compose.ui.geometry.Offset currentPosition, optional boolean currentPressed, optional long previousTime, optional androidx.compose.ui.geometry.Offset previousPosition, optional boolean previousPressed, optional androidx.compose.ui.input.pointer.PointerType type, java.util.List<androidx.compose.ui.input.pointer.HistoricalChange> historical, optional androidx.compose.ui.geometry.Offset scrollDelta);
diff --git a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/input/pointer/MotionEventAdapter.android.kt b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/input/pointer/MotionEventAdapter.android.kt
index a36f567..0a0243a 100644
--- a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/input/pointer/MotionEventAdapter.android.kt
+++ b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/input/pointer/MotionEventAdapter.android.kt
@@ -553,14 +553,14 @@
HistoricalChange(
uptimeMillis = getHistoricalEventTime(pos),
position = originalEventPosition,
- scaleGestureFactor =
+ scaleFactor =
getHistoricalAxisValue(
MotionEvent.AXIS_GESTURE_PINCH_SCALE_FACTOR,
index,
pos,
)
.takeIf { it > 0 } ?: 1f,
- panGestureOffset =
+ panOffset =
if (
Build.VERSION.SDK_INT >= 29 &&
motionEvent.classification ==
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/pointer/HitPathTracker.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/pointer/HitPathTracker.kt
index 45526ea..7cad464 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/pointer/HitPathTracker.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/pointer/HitPathTracker.kt
@@ -530,8 +530,8 @@
parentCoordinates,
historicalPosition,
),
- scaleGestureFactor = it.scaleFactor,
- panGestureOffset = it.panOffset,
+ scaleFactor = it.scaleFactor,
+ panOffset = it.panOffset,
originalEventPosition = it.originalEventPosition,
)
)
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/pointer/PointerEvent.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/pointer/PointerEvent.kt
index 99159e5..1afbb40c 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/pointer/PointerEvent.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/pointer/PointerEvent.kt
@@ -445,8 +445,8 @@
isInitiallyConsumed: Boolean,
type: PointerType = PointerType.Touch,
scrollDelta: Offset = Offset.Zero,
- scaleGestureFactor: Float = 1f,
- panGestureOffset: Offset = Offset.Zero,
+ scaleFactor: Float = 1f,
+ panOffset: Offset = Offset.Zero,
) : this(
id = id,
uptimeMillis = uptimeMillis,
@@ -459,8 +459,8 @@
isInitiallyConsumed = isInitiallyConsumed,
type = type,
scrollDelta = scrollDelta,
- scaleFactor = scaleGestureFactor,
- panOffset = panGestureOffset,
+ scaleFactor = scaleFactor,
+ panOffset = panOffset,
)
@Deprecated(message = "Maintained for binary compatibility", level = DeprecationLevel.HIDDEN)
@@ -563,8 +563,8 @@
type: PointerType,
historical: List<HistoricalChange>,
scrollDelta: Offset,
- scaleGestureFactor: Float,
- panGestureOffset: Offset,
+ scaleFactor: Float,
+ panOffset: Offset,
originalEventPosition: Offset,
) : this(
id = id,
@@ -578,8 +578,8 @@
isInitiallyConsumed = isInitiallyConsumed,
type = type,
scrollDelta = scrollDelta,
- scaleFactor = scaleGestureFactor,
- panOffset = panGestureOffset,
+ scaleFactor = scaleFactor,
+ panOffset = panOffset,
) {
_historical = historical
this.originalEventPosition = originalEventPosition
@@ -674,8 +674,8 @@
type = type,
historical = this.historical,
scrollDelta = this.scrollDelta,
- scaleGestureFactor = this.scaleFactor,
- panGestureOffset = this.panOffset,
+ scaleFactor = this.scaleFactor,
+ panOffset = this.panOffset,
originalEventPosition = this.originalEventPosition,
)
.also {
@@ -760,8 +760,8 @@
type = type,
historical = this.historical,
scrollDelta = scrollDelta,
- scaleGestureFactor = this.scaleFactor,
- panGestureOffset = this.panOffset,
+ scaleFactor = this.scaleFactor,
+ panOffset = this.panOffset,
originalEventPosition = this.originalEventPosition,
)
.also {
@@ -804,8 +804,8 @@
type = type,
historical = this.historical,
scrollDelta = scrollDelta,
- scaleGestureFactor = this.scaleFactor,
- panGestureOffset = this.panOffset,
+ scaleFactor = this.scaleFactor,
+ panOffset = this.panOffset,
originalEventPosition = this.originalEventPosition,
)
.also {
@@ -890,8 +890,8 @@
type = type,
historical = historical,
scrollDelta = scrollDelta,
- scaleGestureFactor = this.scaleFactor,
- panGestureOffset = this.panOffset,
+ scaleFactor = this.scaleFactor,
+ panOffset = this.panOffset,
originalEventPosition = this.originalEventPosition,
)
.also {
@@ -921,8 +921,8 @@
type: PointerType = this.type,
historical: List<HistoricalChange> = this.historical,
scrollDelta: Offset = this.scrollDelta,
- scaleGestureFactor: Float = this.scaleFactor,
- panGestureOffset: Offset = this.panOffset,
+ scaleFactor: Float = this.scaleFactor,
+ panOffset: Offset = this.panOffset,
): PointerInputChange =
PointerInputChange(
id = id,
@@ -937,8 +937,8 @@
type = type,
historical = historical,
scrollDelta = scrollDelta,
- scaleGestureFactor = scaleGestureFactor,
- panGestureOffset = panGestureOffset,
+ scaleFactor = scaleFactor,
+ panOffset = panOffset,
originalEventPosition = this.originalEventPosition,
)
.also {
@@ -1005,10 +1005,10 @@
internal constructor(
uptimeMillis: Long,
position: Offset,
- scaleGestureFactor: Float,
- panGestureOffset: Offset,
+ scaleFactor: Float,
+ panOffset: Offset,
originalEventPosition: Offset,
- ) : this(uptimeMillis, position, scaleGestureFactor, panGestureOffset) {
+ ) : this(uptimeMillis, position, scaleFactor, panOffset) {
this.originalEventPosition = originalEventPosition
}
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/pointer/PointerInputEventProcessor.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/pointer/PointerInputEventProcessor.kt
index f197f8b..c272b4f 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/pointer/PointerInputEventProcessor.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/pointer/PointerInputEventProcessor.kt
@@ -220,8 +220,8 @@
type = it.type,
historical = it.historical,
scrollDelta = it.scrollDelta,
- scaleGestureFactor = it.scaleGestureFactor,
- panGestureOffset = it.panGestureOffset,
+ scaleFactor = it.scaleGestureFactor,
+ panOffset = it.panGestureOffset,
originalEventPosition = it.originalEventPosition,
),
)
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 be84b21..05792aa 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
@@ -903,7 +903,10 @@
}
val layerCoordinator = _innerLayerCoordinator
if (layerCoordinator != null) {
- checkPreconditionNotNull(layerCoordinator.layer) { "layer was not set" }
+ checkPreconditionNotNull(layerCoordinator.layer) {
+ "layer was not set. This error is usually caused by operating off of the UI " +
+ "thread. Did you call invalidate() instead of postInvalidate()?"
+ }
}
return layerCoordinator
}
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/NodeCoordinator.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/NodeCoordinator.kt
index 515f533..742544f 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/NodeCoordinator.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/NodeCoordinator.kt
@@ -570,13 +570,13 @@
layoutNode.onCoordinatorRectChanged(this)
}
it.destroy()
+ layer = null
layoutNode.innerLayerCoordinatorIsDirty = true
invalidateParentLayer()
if (isAttached && layoutNode.isPlaced) {
layoutNode.owner?.onLayoutChange(layoutNode)
}
}
- layer = null
lastLayerDrawingWasSkipped = false
}
}
diff --git a/development/build_log_simplifier/messages.ignore b/development/build_log_simplifier/messages.ignore
index 3389a80..34f75bf 100644
--- a/development/build_log_simplifier/messages.ignore
+++ b/development/build_log_simplifier/messages.ignore
@@ -375,7 +375,7 @@
# Remove when https://youtrack.jetbrains.com/issue/KT-70013 is fixed, o/w anytime a new entry is added to this list, this message will be printed.
Calculating task graph as configuration cache cannot be reused because the set of paths ignored in file-system-check input tracking.*
# b/414775532
-# > Task :compose-hero-benchmarks:poxedex-compose:app:kspReleaseKotlin
+# > Task :compose-hero-benchmarks:pokedex-compose:app:kspReleaseKotlin
w: \[ksp\] LibraryGlideModules \[\] are included more than once, keeping only one\!
# b/439568035
WARNING: The option setting 'android\.newDsl=false' is deprecated\.
diff --git a/libraryversions.toml b/libraryversions.toml
index f9a7f58..760e1f1 100644
--- a/libraryversions.toml
+++ b/libraryversions.toml
@@ -10,7 +10,7 @@
ASYNCLAYOUTINFLATER = "1.1.0"
AUTOFILL = "1.4.0-alpha01"
BENCHMARK = "1.5.0-alpha04"
-BIOMETRIC = "1.4.0-alpha05"
+BIOMETRIC = "1.4.0-alpha06"
BLUETOOTH = "1.0.0-alpha02"
BROWSER = "1.10.0-rc01"
BUILDSRC_TESTS = "1.0.0-alpha01"
diff --git a/room3/room3-compiler/src/main/kotlin/androidx/room3/processor/CustomConverterProcessor.kt b/room3/room3-compiler/src/main/kotlin/androidx/room3/processor/CustomConverterProcessor.kt
index 9f4d7a3..720bf32 100644
--- a/room3/room3-compiler/src/main/kotlin/androidx/room3/processor/CustomConverterProcessor.kt
+++ b/room3/room3-compiler/src/main/kotlin/androidx/room3/processor/CustomConverterProcessor.kt
@@ -111,7 +111,13 @@
if (duplicates.isNotEmpty()) {
context.logger.e(
converter.function,
- ProcessorErrors.duplicateTypeConverters(duplicates),
+ ProcessorErrors.duplicateTypeConverters(
+ duplicates.map {
+ it.className.toString(context.codeLanguage) +
+ "." +
+ it.function.name
+ }
+ ),
)
}
}
@@ -166,7 +172,7 @@
val returnType = asMember.returnType
val invalidReturnType = returnType.isInvalidReturnType()
context.checker.check(
- functionElement.isPublic(),
+ functionElement.isPublic() || functionElement.isInternal(),
functionElement,
TYPE_CONVERTER_MUST_BE_PUBLIC,
)
diff --git a/room3/room3-compiler/src/main/kotlin/androidx/room3/processor/DaoReturnTypeConverterProcessor.kt b/room3/room3-compiler/src/main/kotlin/androidx/room3/processor/DaoReturnTypeConverterProcessor.kt
index 3fdd343..7e8aaa3c 100644
--- a/room3/room3-compiler/src/main/kotlin/androidx/room3/processor/DaoReturnTypeConverterProcessor.kt
+++ b/room3/room3-compiler/src/main/kotlin/androidx/room3/processor/DaoReturnTypeConverterProcessor.kt
@@ -379,11 +379,13 @@
}
if (duplicates.isNotEmpty()) {
+ val converterNames =
+ (listOf(converter) + duplicates).map {
+ it.className.toString(context.codeLanguage) + "." + it.function.name
+ }
context.logger.e(
converter.function,
- ProcessorErrors.duplicateDaoReturnTypeConverters(
- listOf(converter) + duplicates
- ),
+ ProcessorErrors.duplicateDaoReturnTypeConverters(converterNames),
)
}
}
diff --git a/room3/room3-compiler/src/main/kotlin/androidx/room3/processor/DataClassProcessor.kt b/room3/room3-compiler/src/main/kotlin/androidx/room3/processor/DataClassProcessor.kt
index 58d166f..2c4d1b1 100644
--- a/room3/room3-compiler/src/main/kotlin/androidx/room3/processor/DataClassProcessor.kt
+++ b/room3/room3-compiler/src/main/kotlin/androidx/room3/processor/DataClassProcessor.kt
@@ -19,7 +19,6 @@
import androidx.room3.ColumnInfo
import androidx.room3.Embedded
import androidx.room3.Ignore
-import androidx.room3.Junction
import androidx.room3.PrimaryKey
import androidx.room3.Relation
import androidx.room3.compiler.processing.XExecutableElement
@@ -32,7 +31,6 @@
import androidx.room3.ext.isNotVoid
import androidx.room3.processor.ProcessorErrors.CANNOT_FIND_GETTER_FOR_PROPERTY
import androidx.room3.processor.ProcessorErrors.CANNOT_FIND_SETTER_FOR_PROPERTY
-import androidx.room3.processor.ProcessorErrors.DATA_CLASS_PROPERTY_HAS_DUPLICATE_COLUMN_NAME
import androidx.room3.processor.autovalue.AutoValueDataClassProcessorDelegate
import androidx.room3.processor.cache.Cache
import androidx.room3.vo.CallType
@@ -227,9 +225,6 @@
it.value.map(Property::getPath),
),
)
- it.value.forEach {
- context.logger.e(it.element, DATA_CLASS_PROPERTY_HAS_DUPLICATE_COLUMN_NAME)
- }
}
val methods =
diff --git a/room3/room3-compiler/src/main/kotlin/androidx/room3/processor/ProcessorErrors.kt b/room3/room3-compiler/src/main/kotlin/androidx/room3/processor/ProcessorErrors.kt
index 3d2ff48..8b9a89c 100644
--- a/room3/room3-compiler/src/main/kotlin/androidx/room3/processor/ProcessorErrors.kt
+++ b/room3/room3-compiler/src/main/kotlin/androidx/room3/processor/ProcessorErrors.kt
@@ -27,8 +27,6 @@
import androidx.room3.ext.RoomTypeNames.ROOM_DB
import androidx.room3.parser.QueryType
import androidx.room3.parser.SQLTypeAffinity
-import androidx.room3.vo.CustomDaoReturnTypeConverter
-import androidx.room3.vo.CustomTypeConverter
import androidx.room3.vo.Property
object ProcessorErrors {
@@ -38,52 +36,77 @@
const val ISSUE_TRACKER_LINK = "https://issuetracker.google.com/issues/new?component=413107"
- val MISSING_QUERY_ANNOTATION = "Query functions must be annotated with ${Query::class.java}"
- val MISSING_INSERT_ANNOTATION = "Insert functions must be annotated with ${Insert::class.java}"
- val MISSING_DELETE_ANNOTATION = "Delete functions must be annotated with ${Delete::class.java}"
- val MISSING_UPDATE_ANNOTATION = "Update functions must be annotated with ${Update::class.java}"
- val MISSING_UPSERT_ANNOTATION = "Upsert functions must be annotated with ${Upsert::class.java}"
+ val MISSING_QUERY_ANNOTATION =
+ "Query functions must be annotated with ${Query::class.qualifiedName}"
+
+ val MISSING_INSERT_ANNOTATION =
+ "Insert functions must be annotated with ${Insert::class.qualifiedName}"
+
+ val MISSING_DELETE_ANNOTATION =
+ "Delete functions must be annotated with ${Delete::class.qualifiedName}"
+
+ val MISSING_UPDATE_ANNOTATION =
+ "Update functions must be annotated with ${Update::class.qualifiedName}"
+
+ val MISSING_UPSERT_ANNOTATION =
+ "Upsert functions must be annotated with ${Upsert::class.qualifiedName}"
+
val MISSING_RAWQUERY_ANNOTATION =
- "RawQuery functions must be annotated with" + " ${RawQuery::class.java}"
+ "RawQuery functions must be annotated with ${RawQuery::class.qualifiedName}"
+
const val INVALID_ON_CONFLICT_VALUE =
"On conflict value must be one of @OnConflictStrategy values."
+
const val TRANSACTION_REFERENCE_DOCS =
- "https://developer.android.com/reference/androidx/" + "room/Transaction.html"
+ "https://developer.android.com/reference/androidx/room/Transaction.html"
+
val INVALID_ANNOTATION_COUNT_IN_DAO_FUNCTION =
- "An abstract DAO function must be" +
- " annotated with one and only one of the following annotations: " +
- DaoProcessor.PROCESSED_ANNOTATIONS.joinToString(", ") { "@" + it.java.simpleName }
- val INVALID_ANNOTATION_IN_DAO_PROPERTY =
- "An abstract DAO property must be" + " annotated with @get:${Query::class.java}."
+ "An abstract DAO function must be annotated with one and only one of the following " +
+ "annotations: " +
+ DaoProcessor.PROCESSED_ANNOTATIONS.joinToString() { "@" + it.simpleName }
+
+ const val INVALID_ANNOTATION_IN_DAO_PROPERTY =
+ "An abstract DAO property must be annotated with @get:Query."
+
const val CANNOT_RESOLVE_RETURN_TYPE = "Cannot resolve return type for %s"
+
const val CANNOT_USE_UNBOUND_GENERICS_IN_QUERY_FUNCTIONS =
- "Cannot use unbound generics in query" +
- " functions. It must be bound to a type through base Dao class."
+ "Cannot use unbound generics in query functions. " +
+ "It must be bound to a type through the base DAO class."
+
const val CANNOT_USE_UNBOUND_GENERICS_IN_INSERT_FUNCTIONS =
- "Cannot use unbound generics in" +
- " insert functions. It must be bound to a type through base Dao class."
+ "Cannot use unbound generics in insert functions. " +
+ "It must be bound to a type through the base DAO class."
+
const val CANNOT_USE_UNBOUND_GENERICS_IN_UPSERT_FUNCTIONS =
- "Cannot use unbound generics in" +
- " upsert functions. It must be bound to a type through base Dao class."
+ "Cannot use unbound generics in upsert functions. " +
+ "It must be bound to a type through the base DAO class."
+
const val CANNOT_USE_UNBOUND_GENERICS_IN_ENTITY_PROPERTIES =
"Cannot use unbound properties in entities."
+
const val CANNOT_USE_UNBOUND_GENERICS_IN_DAO_CLASSES =
- "Cannot use unbound generics in Dao classes." +
- " If you are trying to create a base DAO, create a normal class, extend it with type" +
- " params then mark the subclass with @Dao."
+ "Cannot use unbound generics in DAO classes." +
+ " If you are trying to create a base DAO, create a normal class with type " +
+ "parameters, extend it with type arguments and then mark the subclass with @Dao."
+
const val CANNOT_FIND_GETTER_FOR_PROPERTY = "Cannot find getter for property."
+
const val CANNOT_FIND_SETTER_FOR_PROPERTY = "Cannot find setter for property."
+
const val MISSING_PRIMARY_KEY =
"An entity must have at least 1 property annotated with @PrimaryKey"
+
const val AUTO_INCREMENTED_PRIMARY_KEY_IS_NOT_INT =
- "If a primary key is annotated with" +
- " autoGenerate, its type must be int, Integer, long or Long."
+ "If a primary key is annotated with 'autoGenerate', its type must be Int or Long"
+
const val AUTO_INCREMENT_EMBEDDED_HAS_MULTIPLE_PROPERTIES =
- "When @PrimaryKey annotation is used on a" +
- " property annotated with @Embedded, the embedded class should have only 1 property."
+ "When the @PrimaryKey annotation is used on a property annotated with @Embedded, " +
+ "the embedded class should only have a single property representing the primary key."
+
const val INVALID_INDEX_ORDERS_SIZE =
- "The number of entries in @Index#orders() should be " +
- "equal to the amount of columns defined in the @Index value."
+ "The number of entries in @Index.orders should be equal to the amount of columns " +
+ "defined in the @Index value."
const val DO_NOT_USE_GENERIC_IMMUTABLE_MULTIMAP =
"Do not use ImmutableMultimap as a type (as with" +
@@ -92,21 +115,22 @@
fun multiplePrimaryKeyAnnotations(primaryKeys: List<String>): String {
return """
- You cannot have multiple primary keys defined in an Entity. If you
- want to declare a composite primary key, you should use @Entity#primaryKeys and
- not use @PrimaryKey. Defined Primary Keys:
- ${primaryKeys.joinToString(", ")}"""
+ You cannot have multiple primary keys defined in an @Entity. If you
+ want to declare a composite primary key, you should use @Entity.primaryKeys and
+ not use @PrimaryKey. Defined primary keys:
+ ${primaryKeys.joinToString()}"""
.trim()
}
fun primaryKeyColumnDoesNotExist(columnName: String, allColumns: List<String>): String {
- return "$columnName referenced in the primary key does not exist in the Entity." +
- " Available column names:${allColumns.joinToString(", ")}"
+ return "$columnName referenced in the primary key does not exist in the @Entity." +
+ " Available column names: ${allColumns.joinToString()}"
}
const val DAO_MUST_BE_AN_ABSTRACT_CLASS_OR_AN_INTERFACE =
- "Dao class must be an abstract class or" + " an interface"
- const val DAO_MUST_BE_ANNOTATED_WITH_DAO = "Dao class must be annotated with @Dao"
+ "DAO declaration must be an abstract class or an interface"
+
+ const val DAO_MUST_BE_ANNOTATED_WITH_DAO = "DAO declaration must be annotated with @Dao"
fun daoMustHaveMatchingConstructor(daoName: String, dbName: String): String {
return """
@@ -116,92 +140,98 @@
.trim()
}
- const val ENTITY_MUST_BE_ANNOTATED_WITH_ENTITY = "Entity class must be annotated with @Entity"
+ const val ENTITY_MUST_BE_ANNOTATED_WITH_ENTITY =
+ "Entity declaration must be annotated with @Entity"
+
const val DATABASE_ANNOTATION_MUST_HAVE_LIST_OF_ENTITIES =
- "@Database annotation must specify list" + " of entities"
+ "@Database annotation must specify list of entities"
+
const val COLUMN_NAME_CANNOT_BE_EMPTY =
- "Column name cannot be blank. If you don't want to set it" +
- ", just remove the @ColumnInfo annotation or use @ColumnInfo.INHERIT_PROPERTY_NAME."
+ "Column name cannot be blank. If you don't want to set it, remove the @ColumnInfo " +
+ "annotation or use ColumnInfo.INHERIT_PROPERTY_NAME."
const val ENTITY_TABLE_NAME_CANNOT_BE_EMPTY =
- "Entity table name cannot be blank. If you don't want" +
- " to set it, just remove the tableName property."
+ "Entity table name cannot be blank. If you don't want to set it, remove the 'tableName' " +
+ "annotation value."
const val ENTITY_TABLE_NAME_CANNOT_START_WITH_SQLITE =
- "Entity table name cannot start with \"sqlite_\"."
+ "Entity table name cannot start with 'sqlite_'."
const val VIEW_MUST_BE_ANNOTATED_WITH_DATABASE_VIEW =
- "View class must be annotated with " + "@DatabaseView"
+ "View declaration must be annotated with @DatabaseView"
+
const val VIEW_NAME_CANNOT_BE_EMPTY =
- "View name cannot be blank. If you don't want" +
- " to set it, just remove the viewName property."
- const val VIEW_NAME_CANNOT_START_WITH_SQLITE = "View name cannot start with \"sqlite_\"."
- const val VIEW_QUERY_MUST_BE_SELECT = "Query for @DatabaseView must be a SELECT."
+ "View name cannot be blank. If you don't want to set it, remove the 'viewName' " +
+ "annotation value."
+
+ const val VIEW_NAME_CANNOT_START_WITH_SQLITE = "View name cannot start with 'sqlite_'."
+
+ const val VIEW_QUERY_MUST_BE_SELECT = "Query for @DatabaseView must be a SELECT statement."
+
const val VIEW_QUERY_CANNOT_TAKE_ARGUMENTS =
"Query for @DatabaseView cannot take any arguments."
fun viewCircularReferenceDetected(views: List<String>): String {
- return "Circular reference detected among views: ${views.joinToString(", ")}"
+ return "Circular reference detected among views: ${views.joinToString()}"
}
const val CANNOT_BIND_QUERY_PARAMETER_INTO_STMT =
"Query function parameters should either be a" +
" type that can be converted into a database column or a List / Array that contains" +
- " such type. You can consider adding a Type Adapter for this."
+ " such type. Consider also adding a @TypeConverter for the parameter type."
const val QUERY_PARAMETERS_CANNOT_START_WITH_UNDERSCORE =
- "Query/Insert function parameters cannot " + "start with underscore (_)."
+ "@Query / @Insert function parameters cannot start with underscore ('_')."
const val DAO_RETURN_TYPE_CONVERTER_WARNING =
"Did you forget to provide a " +
"@DaoReturnTypeConverter to your @Database or @Dao via @DaoReturnTypeConverters? If " +
"you are using a @DaoReturnTypeConverter, you must also verify that the converter " +
- "function and the corresponding DAO function are either both suspending or both" +
- " blocking."
+ "function's return type matches the DAO function by raw type and type arguments."
fun cannotFindQueryResultAdapter(returnTypeName: String) =
"Not sure how to convert the query result to this function's return type " +
"($returnTypeName). $DAO_RETURN_TYPE_CONVERTER_WARNING"
fun classMustImplementEqualsAndHashCode(keyType: String) =
- "The key" +
- " of the provided function's multimap return type must implement equals() and " +
- "hashCode(). Key type is: $keyType."
+ "The key of the query function's multimap return type must implement equals() and " +
+ "hashCode(). Key type missing implementation is: $keyType."
const val INSERT_DOES_NOT_HAVE_ANY_PARAMETERS_TO_INSERT =
- "Function annotated with" + " @Insert but does not have any parameters to insert."
+ "Function annotated with @Insert but does not have any parameters to insert."
const val UPSERT_DOES_NOT_HAVE_ANY_PARAMETERS_TO_UPSERT =
- "Function annotated with" + " @Upsert but does not have any parameters to insert or update."
+ "Function annotated with @Upsert but does not have any parameters to insert or update."
const val DELETE_MISSING_PARAMS =
- "Function annotated with" + " @Delete but does not have any parameters to delete."
+ "Function annotated with @Delete but does not have any parameters to delete."
- fun cannotMapSpecifiedColumn(column: String, columnsInQuery: List<String>, annotation: String) =
- "Column specified in the provided @$annotation annotation must be present in the query. " +
- "Provided: $column. Columns found: ${columnsInQuery.joinToString(", ")}"
+ fun cannotMapSpecifiedColumn(column: String, columnsInQuery: List<String>) =
+ "Column specified in the declared @MapColumn annotation must be present in the query result. " +
+ "Declared column name: $column. Columns found: ${columnsInQuery.joinToString()}"
fun mayNeedMapColumn(columnArg: String): String {
return """
Looks like you may need to use @MapColumn to clarify the 'columnName' needed for
type argument(s) in the return type of a function. Type argument that needs
- @MapColumn: $columnArg
+ @MapColumn are: $columnArg
"""
.trim()
}
const val CANNOT_FIND_DELETE_RESULT_ADAPTER =
- "Not sure how to handle delete function's " +
- "return type. Currently the supported return types are void, int or Int."
+ "Not sure how to handle delete function's return type. " +
+ "Currently the supported return types are void / Unit or Int"
const val CANNOT_FIND_UPDATE_RESULT_ADAPTER =
- "Not sure how to handle update function's " +
- "return type. Currently the supported return types are void, int or Int."
+ "Not sure how to handle update function's return type. " +
+ "Currently the supported return types are void / Unit or Int."
fun suspendReturnsDeferredType(returnTypeName: String) =
- "Dao functions that have a suspend " +
- "modifier must not return a deferred/async type ($returnTypeName). Most probably this " +
- "is an error. Consider changing the return type or removing the suspend modifier."
+ "DAO functions that have a suspend " +
+ "modifier must not return a deferred / async type ($returnTypeName)." +
+ "Most probably this is an error. Consider changing the return type or removing the " +
+ "suspend modifier."
const val CANNOT_FIND_INSERT_RESULT_ADAPTER =
"Not sure how to handle insert function's return type. $DAO_RETURN_TYPE_CONVERTER_WARNING"
@@ -210,28 +240,27 @@
"Not sure how to handle upsert function's return type. $DAO_RETURN_TYPE_CONVERTER_WARNING"
const val INSERT_MULTI_PARAM_SINGLE_RETURN_MISMATCH =
- "Insert function accepts multiple parameters " +
- "but the return type is a single element. Try using a multiple element return type."
+ "Insert function accepts multiple parameters but the return type is a single element. " +
+ "Try using a multiple element return type."
const val UPSERT_MULTI_PARAM_SINGLE_RETURN_MISMATCH =
- "Upsert function accepts multiple parameters " +
- "but the return type is a single element. Try using a multiple element return type."
+ "Upsert function accepts multiple parameters but the return type is a single element. " +
+ "Try using a multiple element return type."
const val INSERT_SINGLE_PARAM_MULTI_RETURN_MISMATCH =
- "Insert function accepts a single parameter " +
- "but the return type is a collection of elements. Try using a single element return type."
+ "Insert function accepts a single parameter but the return type is a collection of " +
+ "elements. Try using a single element return type."
const val UPSERT_SINGLE_PARAM_MULTI_RETURN_MISMATCH =
- "Upsert function accepts a single parameter " +
- "but the return type is a collection of elements. Try using a single element return type."
+ "Upsert function accepts a single parameter but the return type is a collection of " +
+ "elements. Try using a single element return type."
const val UPDATE_MISSING_PARAMS =
- "Function annotated with" + " @Update but does not have any parameters to update."
+ "Function annotated with @Update but does not have any parameters to update."
const val TRANSACTION_FUNCTION_MODIFIERS =
- "Function annotated with @Transaction must not be " +
- "private, final, or abstract. It can be abstract only if the function is also" +
- " annotated with @Query."
+ "Function annotated with @Transaction must not be private, final, or abstract. " +
+ "It can be abstract only if the function is also annotated with @Query."
fun nullableParamInShortcutFunction(param: String) =
"Functions annotated with [@Insert, " +
@@ -239,13 +268,12 @@
fun transactionFunctionAsync(returnTypeName: String) =
"Function annotated with @Transaction must" +
- " not return deferred/async return type $returnTypeName. Since transactions are" +
+ " not return deferred / async return type $returnTypeName. Since transactions may be" +
" thread confined and Room cannot guarantee that all queries in the function" +
- " implementation are performed on the same thread, only synchronous @Transaction" +
- " implemented functions are allowed. If a transaction is started and a change of thread" +
- " is done and waited upon then a database deadlock can occur if the additional thread" +
- " attempts to perform a query. This restrictions prevents such situation from" +
- " occurring."
+ " implementation are performed on the same thread. If a transaction is started and" +
+ " a change of thread is done and waited upon then a database deadlock can occur if" +
+ " the additional thread attempts to perform a query. This restrictions prevents such" +
+ " situation from occurring."
const val TRANSACTION_MISSING_ON_RELATION =
"The return value includes a data class with a @Relation." +
@@ -255,14 +283,14 @@
" for details."
const val CANNOT_FIND_ENTITY_FOR_SHORTCUT_QUERY_PARAMETER =
- "Type of the parameter must be a class " +
- "annotated with @Entity or a collection/array of it."
+ "Type of the parameter must be a class annotated with @Entity or a collection / array of it."
val DB_MUST_EXTEND_ROOM_DB =
"Classes annotated with @Database should extend " + ROOM_DB.canonicalName
const val DAO_RETURN_TYPE_CONVERTER_MUST_HAVE_ONE_LAMBDA_PARAM_THAT_IS_SUSPEND =
- "DaoReturnTypeConverter functions must have exactly ONE lambda parameter, must be suspend and can have at most one parameter."
+ "DaoReturnTypeConverter functions must have exactly ONE lambda parameter, must be suspend " +
+ "and can have at most one parameter of type RoomRawQuery."
const val DAO_RETURN_TYPE_CONVERTER_ANNOTATION_MUST_HAVE_OPERATION_TYPE =
"A Dao Return Type Converter function annotated with `@DaoReturnTypeConverter` must specify the `OperationType` in the annotation."
@@ -274,37 +302,37 @@
"The lambda parameter of a DaoReturnTypeConverter function should be the last parameter."
const val DAO_RETURN_TYPE_CONVERTER_FUNCTIONS_WITHOUT_TYPE_PARAM_SHOULD_RETURN_UNIT =
- "DaoReturnTypeConverter functions without a type parameter should have a suspend lambda returning `Unit`."
+ "DaoReturnTypeConverter functions without a type parameter should have a suspend lambda " +
+ "returning Unit."
const val OBSERVABLE_QUERY_NOTHING_TO_OBSERVE =
- "Observable query return type (LiveData, Flowable" +
- ", DataSource, DataSourceFactory etc) can only be used with SELECT queries that" +
+ "Observable query return type (i.e. Flow) can only be used with SELECT queries that" +
" directly or indirectly (via @Relation, for example) access at least one table. For" +
" @RawQuery, you should specify the list of tables to be observed via the" +
- " observedEntities property."
+ " observedEntities annotation value."
const val RECURSIVE_REFERENCE_DETECTED =
- "Recursive referencing through @Embedded and/or @Relation " + "detected: %s"
+ "Recursive referencing through @Embedded or @Relation detected: %s"
private const val TOO_MANY_MATCHING_GETTERS =
- "Ambiguous getter for %s. All of the following " +
- "match: %s. You can @Ignore the ones that you don't want to match."
+ "Ambiguous getter for %s. All of the following match: %s. " +
+ "You can @Ignore the ones that you don't want to match."
fun tooManyMatchingGetters(property: Property, functionNames: List<String>): String {
- return TOO_MANY_MATCHING_GETTERS.format(property, functionNames.joinToString(", "))
+ return TOO_MANY_MATCHING_GETTERS.format(property, functionNames.joinToString())
}
private const val TOO_MANY_MATCHING_SETTERS =
- "Ambiguous setter for %s. All of the following " +
- "match: %s. You can @Ignore the ones that you don't want to match."
+ "Ambiguous setter for %s. All of the following match: %s. " +
+ "You can @Ignore the ones that you don't want to match."
fun tooManyMatchingSetter(property: Property, functionNames: List<String>): String {
- return TOO_MANY_MATCHING_SETTERS.format(property, functionNames.joinToString(", "))
+ return TOO_MANY_MATCHING_SETTERS.format(property, functionNames.joinToString())
}
const val CANNOT_FIND_COLUMN_TYPE_ADAPTER =
- "Cannot figure out how to save this property into" +
- " database. You can consider adding a type converter for it."
+ "Cannot figure out how to save this property into database. " +
+ "Consider also adding a @TypeConverter for the property type."
const val VALUE_CLASS_ONLY_SUPPORTED_IN_KSP =
"Kotlin value classes are only supported " +
@@ -323,57 +351,53 @@
" matching function parameter. Cannot find function parameters for %s."
fun missingParameterForBindVariable(bindVarName: List<String>): String {
- return MISSING_PARAMETER_FOR_BIND.format(bindVarName.joinToString(", "))
+ return MISSING_PARAMETER_FOR_BIND.format(bindVarName.joinToString())
}
- fun valueCollectionMustBeListOrSetOrMap(mapValueTypeName: String): String {
- return "Multimap 'value' collection type must be a List, Set or Map. " +
- "Found $mapValueTypeName."
- }
+ fun valueCollectionMustBeListOrSetOrMap(mapValueTypeName: String): String =
+ "Multimap 'value' collection type must be a List, Set or Map. " + "Found $mapValueTypeName."
private const val UNUSED_QUERY_FUNCTION_PARAMETER = "Unused parameter%s: %s"
fun unusedQueryFunctionParameter(unusedParams: List<String>): String {
return UNUSED_QUERY_FUNCTION_PARAMETER.format(
if (unusedParams.size > 1) "s" else "",
- unusedParams.joinToString(","),
+ unusedParams.joinToString(),
)
}
private const val DUPLICATE_TABLES_OR_VIEWS =
- "The name \"%s\" is used by multiple entities or views: %s"
+ "The name '%s' is used by multiple entities or views: %s"
fun duplicateTableNames(tableName: String, entityNames: List<String>): String {
- return DUPLICATE_TABLES_OR_VIEWS.format(tableName, entityNames.joinToString(", "))
+ return DUPLICATE_TABLES_OR_VIEWS.format(tableName, entityNames.joinToString())
}
const val DAO_FUNCTION_CONFLICTS_WITH_OTHERS = "Dao function has conflicts."
- fun duplicateDao(dao: String, functionNames: List<String>): String {
- return """
- All of these functions [${functionNames.joinToString(", ")}] return the same DAO
- class [$dao].
- A database can use a DAO only once so you should remove ${functionNames.size - 1} of
- these conflicting DAO functions. If you are implementing any of these to fulfill an
- interface, don't make it abstract, instead, implement the code that calls the
- other one.
- """
+ fun duplicateDao(dao: String, functionNames: List<String>): String =
+ """
+ The following functions: [${functionNames.joinToString()}] return
+ the same DAO class ($dao).
+ A database can use a DAO only once so you should remove ${functionNames.size - 1} of
+ these conflicting DAO functions. If you are implementing any of these to fulfill an
+ interface, don't make it abstract, instead, implement the code that calls the
+ other one.
+ """
.trim()
- }
fun dataClassMissingNonNull(
dataClassTypeName: String,
missingDataClassProperties: List<String>,
allQueryColumns: List<String>,
- ): String {
- return """
+ ) =
+ """
The columns returned by the query does not have the properties
- [${missingDataClassProperties.joinToString(",")}] in $dataClassTypeName even though they are
- annotated as non-null or primitive.
- Columns returned by the query: [${allQueryColumns.joinToString(",")}]
+ [${missingDataClassProperties.joinToString()}] in $dataClassTypeName even
+ though they are annotated as non-null or primitive.
+ Columns returned by the query: [${allQueryColumns.joinToString()}]
"""
.trim()
- }
fun queryPropertyDataClassMismatch(
dataClassTypeNames: List<String>,
@@ -385,16 +409,16 @@
if (unusedColumns.isNotEmpty()) {
val dataClassNames =
if (dataClassTypeNames.size > 1) {
- "any of [${dataClassTypeNames.joinToString(", ")}]"
+ "any of [${dataClassTypeNames.joinToString()}]"
} else {
dataClassTypeNames.single()
}
"""
- The query returns some columns [${unusedColumns.joinToString(", ")}] which are not
+ The query returns some columns [${unusedColumns.joinToString()}] which are not
used by $dataClassNames. You can use @ColumnInfo annotation on the properties to specify
the mapping.
- You can annotate the function with @RewriteQueriesToDropUnusedColumns to direct Room
- to rewrite your query to avoid fetching unused columns.
+ You can also annotate the function with @RewriteQueriesToDropUnusedColumns to direct
+ Room to rewrite your query to avoid fetching unused columns.
"""
.trim()
} else {
@@ -404,7 +428,7 @@
dataClassUnusedProperties.map { (dataClassName, unusedProperties) ->
"""
$dataClassName has some properties
- [${unusedProperties.joinToString(", ") { it.columnName }}] which are not returned by
+ [${unusedProperties.joinToString() { it.columnName }}] which are not returned by
the query. If they are not supposed to be read from the result, you can mark them
with @Ignore annotation.
"""
@@ -415,126 +439,118 @@
${unusedPropertiesWarning.joinToString(separator = " ")}
You can suppress this warning by annotating the function with
@SuppressWarnings(RoomWarnings.QUERY_MISMATCH).
- Columns returned by the query: ${allColumns.joinToString(", ")}.
+ Columns returned by the query: ${allColumns.joinToString()}.
"""
.trim()
}
- const val TYPE_CONVERTER_UNBOUND_GENERIC = "Cannot use unbound generics in Type Converters."
+ const val TYPE_CONVERTER_UNBOUND_GENERIC = "Cannot use unbound generics in type converters."
+
const val TYPE_CONVERTER_BAD_RETURN_TYPE = "Invalid return type for a type converter."
+
const val DAO_RETURN_TYPE_CONVERTER_BAD_RETURN_TYPE =
"Invalid return type for a DAO return type converter."
+
const val TYPE_CONVERTER_MUST_RECEIVE_1_PARAM = "Type converters must receive 1 parameter."
+
const val TYPE_CONVERTER_EMPTY_CLASS =
- "Class is referenced as a converter but it does not have any" + " converter functions."
+ "Class is referenced as a converter but it does not have any converter functions."
+
const val DAO_RETURN_TYPE_CONVERTER_EMPTY_CLASS =
"Class is referenced as a DAO return type converter but it does not have any member functions."
+
const val DAO_RETURN_TYPE_CONVERTER_MUST_CONTAIN_AN_ANNOTATED_FUNCTION =
- "A Dao Return Type Converter must contain at least 1 function annotated with `@DaoReturnTypeConverter`."
+ "A DAO return type converter must contain at least 1 function annotated with " +
+ "`@DaoReturnTypeConverter`."
+
const val DAO_RETURN_TYPE_CONVERTER_FUNCTIONS_MUST_HAVE_AT_MOST_ONE_TYPE_PARAMETER =
- "Dao Return Type Converter functions can have at most 1 type parameter."
+ "DAO return type converter functions can have at most 1 type parameter."
fun daoReturnTypeConverterFunctionsWithATypeParamShouldHaveReturnTypeContainingTheSameTypeArg(
functionArg: String,
returnArgs: String,
- ): String {
- return "Dao Return Type Converter functions with a type parameter should have a return " +
- "type that contains that generic type argument. Found function with type " +
+ ): String =
+ "DAO return type converter functions with a type parameter should have a return " +
+ "type that contains that same name generic type argument. Found function with type " +
"parameter [$functionArg], found return type with type argument(s) [${returnArgs}]."
- }
- const val DAO_RETURN_TYPE_CONVERTER_FUNCTIONS_WITH_A_TYPE_PARAM_SHOULD_HAVE_RETURN_TYPE_WITH_ONLY_ONE_GENERIC_ARG =
- "Dao Return Type Converter functions with a type parameter should have a return type that contains only one generic type argument. The converter functions also cannot contain more than one instance of the same generic type argument, e.g. Foo<T,T>."
+ const val DAO_RETURN_TYPE_CONVERTER_FUNCTIONS_TYPE_PARAM_MISMATCH =
+ "DAO return type converter functions with a type parameter should have a return type " +
+ "that contains only one generic type argument. The converter functions also cannot " +
+ "contain more than one instance of the same generic type argument, e.g. Foo<T,T>."
+
const val TYPE_CONVERTER_MISSING_NOARG_CONSTRUCTOR =
- "Classes that are used as TypeConverters must" +
- " have no-argument public constructors. Use a ProvidedTypeConverter annotation if you" +
- " need to take control over creating an instance of a TypeConverter."
- const val TYPE_CONVERTER_MUST_BE_PUBLIC = "Type converters must be public."
+ "Classes that are used in @TypeConverters must" +
+ " have no-argument public constructors. Use a @ProvidedTypeConverter annotation if you" +
+ " need to take control over creating an instance of the type converter class"
+
+ const val TYPE_CONVERTER_MUST_BE_PUBLIC = "@TypeConverter function must be public or internal"
+
const val DAO_RETURN_TYPE_CONVERTER_MUST_BE_PUBLIC =
- "DAO return type converters must be public."
+ "@DaoReturnTypeConverter function must be public or internal."
+
const val INNER_CLASS_TYPE_CONVERTER_MUST_BE_STATIC =
- "An inner class TypeConverter must be " + "static."
+ "An inner @TypeConverters class must be static."
const val INNER_CLASS_DAO_RETURN_TYPE_CONVERTER_MUST_BE_STATIC =
- "An inner class DaoReturnTypeConverter must be static."
+ "An inner @DaoReturnTypeConverters class must be static."
- fun duplicateTypeConverters(converters: List<CustomTypeConverter>): String {
- return "Multiple functions define the same conversion. Conflicts with these:" +
- " ${converters.joinToString(", ") { it.className.toString() }}"
- }
+ fun duplicateTypeConverters(converters: List<String>) =
+ "Multiple @TypeConverter functions define the same conversion. Conflicts with these:" +
+ " ${converters.joinToString()}"
- fun duplicateDaoReturnTypeConverters(converters: List<CustomDaoReturnTypeConverter>): String {
- return "Multiple DaoReturnTypeConverters found for the same conversion. Conflicts with these:" +
- " ${converters.joinToString(", ") { it.toString() }}"
- }
+ fun duplicateDaoReturnTypeConverters(converters: List<String>) =
+ "Multiple @DaoReturnTypeConverter define the same conversion. Conflicts with these:" +
+ " ${converters.joinToString()}"
- fun typeConverterMustBeDeclared(typeName: String): String {
- return "Invalid type converter type: $typeName. Type converters must be a class."
- }
+ fun typeConverterMustBeDeclared(typeName: String) =
+ "Invalid type converter type: $typeName. Type converters must be a class."
- // TODO must print property paths.
- const val DATA_CLASS_PROPERTY_HAS_DUPLICATE_COLUMN_NAME = "Property has non-unique column name."
+ fun dataClassDuplicatePropertyNames(columnName: String, propertyPaths: List<String>) =
+ "Multiple properties have the same columnName: $columnName." +
+ " Property names: ${propertyPaths.joinToString()}."
- fun dataClassDuplicatePropertyNames(columnName: String, propertyPaths: List<String>): String {
- return "Multiple properties have the same columnName: $columnName." +
- " Property names: ${propertyPaths.joinToString(", ")}."
- }
-
- fun embeddedPrimaryKeyIsDropped(entityQName: String, propertyName: String): String {
- return "Primary key constraint on $propertyName is ignored when being merged into " +
- entityQName
- }
+ fun embeddedPrimaryKeyIsDropped(entityQName: String, propertyName: String) =
+ "Primary key constraint on $propertyName is ignored when being merged into $entityQName"
const val INDEX_COLUMNS_CANNOT_BE_EMPTY = "List of columns in an index cannot be empty"
- fun indexColumnDoesNotExist(columnName: String, allColumns: List<String>): String {
- return "$columnName referenced in the index does not exist in the Entity." +
- " Available column names:${allColumns.joinToString(", ")}"
- }
+ fun indexColumnDoesNotExist(columnName: String, allColumns: List<String>) =
+ "$columnName referenced in the index does not exist in the Entity." +
+ " Available column names:${allColumns.joinToString()}"
- fun duplicateIndexInEntity(indexName: String): String {
- return "There are multiple indices with name $indexName. This happen if you've declared" +
+ fun duplicateIndexInEntity(indexName: String) =
+ "There are multiple indices with name $indexName. This can happen if you've declared" +
" the same index multiple times or different indices have the same name. See" +
" @Index documentation for details."
- }
- fun duplicateIndexInDatabase(indexName: String, indexPaths: List<String>): String {
- return "There are multiple indices with name $indexName. You should rename " +
+ fun duplicateIndexInDatabase(indexName: String, indexPaths: List<String>) =
+ "There are multiple indices with name $indexName. You should rename " +
"${indexPaths.size - 1} of these to avoid the conflict:" +
- "${indexPaths.joinToString(", ")}."
- }
+ "${indexPaths.joinToString()}."
- fun droppedEmbeddedPropertyIndex(propertyPath: String, grandParent: String): String {
- return "The index will be dropped when being merged into $grandParent" +
+ fun droppedEmbeddedPropertyIndex(propertyPath: String, grandParent: String) =
+ "The index will be dropped when being merged into $grandParent" +
"($propertyPath). You must re-declare it in $grandParent if you want to index this" +
" property in $grandParent."
- }
- fun droppedEmbeddedIndex(
- entityName: String,
- propertyPath: String,
- grandParent: String,
- ): String {
- return "Indices defined in $entityName will be dropped when it is merged into" +
+ fun droppedEmbeddedIndex(entityName: String, propertyPath: String, grandParent: String) =
+ "Indices defined in $entityName will be dropped when it is merged into" +
" $grandParent ($propertyPath). You can re-declare them in $grandParent."
- }
- fun droppedSuperClassIndex(childEntity: String, superEntity: String): String {
- return "Indices defined in $superEntity will NOT be re-used in $childEntity. If you want" +
+ fun droppedSuperClassIndex(childEntity: String, superEntity: String) =
+ "Indices defined in $superEntity will NOT be re-used in $childEntity. If you want" +
" to inherit them, you must re-declare them in $childEntity." +
" Alternatively, you can set inheritSuperIndices to true in the @Entity annotation."
- }
fun droppedSuperClassPropertyIndex(
propertyName: String,
childEntity: String,
superEntity: String,
- ): String {
- return "Index defined on property `$propertyName` in $superEntity will NOT be re-used in" +
- " $childEntity. " +
- "If you want to inherit it, you must re-declare it in $childEntity." +
+ ) =
+ "Index defined on property `$propertyName` in $superEntity will NOT be re-used in" +
+ " $childEntity. If you want to inherit it, you must re-declare it in $childEntity." +
" Alternatively, you can set inheritSuperIndices to true in the @Entity annotation."
- }
const val NOT_ENTITY_OR_VIEW = "The class must be either @Entity or @DatabaseView."
@@ -542,37 +558,33 @@
entityName: String,
columnName: String,
availableColumns: List<String>,
- ): String {
- return "Cannot find the child entity column `$columnName` in $entityName." +
- " Options: ${availableColumns.joinToString(", ")}"
- }
+ ) =
+ "Cannot find the child entity column `$columnName` in $entityName." +
+ " Available columns are: ${availableColumns.joinToString()}"
fun relationCannotFindParentEntityProperty(
entityName: String,
columnName: String,
availableColumns: List<String>,
- ): String {
- return "Cannot find the parent entity column `$columnName` in $entityName." +
- " Options: ${availableColumns.joinToString(", ")}"
- }
+ ) =
+ "Cannot find the parent entity column `$columnName` in $entityName." +
+ " Available columns are: ${availableColumns.joinToString()}"
fun relationCannotFindJunctionEntityProperty(
entityName: String,
columnName: String,
availableColumns: List<String>,
- ): String {
- return "Cannot find the child entity referencing column `$columnName` in the junction " +
- "$entityName. Options: ${availableColumns.joinToString(", ")}"
- }
+ ) =
+ "Cannot find the child entity referencing column `$columnName` in the junction " +
+ "$entityName. Available columns are: ${availableColumns.joinToString()}"
fun relationCannotFindJunctionParentProperty(
entityName: String,
columnName: String,
availableColumns: List<String>,
- ): String {
- return "Cannot find the parent entity referencing column `$columnName` in the junction " +
- "$entityName. Options: ${availableColumns.joinToString(", ")}"
- }
+ ) =
+ "Cannot find the parent entity referencing column `$columnName` in the junction " +
+ "$entityName. Options: ${availableColumns.joinToString()}"
fun junctionColumnWithoutIndex(entityName: String, columnName: String) =
"The column $columnName in the junction entity $entityName is being used to resolve " +
@@ -587,95 +599,85 @@
childColumn: String,
parentAffinity: SQLTypeAffinity?,
childAffinity: SQLTypeAffinity?,
- ): String {
- return """
+ ) =
+ """
The affinity of parent column ($parentColumn : $parentAffinity) does not match the type
affinity of the child column ($childColumn : $childAffinity).
"""
.trim()
- }
fun relationJunctionParentAffinityMismatch(
parentColumn: String,
junctionParentColumn: String,
parentAffinity: SQLTypeAffinity?,
junctionParentAffinity: SQLTypeAffinity?,
- ): String {
- return """
+ ) =
+ """
The affinity of parent column ($parentColumn : $parentAffinity) does not match the type
affinity of the junction parent column ($junctionParentColumn : $junctionParentAffinity).
"""
.trim()
- }
fun relationJunctionChildAffinityMismatch(
childColumn: String,
junctionChildColumn: String,
childAffinity: SQLTypeAffinity?,
junctionChildAffinity: SQLTypeAffinity?,
- ): String {
- return """
+ ) =
+ """
The affinity of child column ($childColumn : $childAffinity) does not match the type
affinity of the junction child column ($junctionChildColumn : $junctionChildAffinity).
"""
.trim()
- }
val CANNOT_USE_MORE_THAN_ONE_DATA_CLASS_PROPERTY_ANNOTATION =
- "A property can be annotated with only" +
- " one of the following:" +
- DataClassProcessor.PROCESSED_ANNOTATIONS.joinToString(",") { it.java.simpleName }
+ "A property can be annotated with only one of the following:" +
+ DataClassProcessor.PROCESSED_ANNOTATIONS.joinToString() { it.java.simpleName }
- fun missingIgnoredColumns(missingIgnoredColumns: List<String>): String {
- return "Non-existent columns are specified to be ignored in ignoreColumns: " +
- missingIgnoredColumns.joinToString(",")
- }
+ fun missingIgnoredColumns(missingIgnoredColumns: List<String>) =
+ "Non-existent columns are specified to be ignored in the ignoreColumns annotation value: " +
+ missingIgnoredColumns.joinToString()
fun relationBadProject(
entityQName: String,
missingColumnNames: List<String>,
availableColumnNames: List<String>,
- ): String {
- return """
- $entityQName does not have the following columns: ${missingColumnNames.joinToString(",")}.
- Available columns are: ${availableColumnNames.joinToString(",")}
+ ) =
+ """
+ $entityQName does not have the following columns: ${missingColumnNames.joinToString()}.
+ Available columns are: ${availableColumnNames.joinToString()}
"""
.trim()
- }
const val MISSING_SCHEMA_EXPORT_DIRECTORY =
"Schema export directory was not provided to the" +
" annotation processor so Room cannot export the schema. You can either provide" +
" `room.schemaLocation` annotation processor argument by applying the Room Gradle plugin" +
- " (id 'androidx.room') OR set exportSchema to false."
+ " (id 'androidx.room3') OR set exportSchema to false."
const val INVALID_FOREIGN_KEY_ACTION =
- "Invalid foreign key action. It must be one of the constants" +
- " defined in ForeignKey.Action"
+ "Invalid foreign key action. It must be one of the constants defined in @ForeignKey.Action"
- fun foreignKeyNotAnEntity(className: String): String {
- return """
+ fun foreignKeyNotAnEntity(className: String) =
+ """
Classes referenced in Foreign Key annotations must be @Entity classes. $className is not
- an entity
+ an entity.
"""
.trim()
- }
const val FOREIGN_KEY_CANNOT_FIND_PARENT = "Cannot find parent entity class."
- fun foreignKeyChildColumnDoesNotExist(columnName: String, allColumns: List<String>): String {
- return "($columnName) referenced in the foreign key does not exist in the Entity." +
- " Available column names:${allColumns.joinToString(", ")}"
- }
+ fun foreignKeyChildColumnDoesNotExist(columnName: String, allColumns: List<String>) =
+ "The column $columnName referenced in the foreign key does not exist in the Entity." +
+ " Available column names: ${allColumns.joinToString()}"
fun foreignKeyParentColumnDoesNotExist(
parentEntity: String,
missingColumn: String,
allColumns: List<String>,
- ): String {
- return "($missingColumn) does not exist in $parentEntity. Available columns are" +
- " ${allColumns.joinToString(",")}"
- }
+ ) =
+ "The column $missingColumn does not exist in $parentEntity. Available columns are: " +
+ allColumns.joinToString()
const val FOREIGN_KEY_EMPTY_CHILD_COLUMN_LIST =
"Must specify at least 1 column name for the child"
@@ -683,69 +685,61 @@
const val FOREIGN_KEY_EMPTY_PARENT_COLUMN_LIST =
"Must specify at least 1 column name for the parent"
- fun foreignKeyColumnNumberMismatch(
- childColumns: List<String>,
- parentColumns: List<String>,
- ): String {
- return """
- Number of child columns in foreign key must match number of parent columns.
- Child reference has ${childColumns.joinToString(",")} and parent reference has
- ${parentColumns.joinToString(",")}
- """
+ fun foreignKeyColumnNumberMismatch(childColumns: List<String>, parentColumns: List<String>) =
+ """
+ Number of child columns in foreign key must match number of parent columns.
+ Child reference has ${childColumns.joinToString()} and parent reference has
+ ${parentColumns.joinToString()}
+ """
.trim()
- }
- fun foreignKeyMissingParentEntityInDatabase(parentTable: String, childEntity: String): String {
- return """
- $parentTable table referenced in the foreign keys of $childEntity does not exist in
- the database. Maybe you forgot to add the referenced entity in the entities list of
- the @Database annotation?"""
+ fun foreignKeyMissingParentEntityInDatabase(parentTable: String, childEntity: String) =
+ """
+ $parentTable table referenced in the foreign keys of $childEntity does not exist in
+ the database. Maybe you forgot to add the referenced entity in the entities list of
+ the @Database annotation?
+ """
.trim()
- }
fun foreignKeyMissingIndexInParent(
parentEntity: String,
parentColumns: List<String>,
childEntity: String,
childColumns: List<String>,
- ): String {
- return """
- $childEntity has a foreign key (${childColumns.joinToString(",")}) that references
- $parentEntity (${parentColumns.joinToString(",")}) but $parentEntity does not have
- a unique index on those columns nor the columns are its primary key.
- SQLite requires having a unique constraint on referenced parent columns so you must
- add a unique index to $parentEntity that has
- (${parentColumns.joinToString(",")}) column(s).
- """
+ ) =
+ """
+ $childEntity has a foreign key (${childColumns.joinToString()}) that references
+ $parentEntity (${parentColumns.joinToString()}) but $parentEntity does not have
+ a unique index on those columns nor the columns are its primary key.
+ SQLite requires having a unique constraint on referenced parent columns so you must
+ add a unique index to $parentEntity that has
+ (${parentColumns.joinToString()}) column(s).
+ """
.trim()
- }
- fun foreignKeyMissingIndexInChildColumns(childColumns: List<String>): String {
- return """
- (${childColumns.joinToString(",")}) column(s) reference a foreign key but
- they are not part of an index. This may trigger full table scans whenever parent
- table is modified so you are highly advised to create an index that covers these
- columns.
- """
+ fun foreignKeyMissingIndexInChildColumns(childColumns: List<String>) =
+ """
+ (${childColumns.joinToString()}) column(s) reference a foreign key but
+ they are not part of an index. This may trigger full table scans whenever parent
+ table is modified so you are highly advised to create an index that covers these
+ columns.
+ """
.trim()
- }
- fun foreignKeyMissingIndexInChildColumn(childColumn: String): String {
- return """
- $childColumn column references a foreign key but it is not part of an index. This
- may trigger full table scans whenever parent table is modified so you are highly
- advised to create an index that covers this column.
- """
+ fun foreignKeyMissingIndexInChildColumn(childColumn: String) =
+ """
+ $childColumn column references a foreign key but it is not part of an index. This
+ may trigger full table scans whenever parent table is modified so you are highly
+ advised to create an index that covers this column.
+ """
.trim()
- }
- fun shortcutEntityIsNotInDatabase(database: String, dao: String, entity: String): String {
- return """
- $dao is part of $database but this entity is not in the database. Maybe you forgot
- to add $entity to the entities section of the @Database?
- """
+ fun shortcutEntityIsNotInDatabase(database: String, dao: String, entity: String) =
+ """
+ $dao is part of $database but this entity is not in the database. Maybe you forgot
+ to add $entity to the entities section of the @Database?
+ """
.trim()
- }
const val MISSING_ROOM_GUAVA_ARTIFACT =
"To use Guava features, you must add `guava`" +
@@ -755,73 +749,47 @@
"To use RxJava3 features, you must add `rxjava3`" +
" artifact from Room as a dependency. androidx.room3:room3-rxjava3:<version>"
- const val MISSING_ROOM_PAGING_ARTIFACT =
- "To use PagingSource, you must add `room-paging`" +
- " artifact from Room as a dependency. androidx.room3:room3-paging:<version>"
-
- const val MISSING_ROOM_PAGING_GUAVA_ARTIFACT =
- "To use ListenableFuturePagingSource, you must " +
- "add `room-paging-guava` artifact from Room as a dependency. " +
- "androidx.room3:room3-paging-guava:<version>"
-
- const val MISSING_ROOM_PAGING_RXJAVA3_ARTIFACT =
- "To use RxPagingSource, you must " +
- "add `room-paging-rxjava3` artifact from Room as a dependency. " +
- "androidx.room3:room3-paging-rxjava3:<version>"
-
fun ambiguousConstructor(
dataClass: String,
paramName: String,
matchingProperties: List<String>,
- ): String {
- return """
- Ambiguous constructor. The parameter ($paramName) in $dataClass matches multiple properties:
- [${matchingProperties.joinToString(",")}]. If you don't want to use this constructor,
- you can annotate it with @Ignore. If you want Room to use this constructor, you can
- rename the parameters to exactly match the property name to fix the ambiguity.
- """
+ ) =
+ """
+ Ambiguous constructor. The parameter $paramName in $dataClass matches multiple properties:
+ [${matchingProperties.joinToString()}]. If you don't want to use this constructor,
+ you can annotate it with @Ignore. If you want Room to use this constructor, you can
+ rename the parameters to exactly match the property name to fix the ambiguity.
+ """
.trim()
- }
val MISSING_DATA_CLASS_CONSTRUCTOR =
"""
- Entities and data classes must have a usable public constructor. You can have an empty
- constructor or a constructor whose parameters match the properties (by name and type).
- """
+ Entities and data classes must have a usable public constructor. You can have an empty
+ constructor or a constructor whose parameters match the properties (by name and type).
+ """
.trim()
val TOO_MANY_DATA_CLASS_CONSTRUCTORS =
"""
- Room cannot pick a constructor since multiple constructors are suitable. Try to annotate
- unwanted constructors with @Ignore.
- """
+ Room cannot pick a constructor since multiple constructors are suitable. Try to annotate
+ unwanted constructors with @Ignore.
+ """
.trim()
val TOO_MANY_DATA_CLASS_CONSTRUCTORS_CHOOSING_NO_ARG =
"""
- There are multiple good constructors and Room will pick the no-arg constructor.
- You can use the @Ignore annotation to eliminate unwanted constructors.
- """
+ There are multiple good constructors and Room will pick the no-arg constructor.
+ You can use the @Ignore annotation to eliminate unwanted constructors.
+ """
.trim()
- const val PAGING_SPECIFY_DATA_SOURCE_TYPE =
- "For now, Room only supports PositionalDataSource class."
-
- const val PAGING_SPECIFY_PAGING_SOURCE_TYPE =
- "For now, Room only supports PagingSource with Key of" + " type Int."
-
- const val PAGING_SPECIFY_PAGING_SOURCE_VALUE_TYPE =
- "For now, Room only supports PagingSource with" + " Value that is not of Collection type."
-
- fun primaryKeyNull(property: String): String {
- return "You must annotate primary keys with @NonNull. \"$property\" is nullable. SQLite " +
- "considers this a " +
- "bug and Room does not allow it. See SQLite docs for details: " +
+ fun primaryKeyNull(property: String) =
+ "Primary keys cannot be nullable yet \"$property\" is nullable. SQLite " +
+ "considers this a bug and Room does not allow it. See SQLite docs for details: " +
"https://www.sqlite.org/lang_createtable.html"
- }
const val INVALID_COLUMN_NAME =
- "Invalid column name. Room does not allow using ` or \" in column" + " names"
+ "Invalid column name. Room does not allow using ` or \" in column names"
const val INVALID_TABLE_NAME =
"Invalid table name. Room does not allow using ` or \" in table names"
@@ -830,37 +798,33 @@
"@RawQuery functions should have 1 and only 1 parameter with type RoomRawQuery"
fun parameterCannotBeNullable(parameterName: String) =
+ "Parameter `$parameterName` cannot be nullable."
+
+ const val RAW_QUERY_BAD_RETURN_TYPE =
+ "@RawQuery functions must return a non-void / non-Unit type."
+
+ fun rawQueryBadEntity(typeName: String) =
"""
- Parameter `$parameterName` cannot be nullable.
- """
- .trimIndent()
-
- const val RAW_QUERY_BAD_RETURN_TYPE = "RawQuery functions must return a non-void type."
-
- fun rawQueryBadEntity(typeName: String): String {
- return """
- observedEntities property in RawQuery must either reference a class that is annotated
- with @Entity or it should reference a data class that either contains
- @Embedded properties that are annotated with @Entity or @Relation properties.
- $typeName does not have these properties, did you mean another class?
- """
+ The observedEntities annotation value in @RawQuery must either reference a class that is
+ annotated with @Entity or it should reference a data class that either contains
+ @Embedded properties that are annotated with @Entity or @Relation properties.
+ $typeName does not have these properties, did you mean to use another class?
+ """
.trim()
- }
val RAW_QUERY_STRING_PARAMETER_REMOVED =
"@RawQuery does not allow passing a string anymore." +
" Please use ${RoomTypeNames.RAW_QUERY.canonicalName}."
const val MISSING_COPY_ANNOTATIONS =
- "Annotated property getter is missing " + "@AutoValue.CopyAnnotations."
+ "Annotated property getter is missing @AutoValue.CopyAnnotations."
- fun invalidAnnotationTarget(annotationName: String, elementKindName: String): String {
- return "@$annotationName is not allowed in this $elementKindName."
- }
+ fun invalidAnnotationTarget(annotationName: String, elementKindName: String) =
+ "@$annotationName is not allowed in this $elementKindName."
- const val INDICES_IN_FTS_ENTITY = "Indices not allowed in FTS Entity."
+ const val INDICES_IN_FTS_ENTITY = "Indices not allowed in FTS entities."
- const val FOREIGN_KEYS_IN_FTS_ENTITY = "Foreign Keys not allowed in FTS Entity."
+ const val FOREIGN_KEYS_IN_FTS_ENTITY = "Foreign Keys not allowed in FTS entities."
const val MISSING_PRIMARY_KEYS_ANNOTATION_IN_ROW_ID =
"The property with column name 'rowid' in " +
@@ -878,14 +842,14 @@
"FTS entity must be of INTEGER affinity."
fun missingLanguageIdProperty(columnName: String) =
- "The specified 'languageid' column: \"$columnName\", was not found."
+ "The specified 'languageid' column: $columnName, was not found."
const val INVALID_FTS_ENTITY_LANGUAGE_ID_AFFINITY =
"The 'languageid' property must be of INTEGER " + "affinity."
fun missingNotIndexedProperty(missingNotIndexedColumns: List<String>) =
"Non-existent columns are specified to be not indexed in notIndexed: " +
- missingNotIndexedColumns.joinToString(",")
+ missingNotIndexedColumns.joinToString()
const val INVALID_FTS_ENTITY_PREFIX_SIZES =
"Prefix sizes to index must non-zero positive values."
@@ -916,14 +880,14 @@
"the property then you can annotate it with @Ignore."
const val INVALID_TARGET_ENTITY_IN_SHORTCUT_FUNCTION =
- "Target entity declared in @Insert, @Update " + "or @Delete must be annotated with @Entity."
+ "Target entity declared in @Insert, @Update or @Delete must be annotated with @Entity."
const val INVALID_RELATION_IN_PARTIAL_ENTITY = "Partial entities cannot have relations."
fun invalidQueryForSingleColumnArray(returnType: String) =
"If a DAO function has a " +
"primitive array or an array of String return type, a single column must be returned. " +
- "Please check the query of the DAO function with the `$returnType` return type."
+ "Please check the query of the DAO function with the '$returnType' return type."
fun missingPrimaryKeysInPartialEntityForInsert(
partialEntityName: String,
@@ -964,33 +928,30 @@
StringBuilder()
.apply {
append("Not sure how to handle query function's return type ($returnType). ")
- if (type == QueryType.INSERT) {
- append(
- "INSERT query functions must either return void " +
- "or long (the rowid of the inserted row)."
- )
- } else if (type == QueryType.UPDATE) {
- append(
- "UPDATE query functions must either return void " +
- "or int (the number of updated rows)."
- )
- } else if (type == QueryType.DELETE) {
- append(
- "DELETE query functions must either return void " +
- "or int (the number of deleted rows)."
- )
- } else {
- append(DAO_RETURN_TYPE_CONVERTER_WARNING)
+ when (type) {
+ QueryType.INSERT ->
+ append(
+ "INSERT query functions must either return void / Unit " +
+ "or Long (the rowid of the inserted row)."
+ )
+ QueryType.UPDATE ->
+ append(
+ "UPDATE query functions must either return void / Unit " +
+ "or Int (the number of updated rows)."
+ )
+ QueryType.DELETE ->
+ append(
+ "DELETE query functions must either return void / Unit " +
+ "or Int (the number of deleted rows)."
+ )
+
+ else -> {}
}
+ append(" ")
+ append(DAO_RETURN_TYPE_CONVERTER_WARNING)
}
.toString()
- val JDK_VERSION_HAS_BUG =
- "Current JDK version ${System.getProperty("java.runtime.version") ?: ""} has a bug" +
- " (https://bugs.openjdk.java.net/browse/JDK-8007720)" +
- " that prevents Room from being incremental." +
- " Consider using JDK 11+ or the embedded JDK shipped with Android Studio 3.5+."
-
fun invalidChannelType(typeName: String) =
"'$typeName' is not supported as a return type. " +
"Instead declare return type as ${KotlinTypeNames.FLOW} and use Flow transforming " +
@@ -1023,36 +984,27 @@
.trim()
const val DATABASE_INVALID_DAO_FUNCTION_RETURN_TYPE =
- "Abstract database functions must return a @Dao " + "annotated class or interface."
+ "Abstract database functions must return a @Dao annotated class or interface."
- fun invalidEntityTypeInDatabaseAnnotation(typeName: String): String {
- return "Invalid Entity type: $typeName. An entity in the database must be a class."
- }
+ fun invalidEntityTypeInDatabaseAnnotation(typeName: String) =
+ "Invalid entity type: $typeName. An entity in the database must be a class."
- fun invalidViewTypeInDatabaseAnnotation(typeName: String): String {
- return "Invalid View type: $typeName. Views in a database must be a class or an " +
- "interface."
- }
-
- fun invalidAutoMigrationTypeInDatabaseAnnotation(): String {
- return "Invalid AutoMigration type: An auto migration in the database must be " +
- "an @AutoMigration annotation."
- }
+ fun invalidViewTypeInDatabaseAnnotation(typeName: String) =
+ "Invalid View type: $typeName. Views in a database must be a class or an interface."
const val EMBEDDED_TYPES_MUST_BE_A_CLASS_OR_INTERFACE =
- "The type of an Embedded property must be a " + "class or an interface."
+ "The type of an @Embedded property must be a class or an interface."
+
const val RELATION_TYPE_MUST_BE_A_CLASS_OR_INTERFACE =
- "Entity type in a Relation must be a class " + "or an interface."
+ "Entity type in a @Relation must be a class or an interface."
- fun shortcutFunctionArgumentMustBeAClass(typeName: String): String {
- return "Invalid query argument: $typeName. It must be a class or an interface."
- }
+ fun shortcutFunctionArgumentMustBeAClass(typeName: String) =
+ "Invalid query argument: $typeName. It must be a class or an interface."
- const val AUTOMIGRATION_SPEC_MUST_BE_CLASS = "The AutoMigration spec " + "type must be a class."
+ const val AUTOMIGRATION_SPEC_MUST_BE_CLASS = "The @AutoMigration spec type must be a class."
- fun autoMigrationElementMustImplementSpec(spec: String): String {
- return "The AutoMigration spec $spec must implement the AutoMigrationSpec interface."
- }
+ fun autoMigrationElementMustImplementSpec(spec: String) =
+ "The @AutoMigration spec $spec must implement the AutoMigrationSpec interface."
// TODO: (b/180389433) If the files don't exist the getSchemaFile() function should return
// null and before calling process
@@ -1064,42 +1016,27 @@
" be greater than the From version."
}
- fun autoMigrationSchemasNotFound(schemaVersion: Int, schemaOutFolderPath: String): String {
- return "Schema '$schemaVersion.json' required for migration was not found at the schema " +
+ fun autoMigrationSchemasNotFound(schemaVersion: Int, schemaOutFolderPath: String) =
+ "Schema '$schemaVersion.json' required for migration was not found at the schema " +
"out folder: $schemaOutFolderPath. Cannot generate auto migrations."
- }
- fun invalidAutoMigrationSchema(schemaVersion: Int, schemaOutFolderPath: String): String {
- return "Found invalid schema file '$schemaVersion.json' at the schema out " +
+ fun invalidAutoMigrationSchema(schemaVersion: Int, schemaOutFolderPath: String) =
+ "Found invalid schema file '$schemaVersion.json' at the schema out " +
"folder: $schemaOutFolderPath.\nIf you've modified the file, you might've broken the " +
"JSON format, try deleting the file and re-running the compiler.\n" +
"If you've not modified the file, please file a bug at " +
"https://issuetracker.google.com/issues/new?component=413107&template=1096568 " +
"with a sample app to reproduce the issue."
- }
- fun newNotNullColumnMustHaveDefaultValue(columnName: String): String {
- return "New NOT NULL " +
- "column'$columnName' " +
- "added with no default value specified. Please specify the default value using " +
- "@ColumnInfo."
- }
+ fun newNotNullColumnMustHaveDefaultValue(columnName: String) =
+ "New NOT NULL column'$columnName' added with no default value specified." +
+ "Please specify the default value using @ColumnInfo."
- fun columnWithChangedSchemaFound(columnName: String): String {
- return "Encountered column '$columnName' with an unsupported schema change at the column " +
- "level (e.g. affinity change). These changes are not yet " +
- "supported by AutoMigration."
- }
-
- fun deletedOrRenamedColumnFound(
- className: String?,
- columnName: String,
- tableName: String,
- ): String {
- return if (className != null) {
+ fun deletedOrRenamedColumnFound(className: String?, columnName: String, tableName: String) =
+ if (className != null) {
"""
- AutoMigration Failure in ‘$className’: Column ‘$columnName’ in table ‘$tableName’ has
- been either removed or renamed. Please annotate ‘$className’ with the @RenameColumn
+ AutoMigration Failure in '$className': Column '$columnName' in table '$tableName' has
+ been either removed or renamed. Please annotate '$className' with the @RenameColumn
or @DeleteColumn annotation to specify the change to be performed:
1) RENAME:
@RenameColumn.Entries(
@@ -1139,10 +1076,9 @@
)
"""
}
- }
- fun deletedOrRenamedTableFound(className: String?, tableName: String): String {
- return if (className != null) {
+ fun deletedOrRenamedTableFound(className: String?, tableName: String) =
+ if (className != null) {
"""
AutoMigration Failure in '$className': Table '$tableName' has been either removed or
renamed. Please annotate '$className' with the @RenameTable or @DeleteTable
@@ -1181,48 +1117,38 @@
)
"""
}
- }
- fun tableRenameError(
- className: String,
- originalTableName: String,
- newTableName: String,
- ): String {
- return "AutoMigration Failure in '$className': The table renamed from " +
+ fun tableRenameError(className: String, originalTableName: String, newTableName: String) =
+ "AutoMigration Failure in '$className': The table renamed from " +
"'$originalTableName' to '$newTableName' is " +
"not found in the new version of the database."
- }
- fun conflictingRenameTableAnnotationsFound(annotations: String): String {
- return "Conflicting @RenameTable annotations found: [$annotations]"
- }
+ fun conflictingRenameTableAnnotationsFound(annotations: String) =
+ "Conflicting @RenameTable annotations found: [$annotations]"
- fun conflictingRenameColumnAnnotationsFound(annotations: String): String {
- return "Conflicting @RenameColumn annotations found: [$annotations]"
- }
+ fun conflictingRenameColumnAnnotationsFound(annotations: String) =
+ "Conflicting @RenameColumn annotations found: [$annotations]"
const val AUTO_MIGRATION_FOUND_BUT_EXPORT_SCHEMA_OFF =
- "Cannot create auto migrations when " + "exportSchema is false."
+ "Cannot create auto migrations when the exportSchema annotation value is false."
const val AUTO_MIGRATION_SCHEMA_IN_FOLDER_NULL =
"Schema import directory was not provided to the" +
" annotation processor so Room cannot read older schemas. To generate auto migrations," +
" you must provide `room.schemaLocation` annotation processor arguments by applying the" +
- " Room Gradle plugin (id 'androidx.room') AND set exportSchema to true."
+ " Room Gradle plugin (id 'androidx.room3') AND set exportSchema to true."
- fun tableWithConflictingPrefixFound(tableName: String): String {
- return "The new version of the schema contains '$tableName' a table name" +
+ fun tableWithConflictingPrefixFound(tableName: String) =
+ "The new version of the schema contains '$tableName' a table name" +
" with the prefix '_new_', which will cause conflicts for auto migrations. Please use" +
" a different name."
- }
const val INNER_CLASS_AUTOMIGRATION_SPEC_MUST_BE_STATIC =
- "An inner class AutoMigrationSpec must be" + " static."
+ "An inner class AutoMigrationSpec must be static."
const val AUTOMIGRATION_SPEC_MISSING_NOARG_CONSTRUCTOR =
- "Classes that are used as " +
- "AutoMigrationSpec " +
- "implementations must have no-argument public constructors."
+ "Classes that are used as AutoMigrationSpec implementations must have no-argument " +
+ "public constructors."
const val JVM_NAME_ON_OVERRIDDEN_FUNCTION =
"Using @JvmName annotation on a function or accessor " +
@@ -1265,19 +1191,14 @@
"Invalid non-null declaration of 'Void', should be nullable. The 'Void' " +
"class represents a placeholder type that is uninstantiable and 'null' is always returned."
- fun nullableCollectionOrArrayReturnTypeInDaoFunction(
- typeName: String,
- returnType: String,
- ): String {
- return "The nullable `$returnType` ($typeName) return type in a DAO function is " +
- "meaningless because Room will instead return an empty `$returnType` if no rows are " +
+ fun nullableCollectionOrArrayReturnTypeInDaoFunction(typeName: String, returnType: String) =
+ "The nullable '$returnType' ($typeName) return type in a DAO function is " +
+ "meaningless because Room will instead return an empty '$returnType' if no rows are " +
"returned from the query."
- }
- fun nullableComponentInDaoFunctionReturnType(typeName: String): String {
- return "The DAO function return type ($typeName) with the nullable type argument " +
+ fun nullableComponentInDaoFunctionReturnType(typeName: String) =
+ "The DAO function return type ($typeName) with the nullable type argument " +
"is meaningless because for now Room will never put a null value in a result."
- }
const val EXPORTING_SCHEMA_TO_RESOURCES =
"Schema export is set to be outputted as a resource" +
@@ -1288,11 +1209,10 @@
" warning serves as a reminder to use room.exportSchemaResource cautiously."
const val INVALID_GRADLE_PLUGIN_AND_SCHEMA_LOCATION_OPTION =
- "The Room Gradle plugin " +
- "(id 'androidx.room') cannot be used with an explicit use of the annotation processor" +
- "option `room.schemaLocation`, please remove the configuration of the option and " +
- "configure the schema location via the plugin project extension: " +
- "`room { schemaDirectory(...) }`."
+ "The Room Gradle plugin (id 'androidx.room3') cannot be used with an explicit use of the " +
+ "annotation processor option `room.schemaLocation`, please remove the configuration " +
+ "of the option and configure the schema location via the plugin project extension: " +
+ "`room3 { schemaDirectory(...) }`."
const val INVALID_DATABASE_VERSION = "Database version must be greater than 0"
@@ -1306,10 +1226,6 @@
"Only suspend functions are allowed in DAOs" +
" declared in source sets targeting non-Android platforms."
- const val RAW_QUERY_NOT_SUPPORTED_ON_NON_ANDROID =
- "@RawQuery annotated DAO functions are currently not supported in source sets targeting " +
- "non-Android platforms."
-
const val MISSING_CONSTRUCTED_BY_ANNOTATION =
"The @Database class must be annotated with @ConstructedBy since the source is targeting " +
"non-Android platforms."
diff --git a/room3/room3-compiler/src/main/kotlin/androidx/room3/solver/TypeAdapterStore.kt b/room3/room3-compiler/src/main/kotlin/androidx/room3/solver/TypeAdapterStore.kt
index 81933f8..7ccd29e 100644
--- a/room3/room3-compiler/src/main/kotlin/androidx/room3/solver/TypeAdapterStore.kt
+++ b/room3/room3-compiler/src/main/kotlin/androidx/room3/solver/TypeAdapterStore.kt
@@ -50,13 +50,12 @@
import androidx.room3.solver.binderprovider.DaoConverterDeleteOrUpdateFunctionBinderProvider
import androidx.room3.solver.binderprovider.DaoConverterInsertOrUpsertFunctionQueryResultBinderProvider
import androidx.room3.solver.binderprovider.DaoConverterQueryResultBinderProvider
+import androidx.room3.solver.binderprovider.DaoReturnTypePreparedQueryBinderProvider
import androidx.room3.solver.binderprovider.InstantQueryResultBinderProvider
import androidx.room3.solver.binderprovider.SuspendResultBinderProvider
import androidx.room3.solver.prepared.binder.PreparedQueryResultBinder
-import androidx.room3.solver.prepared.binderprovider.GuavaListenableFuturePreparedQueryResultBinderProvider
import androidx.room3.solver.prepared.binderprovider.InstantPreparedQueryResultBinderProvider
import androidx.room3.solver.prepared.binderprovider.PreparedQueryResultBinderProvider
-import androidx.room3.solver.prepared.binderprovider.RxPreparedQueryResultBinderProvider
import androidx.room3.solver.prepared.result.PreparedQueryResultAdapter
import androidx.room3.solver.query.parameter.ArrayQueryParameterAdapter
import androidx.room3.solver.query.parameter.BasicQueryParameterAdapter
@@ -228,8 +227,14 @@
private val preparedQueryResultBinderProviders: List<PreparedQueryResultBinderProvider> =
mutableListOf<PreparedQueryResultBinderProvider>().apply {
- addAll(RxPreparedQueryResultBinderProvider.getAll(context))
- add(GuavaListenableFuturePreparedQueryResultBinderProvider(context))
+ addAll(
+ daoReturnTypeConverters.map {
+ DaoReturnTypePreparedQueryBinderProvider(
+ context = context,
+ returnTypeConverter = it,
+ )
+ }
+ )
add(InstantPreparedQueryResultBinderProvider(context))
}
diff --git a/room3/room3-compiler/src/main/kotlin/androidx/room3/solver/binderprovider/BaseDaoConverterBinderProvider.kt b/room3/room3-compiler/src/main/kotlin/androidx/room3/solver/binderprovider/BaseDaoConverterBinderProvider.kt
index 740dcd2..fb167e8 100644
--- a/room3/room3-compiler/src/main/kotlin/androidx/room3/solver/binderprovider/BaseDaoConverterBinderProvider.kt
+++ b/room3/room3-compiler/src/main/kotlin/androidx/room3/solver/binderprovider/BaseDaoConverterBinderProvider.kt
@@ -24,7 +24,7 @@
import androidx.room3.ext.isCollection
import androidx.room3.processor.Context
import androidx.room3.processor.ProcessorErrors
-import androidx.room3.processor.ProcessorErrors.DAO_RETURN_TYPE_CONVERTER_FUNCTIONS_WITH_A_TYPE_PARAM_SHOULD_HAVE_RETURN_TYPE_WITH_ONLY_ONE_GENERIC_ARG
+import androidx.room3.processor.ProcessorErrors.DAO_RETURN_TYPE_CONVERTER_FUNCTIONS_TYPE_PARAM_MISMATCH
import androidx.room3.solver.types.DaoReturnTypeConverter
import com.google.common.base.Optional
@@ -69,7 +69,7 @@
context.checker.check(
predicate = allTypeArgsExceptRowAdapterPositionMatch,
element = converter.to.typeElement!!,
- DAO_RETURN_TYPE_CONVERTER_FUNCTIONS_WITH_A_TYPE_PARAM_SHOULD_HAVE_RETURN_TYPE_WITH_ONLY_ONE_GENERIC_ARG,
+ DAO_RETURN_TYPE_CONVERTER_FUNCTIONS_TYPE_PARAM_MISMATCH,
)
return allTypeArgsExceptRowAdapterPositionMatch
diff --git a/room3/room3-compiler/src/main/kotlin/androidx/room3/solver/binderprovider/DaoReturnTypePreparedQueryBinderProvider.kt b/room3/room3-compiler/src/main/kotlin/androidx/room3/solver/binderprovider/DaoReturnTypePreparedQueryBinderProvider.kt
new file mode 100644
index 0000000..cc7e673
--- /dev/null
+++ b/room3/room3-compiler/src/main/kotlin/androidx/room3/solver/binderprovider/DaoReturnTypePreparedQueryBinderProvider.kt
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2026 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.room3.solver.binderprovider
+
+import androidx.room3.OperationType
+import androidx.room3.compiler.processing.XType
+import androidx.room3.parser.ParsedQuery
+import androidx.room3.processor.Context
+import androidx.room3.solver.prepared.binder.PreparedQueryResultBinder
+import androidx.room3.solver.prepared.binderprovider.PreparedQueryResultBinderProvider
+import androidx.room3.solver.shortcut.binder.DaoConverterPreparedQueryResultBinder
+import androidx.room3.solver.types.DaoReturnTypeConverter
+
+class DaoReturnTypePreparedQueryBinderProvider(
+ context: Context,
+ returnTypeConverter: DaoReturnTypeConverter,
+) :
+ BaseDaoConverterBinderProvider(context, returnTypeConverter),
+ PreparedQueryResultBinderProvider {
+ override fun matches(declared: XType): Boolean = matchConverter(declared, OperationType.WRITE)
+
+ override fun provide(declared: XType, query: ParsedQuery): PreparedQueryResultBinder {
+ val typeArg = extractTypeArg(declared)
+ val adapter = context.typeAdapterStore.findPreparedQueryResultAdapter(typeArg, query)
+ return DaoConverterPreparedQueryResultBinder(
+ typeArg = typeArg,
+ adapter = adapter,
+ converter = converter,
+ )
+ }
+}
diff --git a/room3/room3-compiler/src/main/kotlin/androidx/room3/solver/prepared/binder/CoroutinePreparedQueryResultBinder.kt b/room3/room3-compiler/src/main/kotlin/androidx/room3/solver/prepared/binder/CoroutinePreparedQueryResultBinder.kt
index 7ce6f13..4ce8e05 100644
--- a/room3/room3-compiler/src/main/kotlin/androidx/room3/solver/prepared/binder/CoroutinePreparedQueryResultBinder.kt
+++ b/room3/room3-compiler/src/main/kotlin/androidx/room3/solver/prepared/binder/CoroutinePreparedQueryResultBinder.kt
@@ -30,9 +30,9 @@
/** Binder of prepared queries of a Kotlin coroutine suspend function. */
class CoroutinePreparedQueryResultBinder(
- adapter: PreparedQueryResultAdapter?,
private val continuationParamName: String,
-) : PreparedQueryResultBinder(adapter) {
+ override val adapter: PreparedQueryResultAdapter?,
+) : PreparedQueryResultBinder {
override fun executeAndReturn(
sqlQueryVar: String,
diff --git a/room3/room3-compiler/src/main/kotlin/androidx/room3/solver/prepared/binder/InstantPreparedQueryResultBinder.kt b/room3/room3-compiler/src/main/kotlin/androidx/room3/solver/prepared/binder/InstantPreparedQueryResultBinder.kt
index f604ff6..9a50ef4 100644
--- a/room3/room3-compiler/src/main/kotlin/androidx/room3/solver/prepared/binder/InstantPreparedQueryResultBinder.kt
+++ b/room3/room3-compiler/src/main/kotlin/androidx/room3/solver/prepared/binder/InstantPreparedQueryResultBinder.kt
@@ -29,8 +29,8 @@
import androidx.room3.solver.prepared.result.PreparedQueryResultAdapter
/** Default binder for prepared queries. */
-class InstantPreparedQueryResultBinder(adapter: PreparedQueryResultAdapter?) :
- PreparedQueryResultBinder(adapter) {
+class InstantPreparedQueryResultBinder(override val adapter: PreparedQueryResultAdapter?) :
+ PreparedQueryResultBinder {
override fun executeAndReturn(
sqlQueryVar: String,
diff --git a/room3/room3-compiler/src/main/kotlin/androidx/room3/solver/prepared/binder/LambdaPreparedQueryResultBinder.kt b/room3/room3-compiler/src/main/kotlin/androidx/room3/solver/prepared/binder/LambdaPreparedQueryResultBinder.kt
deleted file mode 100644
index fba70e4..0000000
--- a/room3/room3-compiler/src/main/kotlin/androidx/room3/solver/prepared/binder/LambdaPreparedQueryResultBinder.kt
+++ /dev/null
@@ -1,86 +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.room3.solver.prepared.binder
-
-import androidx.room3.compiler.codegen.XCodeBlock
-import androidx.room3.compiler.codegen.XMemberName
-import androidx.room3.compiler.codegen.XPropertySpec
-import androidx.room3.compiler.codegen.XTypeName
-import androidx.room3.compiler.processing.XType
-import androidx.room3.ext.InvokeWithLambdaParameter
-import androidx.room3.ext.LambdaSpec
-import androidx.room3.ext.SQLiteDriverMemberNames
-import androidx.room3.ext.SQLiteDriverTypeNames
-import androidx.room3.solver.CodeGenScope
-import androidx.room3.solver.prepared.result.PreparedQueryResultAdapter
-
-/**
- * Binder for deferred queries.
- *
- * This binder generates code that invokes [functionName] with a lambda whose body will delegate to
- * the given [adapter].
- */
-class LambdaPreparedQueryResultBinder(
- private val returnType: XType,
- private val functionName: XMemberName,
- adapter: PreparedQueryResultAdapter?,
-) : PreparedQueryResultBinder(adapter) {
-
- override fun executeAndReturn(
- sqlQueryVar: String,
- dbProperty: XPropertySpec,
- bindStatement: CodeGenScope.(String) -> Unit,
- returnTypeName: XTypeName,
- scope: CodeGenScope,
- ) {
- val connectionVar = scope.getTmpVar("_connection")
- val performBlock =
- InvokeWithLambdaParameter(
- scope = scope,
- functionName = functionName,
- argFormat = listOf("%N", "%L", "%L"),
- args = listOf(dbProperty, /* isReadOnly= */ false, /* inTransaction= */ true),
- lambdaSpec =
- object :
- LambdaSpec(
- parameterTypeName = SQLiteDriverTypeNames.CONNECTION,
- parameterName = connectionVar,
- returnTypeName = returnType.asTypeName(),
- javaLambdaSyntaxAvailable = scope.javaLambdaSyntaxAvailable,
- ) {
- override fun XCodeBlock.Builder.body(scope: CodeGenScope) {
- val statementVar = scope.getTmpVar("_stmt")
- addLocalVal(
- statementVar,
- SQLiteDriverTypeNames.STATEMENT,
- "%L.%M(%L)",
- connectionVar,
- SQLiteDriverMemberNames.CONNECTION_PREPARE,
- sqlQueryVar,
- )
- beginControlFlow("try")
- bindStatement(scope, statementVar)
- adapter?.executeAndReturn(connectionVar, statementVar, scope)
- nextControlFlow("finally")
- addStatement("%L.close()", statementVar)
- endControlFlow()
- }
- },
- )
- scope.builder.add("return %L", performBlock)
- }
-}
diff --git a/room3/room3-compiler/src/main/kotlin/androidx/room3/solver/prepared/binder/PreparedQueryResultBinder.kt b/room3/room3-compiler/src/main/kotlin/androidx/room3/solver/prepared/binder/PreparedQueryResultBinder.kt
index 9d054e6..569e4c9 100644
--- a/room3/room3-compiler/src/main/kotlin/androidx/room3/solver/prepared/binder/PreparedQueryResultBinder.kt
+++ b/room3/room3-compiler/src/main/kotlin/androidx/room3/solver/prepared/binder/PreparedQueryResultBinder.kt
@@ -28,13 +28,15 @@
* than executed directly then alternative implementations can be implement using this interface
* (e.g. Rx, ListenableFuture).
*/
-abstract class PreparedQueryResultBinder(val adapter: PreparedQueryResultAdapter?) {
+interface PreparedQueryResultBinder {
+
+ val adapter: PreparedQueryResultAdapter?
/**
* Receives the SQL and a function to bind args into a statement, it must then generate the code
* that steps on the query and if applicable returns the result of the write operation.
*/
- abstract fun executeAndReturn(
+ fun executeAndReturn(
sqlQueryVar: String,
dbProperty: XPropertySpec,
bindStatement: CodeGenScope.(String) -> Unit,
diff --git a/room3/room3-compiler/src/main/kotlin/androidx/room3/solver/prepared/binderprovider/GuavaListenableFuturePreparedQueryResultBinderProvider.kt b/room3/room3-compiler/src/main/kotlin/androidx/room3/solver/prepared/binderprovider/GuavaListenableFuturePreparedQueryResultBinderProvider.kt
deleted file mode 100644
index 0db9471..0000000
--- a/room3/room3-compiler/src/main/kotlin/androidx/room3/solver/prepared/binderprovider/GuavaListenableFuturePreparedQueryResultBinderProvider.kt
+++ /dev/null
@@ -1,57 +0,0 @@
-/*
- * 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.room3.solver.prepared.binderprovider
-
-import androidx.room3.compiler.processing.XNullability
-import androidx.room3.compiler.processing.XType
-import androidx.room3.compiler.processing.isVoidObject
-import androidx.room3.ext.GuavaUtilConcurrentTypeNames
-import androidx.room3.ext.RoomGuavaMemberNames.GUAVA_ROOM_CREATE_LISTENABLE_FUTURE
-import androidx.room3.ext.RoomGuavaTypeNames.GUAVA_ROOM_MARKER
-import androidx.room3.parser.ParsedQuery
-import androidx.room3.processor.Context
-import androidx.room3.processor.ProcessorErrors
-import androidx.room3.solver.prepared.binder.LambdaPreparedQueryResultBinder
-import androidx.room3.solver.prepared.binder.PreparedQueryResultBinder
-
-class GuavaListenableFuturePreparedQueryResultBinderProvider(val context: Context) :
- PreparedQueryResultBinderProvider {
-
- private val hasGuavaRoom by lazy {
- context.processingEnv.findTypeElement(GUAVA_ROOM_MARKER.canonicalName) != null
- }
-
- override fun matches(declared: XType): Boolean =
- declared.typeArguments.size == 1 &&
- declared.rawType.asTypeName() == GuavaUtilConcurrentTypeNames.LISTENABLE_FUTURE
-
- override fun provide(declared: XType, query: ParsedQuery): PreparedQueryResultBinder {
- if (!hasGuavaRoom) {
- context.logger.e(ProcessorErrors.MISSING_ROOM_GUAVA_ARTIFACT)
- }
- val typeArg = declared.typeArguments.first()
- if (typeArg.isVoidObject() && typeArg.nullability == XNullability.NONNULL) {
- context.logger.e(ProcessorErrors.NONNULL_VOID)
- }
-
- return LambdaPreparedQueryResultBinder(
- returnType = typeArg,
- functionName = GUAVA_ROOM_CREATE_LISTENABLE_FUTURE,
- adapter = context.typeAdapterStore.findPreparedQueryResultAdapter(typeArg, query),
- )
- }
-}
diff --git a/room3/room3-compiler/src/main/kotlin/androidx/room3/solver/prepared/binderprovider/RxPreparedQueryResultBinderProvider.kt b/room3/room3-compiler/src/main/kotlin/androidx/room3/solver/prepared/binderprovider/RxPreparedQueryResultBinderProvider.kt
deleted file mode 100644
index 799247b..0000000
--- a/room3/room3-compiler/src/main/kotlin/androidx/room3/solver/prepared/binderprovider/RxPreparedQueryResultBinderProvider.kt
+++ /dev/null
@@ -1,96 +0,0 @@
-/*
- * 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.room3.solver.prepared.binderprovider
-
-import androidx.room3.compiler.processing.XRawType
-import androidx.room3.compiler.processing.XType
-import androidx.room3.ext.KotlinTypeNames
-import androidx.room3.parser.ParsedQuery
-import androidx.room3.processor.Context
-import androidx.room3.solver.RxType
-import androidx.room3.solver.prepared.binder.LambdaPreparedQueryResultBinder
-import androidx.room3.solver.prepared.binder.PreparedQueryResultBinder
-
-open class RxPreparedQueryResultBinderProvider
-internal constructor(val context: Context, private val rxType: RxType) :
- PreparedQueryResultBinderProvider {
-
- private val hasRxJavaArtifact by lazy {
- context.processingEnv.findTypeElement(rxType.version.rxMarkerClassName.canonicalName) !=
- null
- }
-
- override fun matches(declared: XType): Boolean =
- declared.typeArguments.size == 1 && matchesRxType(declared)
-
- private fun matchesRxType(declared: XType): Boolean {
- return declared.rawType.asTypeName() == rxType.className
- }
-
- override fun provide(declared: XType, query: ParsedQuery): PreparedQueryResultBinder {
- if (!hasRxJavaArtifact) {
- context.logger.e(rxType.version.missingArtifactMessage)
- }
- val typeArg = extractTypeArg(declared)
- return LambdaPreparedQueryResultBinder(
- returnType = typeArg,
- functionName = rxType.factoryMethodName,
- adapter = context.typeAdapterStore.findPreparedQueryResultAdapter(typeArg, query),
- )
- }
-
- open fun extractTypeArg(declared: XType): XType = declared.typeArguments.first()
-
- companion object {
- fun getAll(context: Context) =
- listOf(
- RxSingleOrMaybePreparedQueryResultBinderProvider(context, RxType.RX3_SINGLE),
- RxSingleOrMaybePreparedQueryResultBinderProvider(context, RxType.RX3_MAYBE),
- RxCompletablePreparedQueryResultBinderProvider(context, RxType.RX3_COMPLETABLE),
- )
- }
-}
-
-private class RxCompletablePreparedQueryResultBinderProvider(context: Context, rxType: RxType) :
- RxPreparedQueryResultBinderProvider(context, rxType) {
-
- private val completableType: XRawType? by lazy {
- context.processingEnv.findType(rxType.className.canonicalName)?.rawType
- }
-
- override fun matches(declared: XType): Boolean {
- if (completableType == null) {
- return false
- }
- return declared.rawType.isAssignableFrom(completableType!!)
- }
-
- /**
- * Since Completable has no type argument, the supported return type is Unit (non-nullable)
- * since the 'createCompletable" factory function take a Kotlin lambda.
- */
- override fun extractTypeArg(declared: XType): XType =
- context.processingEnv.requireType(KotlinTypeNames.UNIT)
-}
-
-private class RxSingleOrMaybePreparedQueryResultBinderProvider(context: Context, rxType: RxType) :
- RxPreparedQueryResultBinderProvider(context, rxType) {
-
- /** Since Maybe can have null values, the lambda returned must allow for null values. */
- override fun extractTypeArg(declared: XType): XType =
- declared.typeArguments.first().makeNullable()
-}
diff --git a/room3/room3-compiler/src/main/kotlin/androidx/room3/solver/query/result/MultimapQueryResultAdapter.kt b/room3/room3-compiler/src/main/kotlin/androidx/room3/solver/query/result/MultimapQueryResultAdapter.kt
index 2ef35ab..1d64246 100644
--- a/room3/room3-compiler/src/main/kotlin/androidx/room3/solver/query/result/MultimapQueryResultAdapter.kt
+++ b/room3/room3-compiler/src/main/kotlin/androidx/room3/solver/query/result/MultimapQueryResultAdapter.kt
@@ -203,7 +203,6 @@
ProcessorErrors.cannotMapSpecifiedColumn(
errorColumn,
resultColumns.map { it.name },
- MapColumn::class.java.simpleName,
)
)
}
diff --git a/room3/room3-compiler/src/main/kotlin/androidx/room3/solver/shortcut/binder/DaoConverterPreparedQueryResultBinder.kt b/room3/room3-compiler/src/main/kotlin/androidx/room3/solver/shortcut/binder/DaoConverterPreparedQueryResultBinder.kt
new file mode 100644
index 0000000..2c0a3e5
--- /dev/null
+++ b/room3/room3-compiler/src/main/kotlin/androidx/room3/solver/shortcut/binder/DaoConverterPreparedQueryResultBinder.kt
@@ -0,0 +1,63 @@
+/*
+ * Copyright 2026 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.room3.solver.shortcut.binder
+
+import androidx.room3.compiler.codegen.XPropertySpec
+import androidx.room3.compiler.codegen.XTypeName
+import androidx.room3.compiler.processing.XType
+import androidx.room3.ext.SQLiteDriverMemberNames
+import androidx.room3.ext.SQLiteDriverTypeNames
+import androidx.room3.solver.CodeGenScope
+import androidx.room3.solver.prepared.binder.PreparedQueryResultBinder
+import androidx.room3.solver.prepared.result.PreparedQueryResultAdapter
+import androidx.room3.solver.types.DaoReturnTypeConverter
+
+class DaoConverterPreparedQueryResultBinder(
+ val typeArg: XType,
+ override val adapter: PreparedQueryResultAdapter?,
+ converter: DaoReturnTypeConverter,
+) : BaseDaoConverterShortcutBinder(converter), PreparedQueryResultBinder {
+ override fun executeAndReturn(
+ sqlQueryVar: String,
+ dbProperty: XPropertySpec,
+ bindStatement: CodeGenScope.(String) -> Unit,
+ returnTypeName: XTypeName,
+ scope: CodeGenScope,
+ ) {
+ convertAndReturnShortcut(typeArg = typeArg, dbProperty = dbProperty, scope = scope) {
+ innerScope,
+ connectionVar ->
+ val statementVar = innerScope.getTmpVar("_stmt")
+ innerScope.builder.apply {
+ addLocalVal(
+ statementVar,
+ SQLiteDriverTypeNames.STATEMENT,
+ "%L.%M(%L)",
+ connectionVar,
+ SQLiteDriverMemberNames.CONNECTION_PREPARE,
+ sqlQueryVar,
+ )
+ beginControlFlow("try")
+ bindStatement(innerScope, statementVar)
+ adapter?.executeAndReturn(connectionVar, statementVar, innerScope)
+ nextControlFlow("finally")
+ addStatement("%L.close()", statementVar)
+ endControlFlow()
+ }
+ }
+ }
+}
diff --git a/room3/room3-compiler/src/test/kotlin/androidx/room3/processor/CustomConverterProcessorTest.kt b/room3/room3-compiler/src/test/kotlin/androidx/room3/processor/CustomConverterProcessorTest.kt
index 37dc0a0..9306fae 100644
--- a/room3/room3-compiler/src/test/kotlin/androidx/room3/processor/CustomConverterProcessorTest.kt
+++ b/room3/room3-compiler/src/test/kotlin/androidx/room3/processor/CustomConverterProcessorTest.kt
@@ -295,7 +295,7 @@
.isEqualTo(XTypeName.BOXED_SHORT.copy(nullable = true))
assertThat(converter?.toTypeName).isEqualTo(XTypeName.BOXED_CHAR.copy(nullable = true))
invocation.assertCompilationResult {
- hasErrorContaining("Multiple functions define the same conversion")
+ hasErrorContaining("Multiple @TypeConverter functions define the same conversion.")
}
}
}
diff --git a/room3/room3-compiler/src/test/kotlin/androidx/room3/processor/DataClassProcessorTest.kt b/room3/room3-compiler/src/test/kotlin/androidx/room3/processor/DataClassProcessorTest.kt
index ea3ec09..e3c945f 100644
--- a/room3/room3-compiler/src/test/kotlin/androidx/room3/processor/DataClassProcessorTest.kt
+++ b/room3/room3-compiler/src/test/kotlin/androidx/room3/processor/DataClassProcessorTest.kt
@@ -27,7 +27,6 @@
import androidx.room3.ext.CommonTypeNames
import androidx.room3.parser.SQLTypeAffinity
import androidx.room3.processor.ProcessorErrors.CANNOT_FIND_GETTER_FOR_PROPERTY
-import androidx.room3.processor.ProcessorErrors.DATA_CLASS_PROPERTY_HAS_DUPLICATE_COLUMN_NAME
import androidx.room3.processor.ProcessorErrors.MISSING_DATA_CLASS_CONSTRUCTOR
import androidx.room3.processor.ProcessorErrors.junctionColumnWithoutIndex
import androidx.room3.processor.ProcessorErrors.relationCannotFindEntityProperty
@@ -347,8 +346,7 @@
hasErrorContaining(
ProcessorErrors.dataClassDuplicatePropertyNames("id", listOf("id", "another"))
)
- hasErrorContaining(DATA_CLASS_PROPERTY_HAS_DUPLICATE_COLUMN_NAME)
- hasErrorCount(3)
+ hasErrorCount(1)
}
}
}
@@ -370,8 +368,7 @@
hasErrorContaining(
ProcessorErrors.dataClassDuplicatePropertyNames("id", listOf("id", "foo > x"))
)
- hasErrorContaining(DATA_CLASS_PROPERTY_HAS_DUPLICATE_COLUMN_NAME)
- hasErrorCount(3)
+ hasErrorCount(1)
}
}
}
diff --git a/room3/room3-compiler/src/test/kotlin/androidx/room3/processor/QueryFunctionProcessorTest.kt b/room3/room3-compiler/src/test/kotlin/androidx/room3/processor/QueryFunctionProcessorTest.kt
index 75007a2..9c3dbea 100644
--- a/room3/room3-compiler/src/test/kotlin/androidx/room3/processor/QueryFunctionProcessorTest.kt
+++ b/room3/room3-compiler/src/test/kotlin/androidx/room3/processor/QueryFunctionProcessorTest.kt
@@ -72,8 +72,12 @@
import com.google.common.collect.*;
import androidx.room3.livedata.LiveDataDaoReturnTypeConverter;
import androidx.room3.rxjava3.RxDaoReturnTypeConverters;
+ import androidx.room3.paging.guava.ListenableFuturePagingSourceDaoReturnTypeConverter;
+ import androidx.room3.guava.GuavaDaoReturnTypeConverter;
@DaoReturnTypeConverters(
{ LiveDataDaoReturnTypeConverter.class,
+ ListenableFuturePagingSourceDaoReturnTypeConverter.class,
+ GuavaDaoReturnTypeConverter.class,
RxDaoReturnTypeConverters.class }
)
@Dao
@@ -85,6 +89,8 @@
import androidx.room3.*
import androidx.room3.livedata.LiveDataDaoReturnTypeConverter
import androidx.room3.rxjava3.RxDaoReturnTypeConverters
+ import androidx.room3.paging.guava.ListenableFuturePagingSourceDaoReturnTypeConverter
+ import androidx.room3.guava.GuavaDaoReturnTypeConverter
import java.util.*
import io.reactivex.*
import io.reactivex.rxjava3.core.*
@@ -94,6 +100,8 @@
import kotlinx.coroutines.flow.*
@DaoReturnTypeConverters(
LiveDataDaoReturnTypeConverter::class,
+ GuavaDaoReturnTypeConverter::class,
+ ListenableFuturePagingSourceDaoReturnTypeConverter::class,
RxDaoReturnTypeConverters::class,
)
@Dao
@@ -1128,6 +1136,11 @@
COMMON.RX3_FLOWABLE,
COMMON.PUBLISHER,
COMMON.RX3_OBSERVABLE,
+ COMMON.LIMIT_OFFSET_PAGING_SOURCE,
+ COMMON.LIMIT_OFFSET_RX3_PAGING_SOURCE,
+ COMMON.RX3_PAGING_SOURCE,
+ COMMON.LIMIT_OFFSET_LISTENABLE_FUTURE_PAGING_SOURCE,
+ COMMON.LISTENABLE_FUTURE_PAGING_SOURCE,
)
runKspTest(sources = additionalSources + commonSources + inputSource, options = options) {
invocation ->
@@ -1175,21 +1188,28 @@
Source.kotlin("MyClass.kt", DAO_PREFIX_KT + input.joinToString("\n") + DAO_SUFFIX)
val commonSources =
listOf(
+ COMMON.LIVE_DATA,
+ COMMON.COMPUTABLE_LIVE_DATA,
COMMON.USER,
COMMON.BOOK,
+ COMMON.PAGE,
COMMON.NOT_AN_ENTITY,
+ COMMON.ARTIST,
+ COMMON.SONG,
+ COMMON.IMAGE,
+ COMMON.IMAGE_FORMAT,
+ COMMON.CONVERTER,
COMMON.RX3_COMPLETABLE,
COMMON.RX3_MAYBE,
COMMON.RX3_SINGLE,
COMMON.RX3_FLOWABLE,
- COMMON.RX3_OBSERVABLE,
- COMMON.LISTENABLE_FUTURE,
- COMMON.LIVE_DATA,
- COMMON.COMPUTABLE_LIVE_DATA,
COMMON.PUBLISHER,
- COMMON.FLOW,
- COMMON.GUAVA_ROOM,
- COMMON.RX3_ROOM,
+ COMMON.RX3_OBSERVABLE,
+ COMMON.LIMIT_OFFSET_PAGING_SOURCE,
+ COMMON.LIMIT_OFFSET_RX3_PAGING_SOURCE,
+ COMMON.RX3_PAGING_SOURCE,
+ COMMON.LIMIT_OFFSET_LISTENABLE_FUTURE_PAGING_SOURCE,
+ COMMON.LISTENABLE_FUTURE_PAGING_SOURCE,
)
runKspTest(sources = additionalSources + commonSources + inputSource, options = options) {
@@ -1335,8 +1355,8 @@
) { _, invocation ->
invocation.assertCompilationResult {
hasErrorContaining(
- "Column specified in the provided @MapColumn " +
- "annotation must be present in the query."
+ "Column specified in the declared @MapColumn " +
+ "annotation must be present in the query result."
)
}
}
@@ -1559,12 +1579,12 @@
)
hasErrorCount(2)
hasErrorContaining(
- "Column specified in the provided @MapColumn annotation must " +
- "be present in the query. Provided: cat."
+ "Column specified in the declared @MapColumn annotation must " +
+ "be present in the query result. Declared column name: cat."
)
hasErrorContaining(
- "Column specified in the provided @MapColumn annotation must " +
- "be present in the query. Provided: dog."
+ "Column specified in the declared @MapColumn annotation must " +
+ "be present in the query result. Declared column name: dog."
)
}
}
diff --git a/room3/room3-compiler/src/test/kotlin/androidx/room3/writer/DaoKotlinCodeGenTest.kt b/room3/room3-compiler/src/test/kotlin/androidx/room3/writer/DaoKotlinCodeGenTest.kt
index d726d91..2c6643e 100644
--- a/room3/room3-compiler/src/test/kotlin/androidx/room3/writer/DaoKotlinCodeGenTest.kt
+++ b/room3/room3-compiler/src/test/kotlin/androidx/room3/writer/DaoKotlinCodeGenTest.kt
@@ -2466,11 +2466,10 @@
import androidx.room3.*
import io.reactivex.rxjava3.core.*
import com.google.common.base.Optional
+ import androidx.room3.rxjava3.RxDaoReturnTypeConverters
@Database(entities = [MyEntity::class], version = 1, exportSchema = false)
- @DaoReturnTypeConverters(
- androidx.room3.rxjava3.RxDaoReturnTypeConverters::class
- )
+ @DaoReturnTypeConverters(RxDaoReturnTypeConverters::class)
abstract class MyDatabase : RoomDatabase() {
abstract fun getDao(): MyDao
}
@@ -2537,6 +2536,9 @@
import io.reactivex.rxjava3.core.*
@Dao
+ @DaoReturnTypeConverters(
+ androidx.room3.rxjava3.RxDaoReturnTypeConverters::class
+ )
interface MyDao {
@Query("INSERT INTO MyEntity (pk, other) VALUES (:id, :name)")
fun insertPublisherSingle(id: String, name: String): Single<Long>
diff --git a/room3/room3-compiler/src/test/test-data/kotlinCodeGen/guavaCallable.kt b/room3/room3-compiler/src/test/test-data/kotlinCodeGen/guavaCallable.kt
index b918c1a..368e02d 100644
--- a/room3/room3-compiler/src/test/test-data/kotlinCodeGen/guavaCallable.kt
+++ b/room3/room3-compiler/src/test/test-data/kotlinCodeGen/guavaCallable.kt
@@ -3,7 +3,6 @@
import androidx.room3.EntityUpsertAdapter
import androidx.room3.RoomDatabase
import androidx.room3.guava.GuavaDaoReturnTypeConverter
-import androidx.room3.guava.createListenableFuture
import androidx.room3.util.appendPlaceholders
import androidx.room3.util.getColumnIndexOrThrow
import androidx.room3.util.getLastInsertedRowId
@@ -195,34 +194,38 @@
public override fun insertListenableFuture(id: String, name: String): ListenableFuture<Long> {
val _sql: String = "INSERT INTO MyEntity (pk, other) VALUES (?, ?)"
- return createListenableFuture(__db, false, true) { _connection ->
- val _stmt: SQLiteStatement = _connection.prepare(_sql)
- try {
- var _argIndex: Int = 1
- _stmt.bindText(_argIndex, id)
- _argIndex = 2
- _stmt.bindText(_argIndex, name)
- _stmt.step()
- getLastInsertedRowId(_connection)
- } finally {
- _stmt.close()
+ return __guavaDaoReturnTypeConverter.convertAsync(__db, true) {
+ performSuspending(__db, false, true) { _connection ->
+ val _stmt: SQLiteStatement = _connection.prepare(_sql)
+ try {
+ var _argIndex: Int = 1
+ _stmt.bindText(_argIndex, id)
+ _argIndex = 2
+ _stmt.bindText(_argIndex, name)
+ _stmt.step()
+ getLastInsertedRowId(_connection)
+ } finally {
+ _stmt.close()
+ }
}
}
}
public override fun updateListenableFuture(id: String, name: String): ListenableFuture<Void?> {
val _sql: String = "UPDATE MyEntity SET other = ? WHERE pk = ?"
- return createListenableFuture(__db, false, true) { _connection ->
- val _stmt: SQLiteStatement = _connection.prepare(_sql)
- try {
- var _argIndex: Int = 1
- _stmt.bindText(_argIndex, name)
- _argIndex = 2
- _stmt.bindText(_argIndex, id)
- _stmt.step()
- null
- } finally {
- _stmt.close()
+ return __guavaDaoReturnTypeConverter.convertAsync(__db, true) {
+ performSuspending(__db, false, true) { _connection ->
+ val _stmt: SQLiteStatement = _connection.prepare(_sql)
+ try {
+ var _argIndex: Int = 1
+ _stmt.bindText(_argIndex, name)
+ _argIndex = 2
+ _stmt.bindText(_argIndex, id)
+ _stmt.step()
+ null
+ } finally {
+ _stmt.close()
+ }
}
}
}
diff --git a/room3/room3-compiler/src/test/test-data/kotlinCodeGen/preparedCallableQuery_rx3.kt b/room3/room3-compiler/src/test/test-data/kotlinCodeGen/preparedCallableQuery_rx3.kt
index 3107cc10..aa64580 100644
--- a/room3/room3-compiler/src/test/test-data/kotlinCodeGen/preparedCallableQuery_rx3.kt
+++ b/room3/room3-compiler/src/test/test-data/kotlinCodeGen/preparedCallableQuery_rx3.kt
@@ -1,8 +1,7 @@
import androidx.room3.RoomDatabase
-import androidx.room3.rxjava3.createCompletable
-import androidx.room3.rxjava3.createMaybe
-import androidx.room3.rxjava3.createSingle
+import androidx.room3.rxjava3.RxDaoReturnTypeConverters
import androidx.room3.util.getLastInsertedRowId
+import androidx.room3.util.performSuspending
import androidx.sqlite.SQLiteStatement
import androidx.sqlite.prepare
import androidx.sqlite.step
@@ -23,56 +22,65 @@
__db: RoomDatabase,
) : MyDao {
private val __db: RoomDatabase
+
+ private val __rxDaoReturnTypeConverters: RxDaoReturnTypeConverters = RxDaoReturnTypeConverters()
init {
this.__db = __db
}
public override fun insertPublisherSingle(id: String, name: String): Single<Long> {
val _sql: String = "INSERT INTO MyEntity (pk, other) VALUES (?, ?)"
- return createSingle(__db, false, true) { _connection ->
- val _stmt: SQLiteStatement = _connection.prepare(_sql)
- try {
- var _argIndex: Int = 1
- _stmt.bindText(_argIndex, id)
- _argIndex = 2
- _stmt.bindText(_argIndex, name)
- _stmt.step()
- getLastInsertedRowId(_connection)
- } finally {
- _stmt.close()
+ return __rxDaoReturnTypeConverters.convertSingle(__db) {
+ performSuspending(__db, false, true) { _connection ->
+ val _stmt: SQLiteStatement = _connection.prepare(_sql)
+ try {
+ var _argIndex: Int = 1
+ _stmt.bindText(_argIndex, id)
+ _argIndex = 2
+ _stmt.bindText(_argIndex, name)
+ _stmt.step()
+ getLastInsertedRowId(_connection)
+ } finally {
+ _stmt.close()
+ }
}
}
}
public override fun insertPublisherMaybe(id: String, name: String): Maybe<Long> {
val _sql: String = "INSERT INTO MyEntity (pk, other) VALUES (?, ?)"
- return createMaybe(__db, false, true) { _connection ->
- val _stmt: SQLiteStatement = _connection.prepare(_sql)
- try {
- var _argIndex: Int = 1
- _stmt.bindText(_argIndex, id)
- _argIndex = 2
- _stmt.bindText(_argIndex, name)
- _stmt.step()
- getLastInsertedRowId(_connection)
- } finally {
- _stmt.close()
+ return __rxDaoReturnTypeConverters.convertMaybe(__db) {
+ performSuspending(__db, false, true) { _connection ->
+ val _stmt: SQLiteStatement = _connection.prepare(_sql)
+ try {
+ var _argIndex: Int = 1
+ _stmt.bindText(_argIndex, id)
+ _argIndex = 2
+ _stmt.bindText(_argIndex, name)
+ _stmt.step()
+ getLastInsertedRowId(_connection)
+ } finally {
+ _stmt.close()
+ }
}
}
}
public override fun insertPublisherCompletable(id: String, name: String): Completable {
val _sql: String = "INSERT INTO MyEntity (pk, other) VALUES (?, ?)"
- return createCompletable(__db, false, true) { _connection ->
- val _stmt: SQLiteStatement = _connection.prepare(_sql)
- try {
- var _argIndex: Int = 1
- _stmt.bindText(_argIndex, id)
- _argIndex = 2
- _stmt.bindText(_argIndex, name)
- _stmt.step()
- } finally {
- _stmt.close()
+ return __rxDaoReturnTypeConverters.convertCompletable(__db) {
+ performSuspending(__db, false, true) { _connection ->
+ val _stmt: SQLiteStatement = _connection.prepare(_sql)
+ try {
+ var _argIndex: Int = 1
+ _stmt.bindText(_argIndex, id)
+ _argIndex = 2
+ _stmt.bindText(_argIndex, name)
+ _stmt.step()
+ } finally {
+ _stmt.close()
+ }
+ kotlin.Unit
}
}
}
diff --git a/settings.gradle b/settings.gradle
index c227496..9cd903c 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -1329,8 +1329,8 @@
includeProject(":noto-emoji-compat-flatbuffers", new File(externalRoot, "noto-fonts/emoji-compat-flatbuffers"), [BuildType.MAIN, BuildType.COMPOSE])
includeProject(":compose-hero-benchmarks", new File(externalRoot, "compose-hero-benchmarks"), [BuildType.COMPOSE])
-includeProject(":compose-hero-benchmarks:poxedex-compose", new File(externalRoot, "compose-hero-benchmarks/poxedex-compose"), [BuildType.COMPOSE])
-includeProject(":compose-hero-benchmarks:poxedex-compose:app", new File(externalRoot, "compose-hero-benchmarks/poxedex-compose/app"), [BuildType.COMPOSE])
+includeProject(":compose-hero-benchmarks:pokedex-compose", new File(externalRoot, "compose-hero-benchmarks/pokedex-compose"), [BuildType.COMPOSE])
+includeProject(":compose-hero-benchmarks:pokedex-compose:app", new File(externalRoot, "compose-hero-benchmarks/pokedex-compose/app"), [BuildType.COMPOSE])
includeProject(":compose-hero-benchmarks:pokedex-views", new File(externalRoot, "compose-hero-benchmarks/pokedex-views"), [BuildType.COMPOSE])
includeProject(":compose-hero-benchmarks:pokedex-views:app", new File(externalRoot, "compose-hero-benchmarks/pokedex-views/app"), [BuildType.COMPOSE])
diff --git a/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/ScaffoldDemos.kt b/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/ScaffoldDemos.kt
index bb393e5..78fc67c 100644
--- a/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/ScaffoldDemos.kt
+++ b/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/ScaffoldDemos.kt
@@ -17,7 +17,6 @@
package androidx.wear.compose.material3.demos
import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -261,9 +260,26 @@
HorizontalPagerScaffold(pagerState = horizontalPagerState, modifier = Modifier.fillMaxSize()) {
HorizontalPager(state = horizontalPagerState) { pageIndex ->
VerticalPagerScaffold(pagerState = verticalPagerStates[pageIndex]) {
- VerticalPager(state = verticalPagerStates[pageIndex]) { innerPage ->
- Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
- Text("Page #$pageIndex-$innerPage")
+ VerticalPager(
+ state = verticalPagerStates[pageIndex],
+ flingBehavior =
+ PagerScaffoldDefaults.snapWithSpringFlingBehavior(
+ state = verticalPagerStates[pageIndex]
+ ),
+ ) { innerPage ->
+ AnimatedPage(
+ pageIndex = innerPage,
+ pagerState = verticalPagerStates[pageIndex],
+ ) {
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text("Page #$pageIndex-$innerPage")
+ Spacer(modifier = Modifier.height(16.dp))
+ Button(onClick = {}) { Text("Button #$pageIndex-$innerPage") }
+ }
}
}
}
diff --git a/xr/arcore/arcore/src/main/kotlin/androidx/xr/arcore/Anchor.kt b/xr/arcore/arcore/src/main/kotlin/androidx/xr/arcore/Anchor.kt
index 35864d1..cca8112 100644
--- a/xr/arcore/arcore/src/main/kotlin/androidx/xr/arcore/Anchor.kt
+++ b/xr/arcore/arcore/src/main/kotlin/androidx/xr/arcore/Anchor.kt
@@ -66,6 +66,8 @@
return AnchorCreateResourcesExhausted()
} catch (e: AnchorNotTrackingException) {
return AnchorCreateTrackingUnavailable()
+ } catch (e: IllegalStateException) {
+ return AnchorCreateIllegalState()
}
val anchor = generateAnchor(runtimeAnchor, perceptionStateExtender.xrResourcesManager)
return AnchorCreateSuccess(anchor)
diff --git a/xr/compose/compose/api/current.txt b/xr/compose/compose/api/current.txt
index b20682e..680f97c 100644
--- a/xr/compose/compose/api/current.txt
+++ b/xr/compose/compose/api/current.txt
@@ -690,14 +690,11 @@
method @BytecodeOnly public static androidx.xr.compose.subspace.layout.SubspaceModifier absolutePadding-0tawpAo(androidx.xr.compose.subspace.layout.SubspaceModifier, float, float, float, float, float, float);
method @BytecodeOnly public static androidx.xr.compose.subspace.layout.SubspaceModifier! absolutePadding-0tawpAo$default(androidx.xr.compose.subspace.layout.SubspaceModifier!, float, float, float, float, float, float, int, Object!);
method @KotlinOnly public static androidx.xr.compose.subspace.layout.SubspaceModifier padding(androidx.xr.compose.subspace.layout.SubspaceModifier, androidx.compose.ui.unit.Dp all);
- method @KotlinOnly @Deprecated public static androidx.xr.compose.subspace.layout.SubspaceModifier padding(androidx.xr.compose.subspace.layout.SubspaceModifier, optional androidx.compose.ui.unit.Dp left, optional androidx.compose.ui.unit.Dp right);
method @KotlinOnly public static androidx.xr.compose.subspace.layout.SubspaceModifier padding(androidx.xr.compose.subspace.layout.SubspaceModifier, optional androidx.compose.ui.unit.Dp horizontal, optional androidx.compose.ui.unit.Dp vertical, optional androidx.compose.ui.unit.Dp depth);
method @KotlinOnly public static androidx.xr.compose.subspace.layout.SubspaceModifier padding(androidx.xr.compose.subspace.layout.SubspaceModifier, optional androidx.compose.ui.unit.Dp start, optional androidx.compose.ui.unit.Dp top, optional androidx.compose.ui.unit.Dp end, optional androidx.compose.ui.unit.Dp bottom, optional androidx.compose.ui.unit.Dp front, optional androidx.compose.ui.unit.Dp back);
method @BytecodeOnly public static androidx.xr.compose.subspace.layout.SubspaceModifier padding-0tawpAo(androidx.xr.compose.subspace.layout.SubspaceModifier, float, float, float, float, float, float);
method @BytecodeOnly public static androidx.xr.compose.subspace.layout.SubspaceModifier! padding-0tawpAo$default(androidx.xr.compose.subspace.layout.SubspaceModifier!, float, float, float, float, float, float, int, Object!);
method @BytecodeOnly public static androidx.xr.compose.subspace.layout.SubspaceModifier padding-3ABfNKs(androidx.xr.compose.subspace.layout.SubspaceModifier, float);
- method @BytecodeOnly @Deprecated public static androidx.xr.compose.subspace.layout.SubspaceModifier padding-VpY3zN4(androidx.xr.compose.subspace.layout.SubspaceModifier, float, float);
- method @BytecodeOnly @Deprecated public static androidx.xr.compose.subspace.layout.SubspaceModifier! padding-VpY3zN4$default(androidx.xr.compose.subspace.layout.SubspaceModifier!, float, float, int, Object!);
method @BytecodeOnly public static androidx.xr.compose.subspace.layout.SubspaceModifier padding-qQh39rQ(androidx.xr.compose.subspace.layout.SubspaceModifier, float, float, float);
method @BytecodeOnly public static androidx.xr.compose.subspace.layout.SubspaceModifier! padding-qQh39rQ$default(androidx.xr.compose.subspace.layout.SubspaceModifier!, float, float, float, int, Object!);
}
diff --git a/xr/compose/compose/api/restricted_current.txt b/xr/compose/compose/api/restricted_current.txt
index 35991ca..977e9ca 100644
--- a/xr/compose/compose/api/restricted_current.txt
+++ b/xr/compose/compose/api/restricted_current.txt
@@ -721,14 +721,11 @@
method @BytecodeOnly public static androidx.xr.compose.subspace.layout.SubspaceModifier absolutePadding-0tawpAo(androidx.xr.compose.subspace.layout.SubspaceModifier, float, float, float, float, float, float);
method @BytecodeOnly public static androidx.xr.compose.subspace.layout.SubspaceModifier! absolutePadding-0tawpAo$default(androidx.xr.compose.subspace.layout.SubspaceModifier!, float, float, float, float, float, float, int, Object!);
method @KotlinOnly public static androidx.xr.compose.subspace.layout.SubspaceModifier padding(androidx.xr.compose.subspace.layout.SubspaceModifier, androidx.compose.ui.unit.Dp all);
- method @KotlinOnly @Deprecated public static androidx.xr.compose.subspace.layout.SubspaceModifier padding(androidx.xr.compose.subspace.layout.SubspaceModifier, optional androidx.compose.ui.unit.Dp left, optional androidx.compose.ui.unit.Dp right);
method @KotlinOnly public static androidx.xr.compose.subspace.layout.SubspaceModifier padding(androidx.xr.compose.subspace.layout.SubspaceModifier, optional androidx.compose.ui.unit.Dp horizontal, optional androidx.compose.ui.unit.Dp vertical, optional androidx.compose.ui.unit.Dp depth);
method @KotlinOnly public static androidx.xr.compose.subspace.layout.SubspaceModifier padding(androidx.xr.compose.subspace.layout.SubspaceModifier, optional androidx.compose.ui.unit.Dp start, optional androidx.compose.ui.unit.Dp top, optional androidx.compose.ui.unit.Dp end, optional androidx.compose.ui.unit.Dp bottom, optional androidx.compose.ui.unit.Dp front, optional androidx.compose.ui.unit.Dp back);
method @BytecodeOnly public static androidx.xr.compose.subspace.layout.SubspaceModifier padding-0tawpAo(androidx.xr.compose.subspace.layout.SubspaceModifier, float, float, float, float, float, float);
method @BytecodeOnly public static androidx.xr.compose.subspace.layout.SubspaceModifier! padding-0tawpAo$default(androidx.xr.compose.subspace.layout.SubspaceModifier!, float, float, float, float, float, float, int, Object!);
method @BytecodeOnly public static androidx.xr.compose.subspace.layout.SubspaceModifier padding-3ABfNKs(androidx.xr.compose.subspace.layout.SubspaceModifier, float);
- method @BytecodeOnly @Deprecated public static androidx.xr.compose.subspace.layout.SubspaceModifier padding-VpY3zN4(androidx.xr.compose.subspace.layout.SubspaceModifier, float, float);
- method @BytecodeOnly @Deprecated public static androidx.xr.compose.subspace.layout.SubspaceModifier! padding-VpY3zN4$default(androidx.xr.compose.subspace.layout.SubspaceModifier!, float, float, int, Object!);
method @BytecodeOnly public static androidx.xr.compose.subspace.layout.SubspaceModifier padding-qQh39rQ(androidx.xr.compose.subspace.layout.SubspaceModifier, float, float, float);
method @BytecodeOnly public static androidx.xr.compose.subspace.layout.SubspaceModifier! padding-qQh39rQ$default(androidx.xr.compose.subspace.layout.SubspaceModifier!, float, float, float, int, Object!);
}
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/Padding.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/Padding.kt
index 77a8711..89f6e86 100644
--- a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/Padding.kt
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/Padding.kt
@@ -30,29 +30,6 @@
import androidx.xr.runtime.math.Vector3
/**
- * Apply additional space along each edge of the content in [Dp]: [left], [right]. Padding is
- * applied before content measurement and takes precedence; content may only be as large as the
- * remaining space.
- *
- * Negative padding is not permitted — it will cause [IllegalArgumentException].
- */
-@Deprecated(
- message = "Use padding with start and end instead",
- replaceWith = ReplaceWith("padding(start, top, end, bottom, front, bottom)"),
-)
-public fun SubspaceModifier.padding(left: Dp = 0.dp, right: Dp = 0.dp): SubspaceModifier =
- this then
- SubspacePaddingElement(
- start = left,
- top = 0.dp,
- end = right,
- bottom = 0.dp,
- front = 0.dp,
- back = 0.dp,
- rtlAware = false,
- )
-
-/**
* Apply additional space along each edge of the content in [Dp]: [start], [top], [end], [bottom],
* [front] and [back]. The start and end edges will be determined by the current [LayoutDirection].
* Padding is applied before content measurement and takes precedence; content may only be as large
diff --git a/xr/glimmer/glimmer/src/androidTest/kotlin/androidx/xr/glimmer/CardTest.kt b/xr/glimmer/glimmer/src/androidTest/kotlin/androidx/xr/glimmer/CardTest.kt
index 6a978fd..89fcc3d 100644
--- a/xr/glimmer/glimmer/src/androidTest/kotlin/androidx/xr/glimmer/CardTest.kt
+++ b/xr/glimmer/glimmer/src/androidTest/kotlin/androidx/xr/glimmer/CardTest.kt
@@ -401,7 +401,7 @@
rule.onNodeWithTag("header").apply {
with(getBoundsInRoot()) {
- width.assertIsEqualTo(cardBounds.width - 16.dp - 16.dp, "width")
+ width.assertIsEqualTo(cardBounds.width - Spacing.Medium * 2, "width")
height.assertIsEqualTo(width / 1.6f, "height")
}
}
@@ -422,7 +422,7 @@
rule.onNodeWithTag("header").apply {
with(getBoundsInRoot()) {
- width.assertIsEqualTo(cardBounds.width - 16.dp - 16.dp, "width")
+ width.assertIsEqualTo(cardBounds.width - Spacing.Medium * 2, "width")
height.assertIsEqualTo(width / 1.6f, "height")
}
}
@@ -443,7 +443,7 @@
rule.onNodeWithTag("header").apply {
with(getBoundsInRoot()) {
- width.assertIsEqualTo(cardBounds.width - 16.dp - 16.dp, "width")
+ width.assertIsEqualTo(cardBounds.width - Spacing.Medium * 2, "width")
height.assertIsEqualTo(10.dp, "height")
}
}
@@ -465,7 +465,7 @@
rule.onNodeWithTag("header").apply {
with(getBoundsInRoot()) {
width.assertIsEqualTo(10.dp, "width")
- height.assertIsEqualTo((cardBounds.width - 16.dp - 16.dp) / 1.6f, "height")
+ height.assertIsEqualTo((cardBounds.width - Spacing.Medium * 2) / 1.6f, "height")
}
}
}
@@ -506,8 +506,8 @@
rule.onNodeWithTag("header").apply {
with(getBoundsInRoot()) {
// Height and width should be unmodified
- height.assertIsEqualTo(50.dp - 16.dp - 16.dp, "height")
- width.assertIsEqualTo(150.dp - 16.dp - 16.dp, "width")
+ height.assertIsEqualTo(50.dp - Spacing.Medium * 2, "height")
+ width.assertIsEqualTo(150.dp - Spacing.Medium * 2, "width")
}
}
}
@@ -559,11 +559,11 @@
// Default card width fills the maximum width
width.assertIsEqualTo(rule.onRoot().getBoundsInRoot().width, "total card width")
// Overall card height should be determined by the size of the card content and action
+ val totalOuterAndInnerPadding = (Spacing.Medium + Spacing.Small) * 2
height.assertIsEqualTo(
(actionBounds.height - /* overlapping offset */ 16.dp) +
cardContentBounds.height +
- 24.dp +
- 24.dp,
+ totalOuterAndInnerPadding,
"total card height",
)
}
@@ -600,15 +600,18 @@
}
rule.onNodeWithTag("cardContent").getBoundsInRoot().apply {
- width.assertIsEqualTo(cardAndActionBounds.width - 24.dp - 24.dp, "card content width")
+ val totalOuterAndInnerPadding = (Spacing.Medium + Spacing.Small) * 2
+ width.assertIsEqualTo(
+ cardAndActionBounds.width - totalOuterAndInnerPadding,
+ "card content width",
+ )
// Card content should be allowed to fill up the height left from the
// cardAndActionBounds after accounting for the space the action takes up in the
// layout (and the content padding)
height.assertIsEqualTo(
cardAndActionBounds.height -
(actionBounds.height - /* overlapping offset */ 16.dp) -
- 24.dp -
- 24.dp,
+ totalOuterAndInnerPadding,
"card content height",
)
}
@@ -673,7 +676,7 @@
)
(contentBounds.left - cardBounds.left).assertIsEqualTo(
- 24.dp,
+ Spacing.Medium + Spacing.Small,
"Padding between the start of the card and the start of the content.",
)
@@ -711,22 +714,22 @@
// Title should be top aligned when the height of the content, title, and subtitle is
// greater than minimum card height
(titleBounds.top - cardBounds.top).assertIsEqualTo(
- 24.dp,
+ Spacing.Medium + Spacing.Small,
"Padding between top of card and top of title.",
)
(titleBounds.left - cardBounds.left).assertIsEqualTo(
- 24.dp,
+ Spacing.Medium + Spacing.Small,
"Padding between the start of the card and the start of the title.",
)
(subtitleBounds.left - cardBounds.left).assertIsEqualTo(
- 24.dp,
+ Spacing.Medium + Spacing.Small,
"Padding between the start of the card and the start of the subtitle.",
)
(contentBounds.left - cardBounds.left).assertIsEqualTo(
- 24.dp,
+ Spacing.Medium + Spacing.Small,
"Padding between the start of the card and the start of the content.",
)
@@ -741,7 +744,7 @@
)
(cardBounds.bottom - contentBounds.bottom).assertIsEqualTo(
- 24.dp,
+ Spacing.Medium + Spacing.Small,
"Padding between bottom of card and bottom of content.",
)
@@ -791,12 +794,12 @@
rule.onNodeWithTag("card", useUnmergedTree = true).getUnclippedBoundsInRoot()
(leadingIconBounds.top - cardBounds.top).assertIsEqualTo(
- 24.dp,
+ Spacing.Medium + Spacing.Small,
"Padding between top of card and top of leading icon.",
)
(leadingIconBounds.left - cardBounds.left).assertIsEqualTo(
- 24.dp,
+ Spacing.Medium + Spacing.Small,
"Padding between start of card and start of leading icon.",
)
@@ -808,24 +811,25 @@
)
(contentBounds.left - leadingIconBounds.right).assertIsEqualTo(
- 12.dp,
+ Spacing.Medium,
"Padding between end of leading icon and start of content.",
)
(trailingIconBounds.top - cardBounds.top).assertIsEqualTo(
- 24.dp,
+ Spacing.Medium + Spacing.Small,
"Padding between top of card and top of trailing icon.",
)
(cardBounds.right - trailingIconBounds.right).assertIsEqualTo(
- 24.dp,
+ Spacing.Medium + Spacing.Small,
"Padding between end of trailing icon and end of card.",
)
// The width should fill the max width, like with the spacer
cardBounds.width.assertIsEqualTo(spacerBounds.width, "width of card.")
+ val totalOuterAndInnerPadding = (Spacing.Medium + Spacing.Small) * 2
cardBounds.height.assertIsEqualTo(
- /* vertical padding * 2 + icon height*/ (24 + 24 + 56).dp,
+ /* vertical padding * 2 + icon height*/ totalOuterAndInnerPadding + 56.dp,
"height of card.",
)
}
@@ -833,13 +837,13 @@
@Test
fun positioning_header() {
rule.setGlimmerThemeContent {
- Column {
+ Column(Modifier.width(300.dp)) {
Spacer(Modifier.height(10.dp).fillMaxWidth().testTag("spacer"))
Card(
modifier = Modifier.testTag("card"),
header = {
Image(
- placeholderImagePainter(Size(1000f, 1000f)),
+ placeholderImagePainter(Size(width = 1000f, height = 1000f)),
"Localized description",
modifier = Modifier.testTag("header"),
contentScale = ContentScale.FillWidth,
@@ -861,32 +865,32 @@
rule.onNodeWithTag("card", useUnmergedTree = true).getUnclippedBoundsInRoot()
(headerBounds.top - cardBounds.top).assertIsEqualTo(
- 16.dp,
+ Spacing.Medium,
"Padding between top of card and top of header image.",
)
(headerBounds.left - cardBounds.left).assertIsEqualTo(
- 16.dp,
+ Spacing.Medium,
"Padding between the start of the card and the start of the header image.",
)
(cardBounds.right - headerBounds.right).assertIsEqualTo(
- 16.dp,
+ Spacing.Medium,
"Padding between the end of the header image and the end of the card.",
)
(contentBounds.left - cardBounds.left).assertIsEqualTo(
- 24.dp,
+ Spacing.Medium + Spacing.Small,
"Padding between the start of the card and the start of the content.",
)
(contentBounds.top - headerBounds.bottom).assertIsEqualTo(
- 8.dp,
+ Spacing.Small,
"Padding between the bottom of the header image and the top of the content.",
)
(cardBounds.bottom - contentBounds.bottom).assertIsEqualTo(
- 24.dp,
+ Spacing.Medium + Spacing.Small,
"Padding between bottom of card and bottom of content.",
)
@@ -929,7 +933,7 @@
)
(contentBounds.left - cardBounds.left).assertIsEqualTo(
- 24.dp,
+ Spacing.Medium + Spacing.Small,
"Padding between the start of the card and the start of the content.",
)
@@ -1002,34 +1006,34 @@
rule.onNodeWithTag("card", useUnmergedTree = true).getUnclippedBoundsInRoot()
(leadingIconBounds.top - cardBounds.top).assertIsEqualTo(
- 24.dp,
+ Spacing.Medium + Spacing.Small,
"Padding between top of card and top of leading icon.",
)
(leadingIconBounds.left - cardBounds.left).assertIsEqualTo(
- 24.dp,
+ Spacing.Medium + Spacing.Small,
"Padding between start of card and start of leading icon.",
)
// Title should be top aligned when the height of the content, title, and subtitle is
// greater than minimum card height
(titleBounds.top - cardBounds.top).assertIsEqualTo(
- 24.dp,
+ Spacing.Medium + Spacing.Small,
"Padding between top of card and top of title.",
)
(titleBounds.left - leadingIconBounds.right).assertIsEqualTo(
- 12.dp,
+ Spacing.Medium,
"Padding between end of leading icon and start of title.",
)
(subtitleBounds.left - leadingIconBounds.right).assertIsEqualTo(
- 12.dp,
+ Spacing.Medium,
"Padding between end of leading icon and start of subtitle.",
)
(contentBounds.left - leadingIconBounds.right).assertIsEqualTo(
- 12.dp,
+ Spacing.Medium,
"Padding between end of leading icon and start of content.",
)
@@ -1044,17 +1048,17 @@
)
(cardBounds.bottom - contentBounds.bottom).assertIsEqualTo(
- 24.dp,
+ Spacing.Medium + Spacing.Small,
"Padding between bottom of card and bottom of content.",
)
(trailingIconBounds.top - cardBounds.top).assertIsEqualTo(
- 24.dp,
+ Spacing.Medium + Spacing.Small,
"Padding between top of card and top of trailing icon.",
)
(cardBounds.right - trailingIconBounds.right).assertIsEqualTo(
- 24.dp,
+ Spacing.Medium + Spacing.Small,
"Padding between end of trailing icon and end of card.",
)
@@ -1068,7 +1072,7 @@
@Test
fun positioning_titleAndSubtitle_withImageAndIcons() {
rule.setGlimmerThemeContent {
- Column {
+ Column(Modifier.width(300.dp)) {
Spacer(Modifier.height(10.dp).fillMaxWidth().testTag("spacer"))
Card(
modifier = Modifier.testTag("card"),
@@ -1120,47 +1124,47 @@
rule.onNodeWithTag("card", useUnmergedTree = true).getUnclippedBoundsInRoot()
(headerBounds.top - cardBounds.top).assertIsEqualTo(
- 16.dp,
+ Spacing.Medium,
"Padding between top of card and top of header image.",
)
(headerBounds.left - cardBounds.left).assertIsEqualTo(
- 16.dp,
+ Spacing.Medium,
"Padding between the start of the card and the start of the header image.",
)
(cardBounds.right - headerBounds.right).assertIsEqualTo(
- 16.dp,
+ Spacing.Medium,
"Padding between the end of the header image and the end of the card.",
)
(leadingIconBounds.top - headerBounds.bottom).assertIsEqualTo(
- 8.dp,
+ Spacing.Small,
"Padding between the bottom of header image and top of leading icon.",
)
(leadingIconBounds.left - cardBounds.left).assertIsEqualTo(
- 24.dp,
+ Spacing.Medium + Spacing.Small,
"Padding between start of card and start of leading icon.",
)
(titleBounds.top - headerBounds.bottom).assertIsEqualTo(
- 8.dp,
+ Spacing.Small,
"Padding between the bottom of header image and top of title.",
)
(titleBounds.left - leadingIconBounds.right).assertIsEqualTo(
- 12.dp,
+ Spacing.Medium,
"Padding between end of leading icon and start of title.",
)
(subtitleBounds.left - leadingIconBounds.right).assertIsEqualTo(
- 12.dp,
+ Spacing.Medium,
"Padding between end of leading icon and start of subtitle.",
)
(contentBounds.left - leadingIconBounds.right).assertIsEqualTo(
- 12.dp,
+ Spacing.Medium,
"Padding between end of leading icon and start of content.",
)
@@ -1175,17 +1179,17 @@
)
(cardBounds.bottom - contentBounds.bottom).assertIsEqualTo(
- 24.dp,
+ Spacing.Medium + Spacing.Small,
"Padding between bottom of card and bottom of content.",
)
(trailingIconBounds.top - headerBounds.bottom).assertIsEqualTo(
- 8.dp,
+ Spacing.Small,
"Padding between the bottom of header image and top of trailing icon.",
)
(cardBounds.right - trailingIconBounds.right).assertIsEqualTo(
- 24.dp,
+ Spacing.Medium + Spacing.Small,
"Padding between end of trailing icon and end of card.",
)
@@ -1198,7 +1202,7 @@
@Test
fun positioning_titleAndSubtitle_withImageAndIcons_withAction() {
rule.setGlimmerThemeContent {
- Column {
+ Column(Modifier.width(300.dp)) {
Spacer(Modifier.height(10.dp).fillMaxWidth().testTag("spacer"))
Card(
action = { Button(onClick = {}, Modifier.testTag("action")) { Text("Send") } },
@@ -1253,47 +1257,47 @@
rule.onNodeWithTag("card", useUnmergedTree = true).getUnclippedBoundsInRoot()
(headerBounds.top - cardBounds.top).assertIsEqualTo(
- 16.dp,
+ Spacing.Medium,
"Padding between top of card and top of header image.",
)
(headerBounds.left - cardBounds.left).assertIsEqualTo(
- 16.dp,
+ Spacing.Medium,
"Padding between the start of the card and the start of the header image.",
)
(cardBounds.right - headerBounds.right).assertIsEqualTo(
- 16.dp,
+ Spacing.Medium,
"Padding between the end of the header image and the end of the card.",
)
(leadingIconBounds.top - headerBounds.bottom).assertIsEqualTo(
- 8.dp,
+ Spacing.Small,
"Padding between the bottom of header image and top of leading icon.",
)
(leadingIconBounds.left - cardBounds.left).assertIsEqualTo(
- 24.dp,
+ Spacing.Medium + Spacing.Small,
"Padding between start of card and start of leading icon.",
)
(titleBounds.top - headerBounds.bottom).assertIsEqualTo(
- 8.dp,
+ Spacing.Small,
"Padding between the bottom of header image and top of title.",
)
(titleBounds.left - leadingIconBounds.right).assertIsEqualTo(
- 12.dp,
+ Spacing.Medium,
"Padding between end of leading icon and start of title.",
)
(subtitleBounds.left - leadingIconBounds.right).assertIsEqualTo(
- 12.dp,
+ Spacing.Medium,
"Padding between end of leading icon and start of subtitle.",
)
(contentBounds.left - leadingIconBounds.right).assertIsEqualTo(
- 12.dp,
+ Spacing.Medium,
"Padding between end of leading icon and start of content.",
)
@@ -1308,18 +1312,18 @@
)
(trailingIconBounds.top - headerBounds.bottom).assertIsEqualTo(
- 8.dp,
+ Spacing.Small,
"Padding between the bottom of header image and top of trailing icon.",
)
(cardBounds.right - trailingIconBounds.right).assertIsEqualTo(
- 24.dp,
+ Spacing.Medium + Spacing.Small,
"Padding between end of trailing icon and end of card.",
)
(actionBounds.top).assertIsEqualTo(
// Padding - offset
- contentBounds.bottom + 24.dp - 16.dp,
+ contentBounds.bottom + Spacing.Medium + Spacing.Small - 16.dp,
"Space between the top of the action and the bottom of the content",
)
@@ -1343,7 +1347,7 @@
@Test
fun positioning_titleAndSubtitle_withIcons_longText() {
rule.setGlimmerThemeContent {
- Column {
+ Column(Modifier.width(300.dp)) {
Spacer(Modifier.height(10.dp).fillMaxWidth().testTag("spacer"))
Card(
modifier = Modifier.testTag("card"),
@@ -1385,34 +1389,34 @@
rule.onNodeWithTag("card", useUnmergedTree = true).getUnclippedBoundsInRoot()
(leadingIconBounds.top - cardBounds.top).assertIsEqualTo(
- 24.dp,
+ Spacing.Medium + Spacing.Small,
"Padding between top of card and top of leading icon.",
)
(leadingIconBounds.left - cardBounds.left).assertIsEqualTo(
- 24.dp,
+ Spacing.Medium + Spacing.Small,
"Padding between start of card and start of leading icon.",
)
// Title should be top aligned when the height of the content, title, and subtitle is
// greater than minimum card height
(titleBounds.top - cardBounds.top).assertIsEqualTo(
- 24.dp,
+ Spacing.Medium + Spacing.Small,
"Padding between top of card and top of title.",
)
(titleBounds.left - leadingIconBounds.right).assertIsEqualTo(
- 12.dp,
+ Spacing.Medium,
"Padding between end of leading icon and start of title.",
)
(subtitleBounds.left - leadingIconBounds.right).assertIsEqualTo(
- 12.dp,
+ Spacing.Medium,
"Padding between end of leading icon and start of subtitle.",
)
(contentBounds.left - leadingIconBounds.right).assertIsEqualTo(
- 12.dp,
+ Spacing.Medium,
"Padding between end of leading icon and start of content.",
)
@@ -1427,17 +1431,17 @@
)
(cardBounds.bottom - contentBounds.bottom).assertIsEqualTo(
- 24.dp,
+ Spacing.Medium + Spacing.Small,
"Padding between bottom of card and bottom of content.",
)
(trailingIconBounds.top - cardBounds.top).assertIsEqualTo(
- 24.dp,
+ Spacing.Medium + Spacing.Small,
"Padding between top of card and top of trailing icon.",
)
(cardBounds.right - trailingIconBounds.right).assertIsEqualTo(
- 24.dp,
+ Spacing.Medium + Spacing.Small,
"Padding between end of trailing icon and end of card.",
)
diff --git a/xr/glimmer/glimmer/src/main/java/androidx/xr/glimmer/Card.kt b/xr/glimmer/glimmer/src/main/java/androidx/xr/glimmer/Card.kt
index b323498..3ad0237 100644
--- a/xr/glimmer/glimmer/src/main/java/androidx/xr/glimmer/Card.kt
+++ b/xr/glimmer/glimmer/src/main/java/androidx/xr/glimmer/Card.kt
@@ -552,7 +552,7 @@
* container. Note that there is additional padding applied around the content / text / icons
* inside a card, this only represents the outer padding for the entire content.
*/
- public val ContentPadding: PaddingValues = PaddingValues(16.dp)
+ public val ContentPadding: PaddingValues = PaddingValues(Spacing.Medium)
/** The default shape of [Card], which determines its corner radius. */
public val shape: Shape
@@ -563,10 +563,10 @@
private val MinimumHeight = 80.dp
/** Spacing between icons and the text in a [Card] */
-private val IconSpacing = 12.dp
+private val IconSpacing = Spacing.Medium
/** Padding around the internal content (text / icons), but not added around header images. */
-private val InnerPadding = 8.dp
+private val InnerPadding = Spacing.Small
/** Spacing between title / subtitle / body text */
private val TextVerticalSpacing = 3.dp
diff --git a/xr/glimmer/glimmer/src/main/java/androidx/xr/glimmer/Spacing.kt b/xr/glimmer/glimmer/src/main/java/androidx/xr/glimmer/Spacing.kt
new file mode 100644
index 0000000..f6aa80b
--- /dev/null
+++ b/xr/glimmer/glimmer/src/main/java/androidx/xr/glimmer/Spacing.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2026 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.xr.glimmer
+
+import androidx.compose.ui.unit.dp
+
+/** The set of standard spacing constants used across Glimmer components */
+internal object Spacing {
+
+ val ExtraSmall = 6.dp
+
+ val Small = 8.dp
+
+ val Medium = 12.dp
+
+ val Large = 16.dp
+
+ val ExtraLarge = 20.dp
+}
diff --git a/xr/runtime/runtime-interfaces/build.gradle b/xr/runtime/runtime-interfaces/build.gradle
index e0faaf0..2edc9d7 100644
--- a/xr/runtime/runtime-interfaces/build.gradle
+++ b/xr/runtime/runtime-interfaces/build.gradle
@@ -39,7 +39,7 @@
android {
- namespace = "xr.runtime.interfaces"
+ namespace = "androidx.xr.runtime.interfaces"
}
diff --git a/xr/runtime/runtime-openxr/build.gradle b/xr/runtime/runtime-openxr/build.gradle
index ef0e2ae..c512528 100644
--- a/xr/runtime/runtime-openxr/build.gradle
+++ b/xr/runtime/runtime-openxr/build.gradle
@@ -49,7 +49,7 @@
android {
- namespace = "xr.runtime.openxr"
+ namespace = "androidx.xr.runtime.openxr"
sourceSets.main {
jniLibs.srcDirs += new File(AndroidXConfig.getPrebuiltsRoot(project), "androidx/xr/native/android")
}